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 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.
You have a resource block using for_each = var.app_servers, where app_servers is declared as a list in variables.tf, and Terraform errors with "Invalid for_each argument". Explain why that happens and show the minimal HCL changes, to both the variable and the resource, that fix it while keeping stable, unique resource keys.
Sample Answer
Approach
for_each requires a collection with stable, known-at-plan-time keys: a map, or a set of strings. A plain list(object(...)) doesn't qualify, list elements are addressed by position, not by a named key, so Terraform can't derive a stable identity for each instance from it and raises "Invalid for_each argument." The fix is to change the variable's type to a map keyed by a natural stable identifier (a server name), or, if you can't change the source variable's shape, derive a stable map from the list with a local.
Code
Before, count over a list, the state of things when the error's sibling bug (silent reindexing) shows up:
variable "app_servers" {
type = list(object({
name = string
ami = string
type = string
}))
}
resource "aws_instance" "app" {
count = length(var.app_servers)
ami = var.app_servers[count.index].ami
instance_type = var.app_servers[count.index].type
}
After, for_each over a map, fixing both the immediate error and the underlying identity problem:
variable "app_servers" {
type = map(object({
ami = string
type = string
}))
}
resource "aws_instance" "app" {
for_each = var.app_servers
ami = each.value.ami
instance_type = each.value.type
tags = { Name = each.key }
}
If you can't change the caller's variable type at all (say it's populated from a JSON list a data source returns), derive a stable map at the point of use instead of touching the source:
locals {
app_map = { for s in var.app_servers : s.name => s }
}
resource "aws_instance" "app" {
for_each = local.app_map
ami = each.value.ami
instance_type = each.value.type
}
Migrating existing count-based instances to for_each without destroying and recreating them needs a state move for each one, either the classic imperative form or the current declarative moved block (Terraform 1.1+), which documents the rename in configuration and gets picked up automatically on the next plan:
moved {
from = aws_instance.app[0]
to = aws_instance.app["app-01"]
}
Key points
for_each needs a map or set(string), count needs an integer index, that type mismatch is the direct cause of the error. Switching the identity model is not just a config edit: every existing resource's address changes (aws_instance.app[0] becomes aws_instance.app["app-01"]), so without a state move Terraform's plan is a full destroy-and-recreate of everything, not a rename. The reason count breaks in the first place is that removing or reordering an element in the middle of the list shifts every subsequent index, cascading replacements through resources that didn't actually change; a map's keys are independent of position, so adding or removing one entry only ever affects that one resource.
Complexity
The fix itself is a constant-size type and code change. The migration cost scales with the number of existing instances: N count-indexed resources need N moved blocks (or N terraform state mv commands) before the first apply, otherwise the plan is O(2N) destroy-plus-create operations instead of the O(N) renames it should be.
Edge cases
Map keys (and set(string) members) must be unique; a hand-written for expression built from non-unique source names errors loudly at plan time (Error: Duplicate object key, unless you add the ... grouping suffix to intentionally collect duplicates); it's zipmap (or a map that already arrived pre-collapsed from an external source, e.g. flattened upstream by a data source) that silently keeps only the last value and drops an instance, worth an explicit uniqueness check on the input either way. Keys become part of the resource address string, so characters that complicate addressing (quotes, unescaped special characters) need sanitizing before use as a key. And if the source data is genuinely a list of scalar strings rather than objects, toset(var.list) is a valid for_each target too, but it has the same reordering caveat as a list unless the values themselves are the stable identity you want.
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.
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.
How would you design a Terraform module that other teams can safely reuse across dev, staging, and prod? Talk through directory layout, how you'd handle inputs and defaults, and how you'd version it so consumers aren't afraid to upgrade.
Sample Answer
A module other teams can safely adopt needs three things: a small, well-documented interface of inputs and outputs that hides the resource internals behind it, a clear versioning discipline so consumers can tell a breaking change from a safe one before they upgrade, and a directory layout that keeps the module itself separate from how any one environment consumes it. Get those right and teams can pin a version, read the README instead of the source, and upgrade on their own schedule instead of being afraid to touch it.
Directory layout & boundaries
/modules/
/network/ main.tf variables.tf outputs.tf README.md examples/
/compute/ main.tf variables.tf outputs.tf README.md examples/
/examples/
dev/ staging/ prod/ (minimal consumption examples with realistic tfvars)
Keep each module scoped to one concern, network, compute, or identity, so a boundary maps to something a consumer would naturally reason about as one unit; a module that bundles unrelated concerns like network plus security plus app forces consumers into all-or-nothing dependencies. Providers should be configured by the root module, not hardcoded inside a reusable module, so the same module works against a consumer's own provider aliases and account.
Interface design
- Required inputs should be the minimum a consumer must decide, the things that genuinely vary per environment; everything else gets a sensible default.
- Group related settings into an object or map variable instead of exploding the parameter list, and use variable descriptions plus validation blocks so misuse fails fast with a clear message.
- Expose only what a consumer needs to wire into other modules, IDs, ARNs, endpoints; don't leak internal resource addresses a consumer could accidentally start depending on.
- Write the README well enough that another team can adopt the module from the README and examples alone, without reading main.tf: what it creates, every input and its default, every output, and one runnable example per environment tier.
- Default to least privilege: any IAM role or security group the module creates should default to the tightest reasonable policy, with broadening an explicit opt-in input, not the default.
Versioning strategy
| Change type | Example | Version bump | What consumers should do |
|---|---|---|---|
| Safe / additive | New optional input with a default, new output | Patch or minor | Can float (~> 1.2), safe to auto-upgrade |
| Breaking | Required input added, output removed or renamed, a change that forces resource recreation | Major | Must pin (= 1.4.2) until they deliberately upgrade and read the changelog |
Pin the module's own provider version requirements so a consumer doesn't get a surprise provider upgrade alongside a module upgrade. Before publishing a new major version, run compatibility tests, a plan against each example environment using the new version, confirming the diff is only the intended change, so you catch a breaking change you didn't mean to ship. Publish to a registry with a changelog per release, and give consumers advance notice, a deprecation window on the old major version, before a breaking release, so nobody upgrades blind.
Worked example: a VPC module
Take a concrete requirement: a VPC with public and private subnets across availability zones, NAT gateways, and route tables. A well-designed module exposes inputs like vpc_cidr, azs, public_subnet_cidrs and private_subnet_cidrs (one entry per AZ), and single_nat_gateway (a cost-versus-availability choice left to the consumer), and outputs vpc_id, public_subnet_ids, private_subnet_ids, and nat_gateway_ids, named plainly enough that another engineer can guess what they contain without opening the source.
An application module then needs those subnet IDs. Three ways to wire that together:
- Direct module composition: the root module calls both the network and app modules and passes the network module's subnet output straight into the app module's input. Simplest, but couples both modules' lifecycles to the same root and the same apply.
- Remote-state data source: the app module, a separate root or workspace, reads the network module's outputs via a remote-state lookup. Decouples the two lifecycles so each team applies independently, at the cost of an implicit dependency on the network stack's state layout staying stable.
- Shared-value registry lookup: both stacks read published values, for example from a parameter store or a data source backed by tags, rather than reading each other's state directly. Most decoupled, and avoids one team's state format becoming another team's contract, at the cost of an extra piece of infrastructure to keep in sync.
Direct composition is fine when one team owns both layers; independent teams releasing on different schedules are usually better served by remote-state or a published-values lookup, so a network apply doesn't require touching the app stack's plan.
Trade-offs & pitfalls
Over-parameterizing a module, an input for every conceivable variation, makes it harder to document and harder for a consumer to use correctly; keep it opinionated and add inputs when a real second consumer needs them, not speculatively. Safe consumption matters as much as safe design: a shared compute-cluster module should be parameterized differently for non-prod versus prod, tighter autoscaling ceilings and spot instances allowed in non-prod, higher limits and on-demand in prod, through explicit, validated inputs, so a consumer can't accidentally instantiate an expensive always-on cluster in a dev account just because the module's default happened to be prod-shaped.
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.