Infrastructure as Code and Automation Questions
Defining, provisioning, and automating infrastructure programmatically. Covers declarative IaC with Terraform and comparable tools like CloudFormation (resource and provider model, state management and remote backends, module design and reuse, workspaces, drift detection, and safe plan/apply workflows), plus the broader automation discipline: provisioning pipelines, golden-image and machine-image building, scripting glue, self-service platforms, and end-to-end environment stand-up. The authoring, lifecycle, and automation of infrastructure code that reduces manual toil across provisioning workflows.
As a senior engineer, you're asked to move a large legacy on-prem environment into IaC-managed cloud resources, essentially a hybrid-cloud migration. Walk through how you'd sequence this to keep risk low: discovery and inventory, deciding what to import versus rebuild from scratch, testing, and what the cutover runbook looks like.
Sample Answer
Direct answer
Sequence by dependency layer, not by application: foundational services first (network connectivity, identity, DNS, logging), shared services next, then non-prod workloads to prove the IaC modules work, and only then production, one tier at a time with a canary before full cutover. For each system, decide import versus rebuild based on whether it's a well-understood, cloud-native-mappable resource, in which case import, or a piece of accumulated config drift where reproducing it faithfully would just encode the debt, in which case rebuild.
Structured elaboration
Phase 1: Discovery and inventory
Automated discovery beats a spreadsheet built from memory: pull from the CMDB (configuration management database, the org's system-of-record inventory) if one exists and is trustworthy, cross-reference with what's actually running (Ansible facts or an SCCM/WSUS export for Windows, network scans for anything undocumented). The output is one canonical list per host/service: what it is, what it depends on, what depends on it, current backup/replication status, and an owner. Systems with no identifiable owner get flagged for a decision, migrate as-is or decommission, before they get IaC'd at all; don't automate ownership questions away.
Deciding import versus rebuild
- Import when the resource maps cleanly onto a cloud construct and its current config is trusted (a well-behaved VM that's really just a Linux box running a stateless service maps onto a cloud VM/instance with no surprises).
- Rebuild when the current config has drifted far enough from any documented baseline that reproducing it faithfully would mean writing IaC for years of undocumented manual tweaks, when a cloud-native replacement removes an entire operational burden (a hand-managed on-prem DB server becoming a managed database service), or when the security posture is bad enough that carrying it forward as-is isn't acceptable.
- The uncomfortable middle case, a system nobody fully understands but that's too risky to touch, gets isolated (its own network segment, tightly scoped IAM) and migrated last, with extra testing budget, rather than either extreme.
Sequencing to keep risk low
- Network and identity: VPN/DirectConnect (or equivalent) between on-prem and cloud, IAM/AD federation, so hybrid connectivity exists before anything depends on it.
- Shared services: DNS, NTP, centralized logging, backup targets, so everything migrated afterward has somewhere to report to.
- Non-production workloads: prove the IaC modules (network, compute, IAM patterns) work end to end where a mistake is cheap.
- Staging/canary for production-bound applications, using production-shaped data volumes and traffic patterns where feasible.
- Stateful systems (databases, anything with data gravity), with explicit replication and cutover planning, not treated as "just another VM."
- Production cutover, app by app, in dependency order.
Testing at each stage
- Terraform-level:
validate,fmt,tflint/checkovin CI before anything runs against real infrastructure. - Integration: apply into a sandbox, run smoke tests against the actual service and functional tests exercising real workflows, not just "did terraform apply succeed."
- Cutover rehearsal: run the actual runbook against staging at least once before running it against production, timing each step so production isn't the first execution.
The cutover runbook
An ordered checklist with explicit go/no-go gates:
- Pre-checks: final data sync started, config drift on the source system confirmed at zero, backups verified restorable via an actual test restore, on-call notified.
- Provision cloud infra via the pinned IaC module version, not "latest."
- Final data sync completes, replication lag confirmed near zero.
- Health checks and smoke tests pass against the new environment before any real traffic touches it.
- Traffic shift via DNS/load-balancer weighting, gradual, with metrics watched between each step.
- Go/no-go gate: if error rate or latency degrades beyond the pre-agreed threshold at any step, weight shifts back to on-prem immediately, no debugging in place first.
- Once fully shifted and stable for the agreed validation window, decommission the on-prem system, keeping backups per the org's retention policy.
Worked example
A concrete piece of Phase 1 for one Linux host, using two purpose-built Ansible modules rather than plain gather_facts (the standard setup module never collects open ports or installed packages, referencing a fact it doesn't produce fails silently, not loudly, which is its own trap):
# discovery-playbook.yml
- hosts: legacy_estate
gather_facts: true
tasks:
- name: Gather listening port facts
community.general.listen_ports_facts:
- name: Gather package facts
ansible.builtin.package_facts:
- name: Record OS, packages, and listening ports
ansible.builtin.set_fact:
inventory_record:
hostname: "{{ ansible_hostname }}"
os: "{{ ansible_distribution }}{{ ansible_distribution_version }}"
packages: "{{ ansible_facts.packages.keys() | list if ansible_facts.packages is defined else [] }}"
listening_ports: "{{ ansible_facts.tcp_listen | default([]) | map(attribute='port') | list }}"
- name: Append to canonical inventory
ansible.builtin.lineinfile:
path: ./inventory.jsonl
line: "{{ inventory_record | to_json }}"
create: true
community.general.listen_ports_facts (it needs netstat or ss on the target and is Linux-only, matching this "legacy_estate" group) is what actually populates ansible_facts.tcp_listen, a list of dicts with a port field, among others, which is why the field is only meaningful after this task runs, not from gather_facts alone. ansible.builtin.package_facts auto-detects the box's package manager and populates ansible_facts.packages as a dict of package name to installed-version list. lineinfile needs create: true or it refuses to touch a file that doesn't exist yet; without it, the very first run of this playbook fails before the inventory file is ever created.
Running this across the estate produces one JSON line per host, which feeds the import-versus-rebuild decision: a host whose listening ports and packages match a known, simple service profile is a strong import candidate; one that doesn't match anything documented goes into the "investigate before deciding" bucket.
Trade-offs & pitfalls
- Treating discovery as a one-time step is a common mistake: on-prem estates drift during the months a migration takes, so re-run discovery periodically through the project, not just at kickoff.
- Rebuilding too aggressively, using the migration as an excuse to modernize everything at once, multiplies risk; separate "move" from "improve" and do the improvement afterward, once the system is stable on the new platform.
- The go/no-go gate only works if the threshold is agreed before the cutover; under pressure, teams tend to talk themselves into pushing through a borderline metric rather than rolling back.
- Stateful systems are where hybrid migrations actually go wrong; budget disproportionately more testing and rehearsal time for databases and anything with data gravity than for stateless compute.
A CloudFormation stack update partially succeeds, some resources get replaced, others just updated, and now the stack is in an inconsistent state that includes a stateful resource like a database. How do you reconcile that stack and get back to a known-good state without taking the database down?
Sample Answer
Direct answer
Stop making further changes, protect the stateful resource immediately with a fresh snapshot (or a replica), and then use a CloudFormation change set, never a blind update, to see exactly what any reconciling update would do before it happens. Keep the database itself out of that reconciliation entirely by setting DeletionPolicy/UpdateReplacePolicy to Retain and, if the template and the live resource have drifted apart, adopting the existing database into the stack via resource import rather than letting CloudFormation replace it. Only cut traffic over to anything new once it is validated, so the database is never touched by an apply that could replace it.
Structured elaboration
Assess before touching anything further
- Check stack events (
aws cloudformation describe-stack-events) to see exactly which resources were replaced, which were updated in place, and which are in a failed or rollback state. - Run drift detection (
aws cloudformation detect-stack-driftthendescribe-stack-resource-drifts) to see how far the live resources have diverged from the template's understanding of them.
Protect the stateful resource first
- Take an immediate manual snapshot:
aws rds create-db-snapshot --db-instance-identifier mydb --db-snapshot-identifier pre-reconcile-YYYYMMDD. - If the engine supports it, stand up a read replica as a warm standby before making any further changes to the stack.
- Confirm automated backups and the earliest available restore point, in case a snapshot restore ends up being the fallback.
Prepare a non-destructive plan via a change set
- Generate a change set against the current template rather than applying directly:
aws cloudformation create-change-set --stack-name my-stack --template-body file://new.yml --change-set-name reconcile-cs --capabilities CAPABILITY_NAMED_IAM. - Inspect it specifically for any
Replaceaction on the database. If the change set would replace it, stop and rework the template rather than proceeding, this is the single most important check in the whole process.
Reconciling the stack's model of reality with what is actually running
| Approach | When it fits | What it costs you |
|---|---|---|
DeletionPolicy/UpdateReplacePolicy: Retain + adjust template | The database itself is healthy but the stack wants to replace it due to a property change | Requires getting the template's declared properties to match the live resource closely enough that CloudFormation stops proposing a replace |
| Resource import | Physical resource exists and is healthy, but the stack has lost track of owning it, or a related resource was replaced around it | Import is strict about matching properties exactly; a mismatch means CloudFormation still thinks something needs to change |
| Snapshot restore into a new instance, then adopt | The existing instance itself is compromised or a required change (e.g. major version upgrade) forces replacement anyway | Real downtime/cutover risk unless paired with a replica or blue-green cutover of the stateless tier pointing at the new instance |
Cutover for anything that does need to move
Where the database tier genuinely has to change (a forced replacement, a major version upgrade), prefer promoting a replica or restoring a snapshot into a new instance, validating it (schema checks, smoke tests) in parallel, and only then repointing the application via its endpoint during a short, controlled cutover, rather than letting a stack update do it as a side effect of resolving the CloudFormation inconsistency.
Worked example
Given a stack where an EC2 Auto Scaling Group was replaced successfully but the RDS instance update rolled back partway, leaving the stack in UPDATE_ROLLBACK_FAILED: first, snapshot the RDS instance immediately. Second, run drift detection to confirm the RDS instance's live properties versus what the template currently declares. Third, generate a change set against a corrected template that sets DeletionPolicy: Retain on the RDS resource and matches its declared properties to the live instance's actual configuration, confirm the change set shows zero actions against the RDS resource. Fourth, apply that change set, since it only touches the already-replaced ASG's dependent resources (for example, security group references) and leaves the database untouched. Fifth, once the stack is back to a clean state, address the original intended change (whatever caused the RDS property to want replacing) as its own follow-up change set, reviewed specifically for that risk.
Trade-offs & pitfalls
DeletionPolicy: Retainprotects against accidental replacement, but if a team forgets to set it before a risky update, that protection simply is not there when it is needed; it has to be a template default for stateful resources, not something added reactively.- Change sets add real process overhead compared to a direct update, but that overhead is the entire point for a stateful resource; skipping it to move faster is how partial-failure states like this one happen in the first place.
- Resource import is strict: even a small mismatch between the template's declared properties and the live resource's actual configuration means CloudFormation still believes a change is needed, which can silently reintroduce the same class of risk this whole process is meant to avoid.
A repo has one large Terraform entrypoint that manages several environments and shared pieces of infrastructure. The team is having merge conflicts and accidental cross-environment changes. How would you reorganize the code so engineers can work in parallel with less risk?
Sample Answer
I would separate shared logic from environment-specific entrypoints. Right now one large root module is forcing unrelated changes into the same file set, which creates merge conflicts and increases blast radius.
New structure
modules/networkmodules/appmodules/databaseenvs/devenvs/stagingenvs/prod
Each environment folder has its own backend and only calls the modules it needs. Shared values like tags or account IDs can live in small local files or a common variable file.
Why this helps
- Two engineers can edit dev and prod in parallel with less conflict
- A change to app compute does not force a network review unless inputs change
- Cross-environment mistakes are harder because each root has its own state
Example
Dev can use t3.small and prod can use m6i.large, but both call the same modules/app. That gives consistency without a monolith. I would also keep module outputs explicit so one environment cannot accidentally consume another environment’s state.
A Terraform apply died partway through and now the state looks wrong: some resource entries are missing or out of sync, and terraform plan wants to recreate things that absolutely must not be recreated. Walk me through how you'd recover.
Sample Answer
Direct answer
Treat a partially-applied Terraform run as a state-integrity incident, not something you fix by re-running apply. Stop, snapshot the current state before touching anything, and do all reconciliation work in an isolated recovery copy of state so a mistake can't reach production. Then bring state back into agreement with what's actually running, mainly with terraform import, terraform state mv, and terraform state rm, and only promote the recovered state once a plan against it shows zero unexpected creates or destroys.
The recovery framework: which command fixes which symptom
| Symptom | Command | What it actually does |
|---|---|---|
| Resource exists in the cloud but is missing from state | terraform import <address> <id> | Binds a live resource to a state address without any API calls that create or destroy it |
| Resource's state address changed (renamed, moved into a module) | terraform state mv <old> <new> | Renames/relocates a state entry, no cloud calls at all |
| State references something that's genuinely gone | terraform state rm <address> | Removes the stale entry from state only, after you've verified the resource really doesn't exist |
| You need to know if state agrees with reality before touching anything | terraform plan -refresh-only | Shows drift as a diff, proposes no changes |
The golden rule underneath all of it: import, state mv, and state rm only ever touch the state file. apply touches the real world. When state is wrong, reach for the state-only commands first; apply (even targeted) is the last step, run only after a refresh-only plan shows the picture is clean.
Worked example: rebuilding state after a partial apply
- Preserve and isolate. Copy the current (possibly corrupted) state and the last known-good backup to a separate, write-protected location. Lock the real backend so nothing else can touch it while you work.
- Stand up a recovery workspace pointed at a copy, not production:
terraform init \
-backend-config="bucket=my-tfstate-recovery-bucket" \
-backend-config="key=prod-recovery.tfstate" \
-backend-config="region=us-east-1"
- Inventory what's actually live. Use the cloud CLI or console to list real resource IDs (instance IDs, ARNs, bucket names) for everything the config is supposed to manage, and build a mapping of state address to real ID.
- Reconcile with import and state mv:
terraform import module.web.aws_instance.web[0] i-0123456789abcdef0
terraform import aws_s3_bucket.static my-bucket-name
# if an address changed (e.g. moved into a module) after backup:
terraform state mv 'aws_instance.old_name' 'module.web.aws_instance.web[0]'
- Handle anything state points at that's truly gone, after confirming it wasn't just renamed:
terraform state rm <address>. - Validate without changing anything:
terraform plan -refresh-only -out=plan.tfplan
terraform show -json plan.tfplan
Review the plan carefully. Any create or destroy action means the mismatch isn't fully resolved yet; a diff limited to attribute drift (tags, a computed field) is expected and can be reconciled by updating config or accepting the read value.
7. Promote once clean: back up production state again, acquire the real lock, and push the recovered state (terraform state push) or copy it into the production backend key. Run terraform plan once more against production; it should show no changes.
8. Close out: release locks, notify the team, rotate any temporary credentials used for the recovery, and write down what happened.
Stale lock vs corrupted state
Everything above assumes Terraform can already acquire the state lock and the problem is what's inside the state file. A stale lock is a different failure mode: the pipeline can't even start, because a prior run crashed (or was killed) mid-apply and never released its lock.
How it shows up. On an S3+DynamoDB backend, a stale lock surfaces as:
Error: Error acquiring the state lock
Lock Info:
ID: 7f3a1c2e-9e2b-4b7a-9b1a-3f2e7c5a1d90
Path: my-tfstate-bucket/prod/terraform.tfstate
Operation: OperationTypeApply
Who: ci-runner@build-42
Version: 1.x.x
Created: 2026-07-18 03:14:22 UTC
Info:
That row lives in the DynamoDB lock table; a crashed apply never gets to delete it, so every later run keeps hitting the same lock.
Before running terraform force-unlock <LOCK_ID>, verify:
- The owning run is actually dead. Check the CI system for the job matching
Whoand confirm its status is failed or terminated, not still executing. - The
OperationandCreatedtimestamp are consistent with a crash, not a live apply. ACreatedtime that lines up with a known crash a few minutes ago supports a stale lock; aCreatedtime from seconds ago means an apply is genuinely in flight. - Check with the team (deploy channel, on-call) that nobody else is intentionally mid-apply against this same state, since the lock record alone can't tell you that.
- Snapshot the current state object before unlocking, in case whatever crashed left a partial write behind.
Once all of that checks out, run terraform force-unlock <LOCK_ID>, using the exact ID from the error, against the same workspace.
Why a blind force-unlock is dangerous: the DynamoDB lock exists to serialize writes to the S3 state object. Force-unlock while a run is genuinely still applying leaves two processes that both believe they hold exclusive write access; they can both write to the same state key, the last write silently wins, and the loser's changes vanish. That is exactly how you manufacture the missing or out-of-sync state entries the rest of this answer is about recovering from, so force-unlock is a last resort after verification, not a first response to the error.
Trade-offs, pitfalls, and how the cause changes the fix
The mechanics above are the same regardless of cause, but what actually happened changes which step matters most:
- If the corruption is from an operator accidentally running
terraform state rmon the wrong resource, rather than a crash, there's no drift to reconcile from a failed apply, the fix is narrower: re-import just the specific resource(s) the operator removed. The real fix is a guardrail, not a recovery step: gatestate rmbehind peer review or a break-glass process so a fat-fingered command doesn't silently look identical to a crash. - If the partial failure came from hitting a cloud provider quota limit mid-apply, the resources created before the limit hit are already correctly in state; the danger is retrying blindly. Retrying the same apply while the quota is still exhausted just fails again on the same resource, or worse, races a partial retry against the resources that already succeeded. Fix the quota first (request an increase, free up existing capacity), confirm with a refresh-only plan, then retry.
- If the failure spans two providers (say the AWS side of an apply succeeded but a paired GCP resource failed), a naive retry re-applies the whole graph, which is only safe if every resource involved is genuinely idempotent to re-apply. Frame this recovery around idempotent retries specifically: confirm each resource type is safe to re-create-or-noop before retrying, and for anything that isn't (a resource whose create isn't idempotent, like one that always generates a new ID), reconcile it manually with import instead of retrying the apply.
- Common wrong turn under pressure: reaching for
terraform apply(even-targeted) to "fix" a mismatch. Apply mutates the world; only the state subcommands are safe to use while you don't yet trust what's actually there.
How do you make sure Terraform or CloudFormation never accidentally destroys a production resource on the next apply? Talk through the guardrails you'd put in place, from policy checks and manual approvals to how you'd recover quickly if a destroy did slip through.
Sample Answer
Direct answer
Stop an accidental production destroy with layered guardrails, not one control: a built-in per-resource lock in the config itself, an automated policy check that fails the pipeline if a plan touches a protected resource, and a human approval gate for anything that still gets through. None of these alone is sufficient, a resource-level lock protects one resource but not a whole environment, a policy check is only as good as its rule coverage, and a human approver gets fatigued reviewing the same plan every day. Layered together, each one catches what the others miss.
The guardrail layers
| Layer | Mechanism | What it stops |
|---|---|---|
| In the config itself | lifecycle { prevent_destroy = true } on specific resources | A plan that would destroy that exact resource fails to even generate, before any pipeline logic runs |
| CI: automated policy check | Sentinel / OPA against the plan JSON | Any plan containing a delete/replace on a resource tagged environment = prod (or similar), regardless of who wrote it |
| CI: plan review and gating | Automated scan of terraform show -json for delete actions on protected resources | Same class of mistake, enforced even for tools or teams that don't use a full policy engine |
| Cloud-provider level | Termination protection (EC2), resource locks (Azure), deletion protection (RDS) | A destroy issued outside Terraform entirely, or a bug in the automation itself |
| Process | Manual approval required for any prod plan containing a delete | The case everything upstream missed, or a genuinely intended but high-risk change |
Worked example: policy-as-code and a built-in resource lock
The cheapest guardrail, right in the resource block:
resource "aws_db_instance" "prod" {
# ...
lifecycle {
prevent_destroy = true
}
}
This alone stops the single most common accident, an unintended destroy of one named resource, without needing any pipeline tooling at all. It doesn't help if the resource is later removed from the config entirely (which sidesteps the lifecycle block), so it's a floor, not a ceiling.
Sentinel policy (Terraform Enterprise), denying any destroy on a prod-tagged resource:
import "tfplan/v2" as tfplan
prod_resources = func() {
result = []
for tfplan.resource_changes as rc {
if rc.change.actions contains "delete" {
if rc.change.before.tags["environment"] == "prod" {
result.append(rc)
}
}
}
return result
}()
main = rule {
length(prod_resources) is 0
}
OPA/Rego equivalent for a Kubernetes admission flow, denying deletion of a prod-labeled object:
package kubernetes.admission
deny[msg] {
input.request.operation == "DELETE"
input.request.object.metadata.labels["env"] == "prod"
msg := "Deleting a prod-labeled resource requires an approved exception"
}
CI staging: unauthenticated static checks (terraform validate, tflint, checkov) run on every PR; the policy check above runs against the generated plan before any apply job can start; non-prod environments auto-apply on merge; prod applies require a recorded manual approval step.
Trade-offs and pitfalls
- Every added gate slows deploys; apply risk-based gating, strict for prod, lighter for dev and staging, rather than the same friction everywhere, or engineers will look for ways around it.
- Tags are a control surface, not a guarantee: a resource missing its
environmenttag silently escapes tag-based policy rules, so treat "no tag" as a policy failure in its own right, not as "assume dev." prevent_destroyprotects a resource that's still in the config; it does nothing once that resource block is deleted from the config entirely, plan-level policy is what catches that case.- If a destroy does slip through: the recovery path is the same discipline as any state incident, restore from the most recent backup or snapshot (RDS snapshot, EBS snapshot, versioned S3 object), reconcile Terraform state to match what's actually there, and only then resume normal applies. Have that runbook written down before an incident, not during one.
- Policies that are too strict without an exception path get quietly bypassed under deadline pressure; build a reviewable, logged exception mechanism into the policy itself rather than leaving break-glass as an undocumented workaround.
Unlock Full Question Bank
Get access to all Infrastructure as Code and Automation interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.