Skip to main content
Cloud Interviews advanced Lesson 10 of 10

The Infrastructure-as-Code Round

Reading a Terraform plan for what it will destroy, state and locking, the drift question, and the review comments an interviewer is waiting to hear.

The hands-on round hands you a plan output or a configuration file and asks what you notice. It is code review, and there is a specific list of things being checked.

Read the plan for what it destroys

terraform plan -out=tfplan
Terraform will perform the following actions:

  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine_version         = "15.4" -> "16.2" # forces replacement
      ~ id                     = "db-ABCDEF" -> (known after apply)
      ~ endpoint               = "prod.abc123.us-east-1.rds.amazonaws.com" -> (known after apply)
        identifier             = "prod"
      - final_snapshot_identifier = null
        skip_final_snapshot    = true
    }

  # aws_security_group.app will be updated in-place
  ~ resource "aws_security_group" "app" {
      ~ ingress {
          ~ cidr_blocks = ["10.0.0.0/16"] -> ["0.0.0.0/0"]
        }
    }

  # aws_s3_bucket.logs will be created
  + resource "aws_s3_bucket" "logs" { ... }

Plan: 2 to add, 1 to change, 1 to destroy.

Three findings, in order of how much they matter:

1. THE DATABASE WILL BE DESTROYED AND RECREATED
   engine_version forces replacement, skip_final_snapshot is true,
   and final_snapshot_identifier is null.
   → total data loss, no snapshot, no recovery.
   This is not a deploy. Stop.

2. THE SECURITY GROUP OPENS TO THE INTERNET
   10.0.0.0/16 → 0.0.0.0/0. Whatever port that is, it is now public.
   → almost certainly not intended; "updated in-place" hides it in the noise.

3. A new bucket is created. Fine, but check encryption and public access
   block are set — the plan output above shows neither.

“The line I’d stop at is must be replaced on the database with skip_final_snapshot = true. That is not a version upgrade, it is a delete and recreate with no snapshot. An in-place engine upgrade needs allow_major_version_upgrade and a maintenance window, and I’d want a manual snapshot taken before the apply regardless.”

Plan: 2 to add, 1 to change, 1 to destroy is the line everyone reads and nobody acts on. A non-zero destroy count in a production plan should require an explicit sign-off, and saying you would gate on it is a good answer.

# machine-readable, for a CI gate
terraform show -json tfplan | \
  jq -r '.resource_changes[] | select(.change.actions[] | . == "delete") | .address'
aws_db_instance.main
terraform show -json tfplan | jq '[.resource_changes[]
  | select(.change.actions | index("delete"))] | length'
1

A CI job that fails when that count exceeds zero without an approval label is the concrete version of the answer, and naming it moves you from “I would be careful” to “here is the control”.

State: the thing that actually breaks

# backend.tf — what a shared-state configuration needs
terraform {
  required_version = "~> 1.9"

  backend "s3" {
    bucket         = "acme-tfstate"
    key            = "prod/network/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"       # the lock
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"                    # pinned, not latest
    }
  }
}
terraform apply    # while a colleague is already applying

│ Error: Error acquiring the state lock

│ Error message: ConditionalCheckFailedException: The conditional request failed
│ Lock Info:
│   ID:        8f3c1a2e-9b4d-4c7f-a1e5-6d2b3f4a5c6e
│   Path:      acme-tfstate/prod/network/terraform.tfstate
│   Operation: OperationTypeApply
│   Who:       colleague@acme.com
│   Created:   2026-09-10 14:22:03.118 +0000 UTC

That error is the system working. Without the lock, both applies write state and one silently overwrites the other — leaving a state file describing infrastructure that does not exist, and a terraform destroy that misses resources you are still paying for.

Four state facts worth having ready:

STATE CONTAINS SECRETS       database passwords, generated keys, and any
                             sensitive output are stored in plain text.
                             Encrypt the bucket; restrict who can read it.
                             Never commit state to git.

SPLIT STATE BY BLAST RADIUS  network / data / application in separate state
                             files. One giant state means every apply risks
                             everything, and plan times grow to minutes.

IMPORT, DO NOT RECREATE      `terraform import` (or an import block) adopts
                             an existing resource. Recreating a live resource
                             to bring it under management is the wrong answer.

NEVER EDIT STATE BY HAND     `terraform state mv` and `rm` exist for a reason;
                             hand-editing JSON is how state gets corrupted.

Drift

