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.
What's the difference between using Terraform workspaces versus separate directories or separate state backends per environment? What tends to go wrong with each, and when would you recommend one over the other?
Sample Answer
Direct answer
For environments that are structurally identical (same modules, same resource graph, only variable values differ), Terraform workspaces are the lightweight default: one configuration, one state instance per workspace, switched with terraform workspace select. Once environments diverge, in topology, in which modules they use, or in how strictly they need to be isolated from each other, separate root configurations (each with its own explicit backend and state key, and ideally its own directory or repo) are safer, because the isolation is structural rather than something a person has to remember to check before running apply.
The three shapes this usually takes
Workspaces. Same .tf files, multiple named state instances (terraform workspace new stage), terraform.workspace interpolated into resource names/tags. No code duplication, fastest to add a new environment.
- Pitfall: the code is identical across environments, so nothing structurally stops someone from running
applyagainst the wrong workspace, only the state differs, not the guardrails. Access control also tends to be per-backend, not per-workspace: anyone with write access to the backend can touch every environment's state through it.
Separate directories, shared backend. envs/dev/, envs/staging/, envs/prod/, each with its own backend.tf and terraform.tfvars, often still in the same backend account/bucket with a distinct key per environment.
- Pitfall: still allows credential and blast-radius overlap across environments if IAM isn't separately scoped per directory's backend key, and more files means more copy-paste drift between near-identical
main.tfs unless the shared logic is factored into modules.
Separate repos, fully separate remote state (often separate cloud accounts). Each environment is its own repository with its own backend, frequently its own AWS/GCP account.
- Pitfall: strongest isolation, but the heaviest to run, module version drift across repos, and a much bigger CI/CD surface (a pipeline per repo instead of per directory), which slows down bootstrapping a brand-new environment.
Worked example: a team of 10 to 50 engineers
At this size, weighed on isolation, CI complexity, and how fast people can move:
- Workspaces keep CI complexity lowest (one pipeline, workspace selected by branch or an environment variable), and are fastest to work with day to day. But at 10 to 50 engineers, not everyone touching Terraform owns it full time, and a single mis-set
TF_WORKSPACEin a CI job is enough to apply dev's plan against prod's state. That's a real risk at this size, not a hypothetical. - Separate directories, shared backend roughly double CI complexity (a plan/apply job per environment instead of one), but isolation is now enforced by path rather than by someone remembering to check
terraform workspace show. This is usually the right size for a 10-to-50-engineer team: enough people that memory-based discipline isn't a sufficient guardrail, not so many that maintaining several near-identical directories becomes its own maintenance burden. - Separate repos, separate remote state is usually over-engineering at this size unless different teams genuinely own different environments under different security boundaries, for example prod needs a different approval chain or lives in a different cloud account for compliance reasons. Otherwise the cross-repo module-versioning tax outweighs the extra isolation for a single team this size.
Trade-offs and pitfalls
| Approach | Isolation strength | CI complexity | Velocity | Best when |
|---|---|---|---|---|
| Workspaces | Weak, same backend and IAM boundary for every environment | Low, one pipeline | Fastest | Environments are structurally identical and the team is disciplined about workspace selection |
| Separate directories, shared backend | Medium, separate state key/path per environment | Medium, a job per environment | Medium | Environments diverge somewhat but stay in one account or repo |
| Separate repos, separate remote state | Strong, separate state and often separate accounts/IAM | High, a pipeline per repo | Slowest to add a new environment | Production needs a hard security or blast-radius boundary from lower environments |
A hybrid that avoids most of the downsides: keep reusable modules in a shared, versioned registry regardless of which of the three shapes you pick for the root configs, so "separate directories" or "separate repos" don't also mean "separate, drifting copies of the same VPC module."
What does terraform import actually do, and can you think of a time you'd reach for it instead of just writing a fresh resource block?
Sample Answer
Direct answer
terraform import maps a real-world resource that already exists, created by hand, by a script, or by another tool, into Terraform's state under a specific resource address, so Terraform starts managing something it didn't create. You'd reach for it whenever the resource already exists and deleting-then-recreating it is unacceptable: a production database with real data, a manually created IAM role that other things depend on, or infrastructure inherited from a pre-IaC era or an acquisition, rather than writing a fresh resource block and letting Terraform create it from scratch.
Structured elaboration
What import actually does
It reads the target resource's current attributes from the provider and writes a state entry linking <resource address> to <remote ID>. That's it. The classic terraform import <addr> <id> command does not generate HCL for you and does not reconcile your configuration against reality, so after importing you still need a resource block that matches, and you keep running terraform plan until it shows no diff.
When to reach for it instead of a fresh resource block
Anytime recreation would be destructive or disruptive: a resource holding state or data (a database, an S3 bucket with objects in it), a resource with a live external effect (a DNS record already receiving traffic, a load balancer with active connections), or simply infrastructure someone stood up manually before Terraform existed in the project.
The basic flow
Write a minimal resource block of the correct type first, matching just the arguments you intend to manage. Run terraform import <addr> <id>. Run terraform plan to see what Terraform thinks should change, since your HCL almost never matches the real resource on the first try. Adjust the HCL (and add lifecycle { ignore_changes = [...] } for attributes you don't want to fight with) until plan shows no diff, then only apply if you deliberately want to change something.
Current tooling: declarative import
As of Terraform 1.5, there's also an import block you place directly in configuration, which is plannable (you can see the import as part of a normal plan before committing to it) and can be paired with terraform plan -generate-config-out=generated.tf to scaffold a starting resource block for you instead of hand-writing one. Most day-to-day usage you'll still see is the classic imperative CLI command; the block form is worth knowing for bulk imports or when you want the import itself to go through review.
The related command: taint / -replace
Import and taint solve opposite problems. Import brings an unmanaged resource under management without touching it. terraform taint (deprecated since Terraform 0.15.2 in favor of terraform apply -replace=<address>) does the reverse: it marks a resource Terraform already manages for forced destroy-and-recreate on the next apply, useful when a resource is in a bad state that Terraform's normal attribute diff wouldn't catch on its own, a corrupted disk, or an instance that failed its bootstrap script.
Worked example
# import an existing RDS instance into aws_db_instance.db
terraform import aws_db_instance.db my-db-identifier
# confirm it's tracked
terraform state list | grep aws_db_instance.db
# see what Terraform thinks should change (should shrink to nothing
# as you adjust the HCL to match reality)
terraform plan
If the HCL block for aws_db_instance.db only declares identifier = "my-db-identifier" to start, the first plan will likely propose changes for every other default-valued argument Terraform infers as "should be removed." You reconcile by adding the real values (or ignore_changes for provider-computed ones like endpoint) until plan is clean.
Trade-offs & pitfalls
Import doesn't validate that your HCL is correct, it only validates that the ID exists; if your resource block is wrong, the first plan after import can propose a destructive change on something you specifically imported to avoid touching. Before Terraform 1.5's -generate-config-out, bulk imports of dozens of resources meant either writing every resource block by hand or scripting the CLI, both error-prone. taint/-replace triggers a real destroy-and-recreate, treat it as dangerous on anything stateful even though the command itself is quick to run.
Design a drift-detection and remediation system for thousands of cloud resources spread across multiple accounts and regions. How would it decide what to auto-fix versus what needs a human to sign off, and how do you keep the whole thing auditable?
Sample Answer
At this scale the system has three jobs: detect drift cheaply across thousands of resources without hammering provider APIs, classify each drift by risk and blast radius so only the genuinely dangerous cases interrupt a human, and record every decision so the whole thing survives an audit. The core design is an event-driven detector backed by a scheduled baseline sweep, feeding a risk classifier that routes low-risk stateless drift to automatic remediation and anything touching stateful or security-sensitive resources to a human approval queue, with every step written to an append-only audit log.
Architecture
flowchart TB
A[Scheduled sweep] --> C[Drift Detector]
B[Change event] --> C
C --> D[Risk + severity classifier]
D --> E{Route}
E -->|low risk, stateless| F[Auto-remediate]
E -->|elevated risk or stateful| G[Approval queue]
G --> H[Human sign-off]
H --> F
F --> I[(Audit log, append only)]
G --> I
D --> J[Page on-call if P1]
Detection: event-driven plus scheduled, not either/or
Pure scheduled scanning, a terraform plan sweep across every workspace on a timer, is simple but doesn't scale cleanly to thousands of resources: at high account and workspace counts it turns either infrequent, drift sits undetected for a day, or API-throttled and slow. Pure event-driven detection, reacting to change events, is fast but has a coverage gap: a missed or delayed event means drift that's never checked. Production designs run both: a fast event-driven path reacting within minutes for anything change-event-visible, and a slower scheduled full sweep as the backstop that catches whatever the event path missed. This also avoids two reconciling systems fighting each other: only one component, the reconciler, is allowed to write remediation actions; the event path and the scheduled sweep both just feed the same detector, neither remediates independently.
Noisy false positives at scale
At hundreds of workspaces, naive diffing surfaces a lot of drift that isn't actionable: provider-computed fields that change on their own, timestamps, autoscaling-driven instance counts. Filter these before they reach a human, with an allowlist of expected-to-drift attributes per resource type, and a minimum-repeat threshold that only alerts if the same drift is still present on the next sweep, to filter out transient blips.
Risk classification and severity
| Tier | Example | Action |
|---|---|---|
| P1, security-relevant | Security group opened to 0.0.0.0/0, IAM policy widened | Auto-remediate immediately and page on-call |
| P2, operationally risky | Instance type changed, autoscaling config drifted | Queue for human approval within the normal work day |
| P3, cosmetic | Tag or description changed | Log only, batch into a periodic digest, no page |
Severity is a function of resource type, the specific attribute that changed, and a criticality tag on the resource, not just "something changed."
Stateless vs stateful: different safety gates
A fleet of stateless web servers behind an autoscaler is safe to auto-remediate destructively: replace the drifted instance from the golden image or force an autoscaling group refresh, because no unique data lives on the instance. A stateful service like a database is a different problem: never auto-remediate by destroy-and-recreate, because that risks data loss. For stateful resources the system should default to detect-and-import, bringing the drifted-but-legitimate state under management via terraform import after human review, rather than detect-and-revert, and any destructive action on a stateful resource requires an explicit human approval step regardless of the computed risk score.
When two tools own the same hosts
In a hybrid shop where Terraform provisions infrastructure and Ansible owns day-2 configuration, a naive drift detector misreads Ansible's legitimate changes as unauthorized drift and tries to auto-remediate over them. The fix is an explicit ownership boundary tracked per attribute, not per resource: Terraform's desired state wins for anything it provisions (instance type, security group membership, subnet placement), Ansible's desired state wins for anything it configures (installed packages, running services, config files), and the risk classifier is fed this boundary so it never routes an Ansible-owned attribute into the Terraform remediation path or vice versa. This boundary is a prerequisite for automated remediation, not an optimization: without it the two tools will eventually fight over the same attribute, each reverting the other's legitimate change. Detection wiring differs per tool accordingly: Terraform drift comes from terraform plan -detailed-exitcode (exit code 2 means drift) run against the provisioning-owned attributes, while Ansible drift comes from ansible-playbook --check --diff runs against the configuration-owned attributes, on separate schedules against separate attribute sets. If a resource genuinely needs to move ownership from one tool to the other, that's a deliberate migration with human sign-off, never something the drift system decides on its own.
Multi-cloud prioritization
Across multiple cloud providers, raw discovery order, whichever provider's scanner happened to run first, is a bad prioritization signal. The remediation queue should be ordered by business-criticality tag and blast radius, how many downstream services depend on this resource, rather than which cloud reported it first, so a P1-equivalent issue in a smaller, less-instrumented cloud doesn't sit behind a backlog of P3 issues from the cloud with the most resources.
Preventing remediation loops
If an external system keeps re-modifying a resource the reconciler keeps "fixing", that's a loop. Guard it with a repeat-offender counter per resource: after a small number of auto-remediations of the same resource within a time window, stop auto-remediating and flip it to the human-approval queue with the history attached, instead of fighting the external system forever.
Audit trail
Every stage, detected, classified, routed, remediated or approved, applied, writes an immutable record with pre and post state, and who or what triggered the action, so any change can be traced end to end and replayed for a compliance review.
Trade-offs & pitfalls
Auto-remediation is the highest-leverage part of the system and the highest-risk: too aggressive and it fights legitimate operator changes or destroys data; too conservative and the human queue drowns in low-value approvals until people stop reading it. The repeat-offender loop guard and the stateless/stateful split keep the aggressive side safe; severity tiering and noise filtering keep the human queue usable.
Walk me through blue-green versus canary deployment from an infrastructure perspective; when would you reach for each? Think about traffic routing, resource duplication, cost, and what gets harder when a database schema change is part of the rollout.
Sample Answer
Direct answer
Blue-green gives you a full, already-validated environment and an instant, simple rollback, at the cost of running two complete copies of your infrastructure during the change; canary gives you gradual, lower-risk validation at the cost of more routing and monitoring complexity, and it is harder to reason about while it is in flight. Pick blue-green when a clean, all-or-nothing cutover is affordable and rollback speed matters most; pick canary when the blast radius of being wrong needs to stay small and you can invest in the routing and monitoring to support gradual exposure.
Structured elaboration
Comparison
| Dimension | Blue-green | Canary |
|---|---|---|
| Traffic routing | Atomic cutover: DNS, listener swap, or weight flip to 100% | Gradual: weighted routing or service-mesh rules, ramped over time |
| Resource duplication / cost | Full duplicate environment while both are live, higher but short-lived cost | Only a fraction of the fleet runs the new version at any point, lower peak cost |
| Rollback | Instant, swap back to the known-good environment | Ramp weight back down; usually just as fast, but only a subset of traffic was ever exposed |
| Blast radius if wrong | All traffic hits the new version the moment you cut over | Bounded to the canary percentage until you choose to widen it |
| Operational complexity | Lower: mostly "which environment is live" | Higher: routing rules, staged thresholds, automated promote/rollback logic |
| Database schema changes | Blue and green must both work against the same schema at the moment of cutover, since there is no gradual overlap window; this forces backward and forward compatible migrations (expand, migrate, contract) or a maintenance window | Old and new versions coexist against the same schema for longer, so the compatibility window has to hold for however long the canary runs, not just for an instant |
Other strategies worth naming
Blue-green and canary are not the only tools:
- Progressive expansion: widen the set of inputs a change applies to in stages rather than widening a traffic percentage, for example rolling a new IAM policy or network ACL out to one account, then one region, then everywhere, independent of any single request's traffic path.
- Feature flags for infra: gate exposure to a new code or config path behind a flag rather than, or in addition to, a routing change, so the change can be turned off instantly without touching load-balancer configuration at all. This is especially useful when the change is a behavior toggle inside already-deployed infrastructure rather than a new fleet.
Why this gets harder for networking or storage than for a stateless app release
Blue-green and canary are easy to describe for a stateless app release because the unit being duplicated (a fleet of identical, disposable instances) and the unit being routed (an HTTP request) line up cleanly. That stops being true for infrastructure-level changes:
- Networking: a routing or firewall change often affects an entire connection or an entire flow, not a single request, so traffic cannot always be shifted 5% at a time the way it can with an HTTP load balancer. A stateful TCP connection, a VPN tunnel, or a peering change uses the old path or the new path for its whole lifetime; the workaround is to canary at the level of an entire subnet, account, or long-lived connection cohort rather than per request, and to accept a coarser blast radius.
- Storage: there is no cheap duplicate of a stateful data store the way there is for a stateless instance. Standing up a second, fully synced database is expensive and introduces replication lag as a new failure mode. The common workaround is to validate the new storage layer with shadow traffic or dual writes before anything reads from it in production, and to use expand-then-contract schema migrations so both the old and new consumers can run against the same underlying store during the transition instead of trying to canary the store itself.
Worked example
A schema change on a Postgres-backed service being rolled out with blue-green: adding a NOT NULL column safely requires an expand-then-contract sequence rather than a single migration, because both blue and green must work against the same schema during cutover.
- Expand: add the column as nullable, deploy it (both blue and green tolerate a nullable column).
- Backfill: populate the column for existing rows.
- Cut over blue-green as normal, both versions still tolerate the nullable column.
- Contract: once green is fully promoted and blue is decommissioned, add the
NOT NULLconstraint in a separate migration, since only green's code path needs to rely on it.
This is the same three-step compatibility pattern regardless of whether the deployment strategy is blue-green or canary; what changes is only how long the "both versions must tolerate the old and new shape" window needs to hold.
Trade-offs & pitfalls
- Choosing canary by default because it "sounds safer," without the routing and monitoring maturity to support it, is a common mistake; a canary that cannot actually be measured is worse than a blue-green cutover that can be instantly reversed.
- Forgetting that duplication cost for blue-green is temporal, not permanent, leads teams to avoid it for cost reasons when the actual bill is only for the cutover window.
- Assuming the app-release playbook (weighted HTTP routing) transfers directly to networking or storage changes is the single most common failure mode; check whether the thing being changed is even divisible at the granularity being planned for the canary.
What is Terraform state, and why does Terraform need to keep track of it at all? What would actually break if it didn't exist?
Sample Answer
Direct answer
Terraform state is the record, a JSON file, local or (far more commonly) in a remote backend, of the mapping from every resource block in your configuration to the real-world object it created, plus that object's last-known attribute values. It exists because provider API calls alone can't tell Terraform "this resource address already corresponds to that cloud object" or "here's what its attributes were the last time we looked." State is what lets plan compute an actual diff instead of guessing, and what lets apply update or destroy the correct object instead of creating a duplicate.
What's actually inside the state file
- Resource mapping: resource address to real cloud resource ID, for example
aws_instance.webtoi-0123456789. - Attributes and metadata: the last-known values of every attribute, including computed ones the provider filled in, plus timestamps.
- Dependency graph metadata: what depends on what, and which module each resource belongs to.
- Outputs, and any values a data source resolved.
- Terraform version and a serial number, used to detect concurrent state modifications.
What state makes possible
- Idempotent plan/apply: comparing desired config against last-known state, rather than re-describing everything from the provider on every run, lets
plancompute a targeted diff and lets repeated applies against unchanged config produce a no-op. - Resource targeting (
-target): Terraform resolves a targeted address using the mapping in state; without that mapping there's nothing to reliably point-targetat. terraform import: writes an existing resource's real ID into state under a chosen address, which is exactly what lets Terraform manage a resource it didn't originally create.
Worked example: your config declares
resource "aws_instance" "web" {
ami = "ami-0123456789"
instance_type = "t3.micro"
}
After apply, state records aws_instance.web -> i-0abc123... plus every attribute AWS returned. Change instance_type to t3.small and run plan again: Terraform reads the last-known t3.micro value out of state (it doesn't need to re-describe the instance from AWS to know the old value), compares it to the new config's t3.small, and shows exactly ~ instance_type = "t3.micro" -> "t3.small" as an in-place update, a diff it could not compute correctly without a stored last-known value to compare against.
What would actually break without it
- No diffing: every
applywould have to either blindly re-create everything (duplicate resources on every run) or re-describe every resource from the provider API on every operation, and many attributes (like a write-only password field) can't be losslessly reconstructed that way even if you tried. - No safe in-place updates: Terraform wouldn't know that a config change should modify
i-0abc123in place rather than create a new instance, since there's no persisted mapping from config address to that specific real object. - No dependency-aware destroy ordering: the stored dependency graph is what lets
destroytear things down in reverse-dependency order, and what lets Terraform correctly destroy a resource that's since been removed from config entirely, the current config's graph alone has no idea that resource ever existed. - No detection of external drift: without a last-known snapshot to diff against, Terraform can't distinguish "this attribute changed outside of Terraform" from "this attribute has always had this value."
Trade-offs and pitfalls
State is also a common source of operational pain on its own: when it goes stale (drifts from what's actually deployed) or gets corrupted (truncated, partially written from an interrupted apply, or hand-edited), plan and apply stop being trustworthy. Recovering from that is its own topic rather than a footnote here, but the short version is diagnosing against a known-good, versioned backup of the state object and repairing with terraform state subcommands (mv, rm, import) rather than hand-editing the JSON directly.
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.