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 replacedon the database withskip_final_snapshot = true. That is not a version upgrade, it is a delete and recreate with no snapshot. An in-place engine upgrade needsallow_major_version_upgradeand 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: [email protected]
│ 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_passwordis 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.