terraform plan -detailed-exitcode
echo "exit code: $?"
Note: Objects have changed outside of Terraform

  # aws_security_group.app has changed
  ~ resource "aws_security_group" "app" {
      + ingress {
          + from_port   = 22
          + to_port     = 22
          + cidr_blocks = ["203.0.113.44/32"]
        }
    }

exit code: 2

Someone opened SSH from a single IP during an incident and never removed it. The next apply will silently close it — which might break their access, or might be exactly right.

-detailed-exitcode returns 0 for no changes, 1 for an error, and 2 for changes pending. That is what makes scheduled drift detection possible in CI:

# .github/workflows/drift.yml
on:
  schedule: [{cron: "0 7 * * 1-5"}]
jobs:
  drift:
    runs-on: ubuntu-latest
    permissions: {id-token: write, contents: read}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: us-east-1
      - run: terraform init && terraform plan -detailed-exitcode -lock=false

Note role-to-assume rather than stored credentials — the OIDC federation from the IAM lesson — and -lock=false because a read-only plan should not block someone’s apply.

“Drift is not automatically bad — it usually means someone fixed something under pressure. The failure is not detecting it for three months, by which point the code and reality have diverged enough that nobody trusts an apply. I’d run plan on a schedule and treat a non-zero exit as a ticket, then decide per change whether to codify it or revert it.”

The review, with the comments an interviewer wants

resource "aws_s3_bucket" "data" {
  bucket = "acme-data"
}

resource "aws_security_group" "db" {
  name = "db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_db_instance" "main" {
  identifier          = "prod"
  engine              = "postgres"
  instance_class      = "db.r6g.xlarge"
  allocated_storage   = 100
  username            = "admin"
  password            = "Passw0rd123!"
  skip_final_snapshot = true
  publicly_accessible = true
}
BLOCKING
  password hardcoded          → and it is now in git history forever, and in
                                state. Use a secrets manager with a generated
                                value, and rotate this one today.
  db open to 0.0.0.0/0        → Postgres exposed to the internet.
  publicly_accessible = true  → the same problem from the other direction.
                                Both must go.
  skip_final_snapshot         → any destroy or replace loses the data.

SHOULD FIX
  no bucket encryption        → server_side_encryption_configuration
  no public access block      → aws_s3_bucket_public_access_block
  no versioning               → recovery from accidental delete
  no deletion_protection      → on the database
  no backup_retention_period  → defaults to 1 day; probably not the intent
  no multi_az                 → it is called "prod"
  hardcoded bucket name       → collides across environments; needs a prefix
                                or a random suffix
  no tags                     → from the cost lesson: untagged spend belongs
                                to nobody
  no lifecycle {prevent_destroy} on the database

Working through that list out loud is the round. The ordering matters as much as the content — leading with the hardcoded password rather than the missing tags shows you triage.

The corrected version of the dangerous parts:

resource "random_password" "db" {
  length  = 32
  special = true
}

resource "aws_secretsmanager_secret" "db" {
  name = "${var.env}/db/master"
}

resource "aws_secretsmanager_secret_version" "db" {
  secret_id     = aws_secretsmanager_secret.db.id
  secret_string = jsonencode({ username = "admin", password = random_password.db.result })
}

resource "aws_db_instance" "main" {
  identifier                = "${var.env}-app"
  engine                    = "postgres"
  engine_version            = "16.2"
  instance_class            = var.db_instance_class
  allocated_storage         = 100
  storage_encrypted         = true
  username                  = "admin"
  password                  = random_password.db.result
  multi_az                  = var.env == "prod"
  publicly_accessible       = false
  vpc_security_group_ids    = [aws_security_group.db.id]
  db_subnet_group_name      = aws_db_subnet_group.isolated.name
  backup_retention_period   = 30
  deletion_protection       = var.env == "prod"
  skip_final_snapshot       = false
  final_snapshot_identifier = "${var.env}-app-final-${formatdate("YYYYMMDDhhmmss", timestamp())}"

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [final_snapshot_identifier]
  }

  tags = local.common_tags
}

The random_password value still lands in state — worth saying, because it is the honest caveat:

“This keeps the password out of the code, but random_password is stored in state in plain text. That is why the state bucket must be encrypted with restricted read access. If the requirement is that nobody can ever read it, the database creates its own managed password and Terraform never sees it.”

Modules and pinning

# unpinned — someone else's merge changes your next apply
module "vpc" {
  source = "git::https://github.com/acme/tf-modules.git//vpc"
}

# pinned to an immutable ref
module "vpc" {
  source = "git::https://github.com/acme/tf-modules.git//vpc?ref=v2.4.1"

  name            = "${var.env}-vpc"
  cidr            = var.vpc_cidr
  azs             = slice(data.aws_availability_zones.available.names, 0, 3)
  private_subnets = var.private_subnets
  public_subnets  = var.public_subnets

  enable_nat_gateway     = true
  single_nat_gateway     = var.env != "prod"   # one NAT in dev, one per AZ in prod
  enable_s3_endpoint     = true                # the free saving from the storage lesson

  tags = local.common_tags
}

Two things worth pointing at in that block. single_nat_gateway = var.env != "prod" encodes a cost decision in code — one NAT in development, one per AZ in production — and enable_s3_endpoint = true is the gateway endpoint that removes object-storage traffic from the NAT bill.

Environment differences belong in variables, not in copied directories. A prod/ folder that is a copy of staging/ with edits guarantees they diverge; a module called with different variables guarantees they do not.

The pipeline

1. fmt + validate       terraform fmt -check, terraform validate
2. static analysis      tfsec / checkov — catches the open security group
                        before a human has to
3. cost estimate        infracost — posts the monthly delta on the PR
4. plan                 on the PR, output posted as a comment
5. human review         reads the plan, not just the diff
6. apply                on merge, from CI, never from a laptop
7. drift detection      scheduled plan, non-zero exit opens a ticket

Steps 2 and 3 are the ones worth naming specifically. tfsec would have flagged the 0.0.0.0/0 ingress and the unencrypted bucket automatically; infracost turns “this adds a NAT gateway” into “this adds $47/month” on the pull request, before it is merged.

Step 6 is a boundary, not a convenience: if a laptop can apply to production, then the audit trail, the review, and the state locking are all optional in practice.

Recognising it

IN THE PLAN OR CODE                             SAY
"must be replaced" on a stateful resource       stop — that is data loss
skip_final_snapshot = true                      no recovery from a destroy
0.0.0.0/0 on anything but 80/443                almost certainly wrong
publicly_accessible = true on a database        wrong from the other direction
a hardcoded secret                              blocking; it is in git forever
local state, or no lock table                   concurrent applies corrupt it
unpinned module or provider version             someone else's merge breaks you
no tags                                         nobody owns the spend
one giant state file                            blast radius; split it
prod/ is a copy of staging/                     they will diverge; use variables
apply runs from a laptop                        no audit trail, no review

Practice

1. Read a plan summary line and stop there.
Plan: 2 to add, 1 to change, 1 to destroy.

Find what is being destroyed. Here it is the production database, with skip_final_snapshot = true — a delete and recreate with no recovery.

2. Apply while a colleague is applying, with local state.
With a lock: Error acquiring the state lock. (The system working.)
Without:     both write state; one silently overwrites the other.

The result is a state file describing infrastructure that does not exist, and orphaned resources you keep paying for.

3. Review the configuration and order your comments.
BLOCKING: hardcoded password, 0.0.0.0/0 on 5432, publicly_accessible,
          skip_final_snapshot
THEN:     encryption, versioning, backups, multi-AZ, tags

Leading with the password rather than the missing tags shows you triage. The ordering is scored as much as the content.

4. Reference a module without a ref.
Someone else's merge changes your next apply.

Pin to an immutable tag. It also makes upgrades a reviewable change rather than something that happens to you.

That closes the cloud track. For the surrounding rounds, see Data Engineering Interviews for pipeline and system design, AI & ML Interviews for ML system design, and Interview Preparation for the behavioural and negotiation rounds.

Frequently Asked Questions

What is the first thing to check in a Terraform plan?
The destroy and replace lines. `terraform plan` reports adds, changes, and destroys in a summary, and a resource marked "must be replaced" will be deleted and recreated — which for a database or a load balancer means downtime or data loss. Reading the summary line and then finding what forces each replacement is the habit being tested.
Why does Terraform state need locking?
Two people applying at once against the same state can interleave writes and corrupt it, leaving the file describing infrastructure that does not exist. A backend with locking — a DynamoDB table, or the native locking in newer backends — makes the second apply wait rather than race. Local state in a shared repository is the version of this that fails.
What is drift and how do you handle it?
Infrastructure changed outside the code — a console click during an incident, or another tool. `terraform plan` shows it as a diff that nobody wrote. The response is to decide whether the change was right: if it was, bring it into the code; if not, apply over it. Detecting drift on a schedule is what stops it accumulating silently.
Should modules be versioned?
Yes, and pinned by version rather than tracking a branch. An unpinned module means someone else's merge changes your next apply, which turns an unrelated deploy into an incident. Pinning also makes upgrades a deliberate, reviewable change rather than something that happens to you.