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.
You're designing the Infrastructure-as-Code setup for a large org with multiple AWS accounts and a lot of teams. How would you lay out repos and remote state, handle cross-account access, structure modules for reuse, and get changes safely promoted from dev to prod?
Sample Answer
Direct answer
Split this into three layers: a versioned, registry-published modules repo that no environment applies directly; per-account (or per-environment) root configurations, each with its own remote state backend and key; and a promotion pipeline that moves a module version bump from dev through staging to production behind plan review and a human approval gate. Cross-account access is done with short-lived STS role assumption from CI (OIDC, not long-lived IAM keys) into a narrowly-scoped deployer role in each target account. Promotion is a version bump plus a gated pipeline stage, not a copy-paste of resources between account configs.
Repository and state layout
modules/(its own repo or clearly separated path):network/,iam/,compute/,eks/,rbac/. Each module is small, single-responsibility, semantically versioned, and published to a module registry (Terraform Registry, or an internal artifact store) so consumers pin a version instead of tracking a branch.envs/(one repo or directory per account or per environment):org-utilities/,dev/,staging/,prod/. Each has its own backend configuration.org-utilities/is the shared-services account, not just another spoke, see "Network topology across accounts" below for what it holds and how it's wired to the rest.- A platform repo owning the shared CI/CD pipeline definitions and policy-as-code rules that every env repo inherits.
Remote state, one per account/environment, not shared:
- S3 (or GCS) bucket + DynamoDB lock table per account/region, named and scoped to that account, encrypted with a KMS key owned by a central security account.
- IAM policy on the state bucket/table restricts access to the CI deployer role for that account and the owning team, nobody else.
graph LR
P["Platform repo: module registry + shared CI/CD"] --> DEVR["Dev env repo"]
P --> STGR["Stage env repo"]
P --> PRODR["Prod env repo"]
DEVR --> DEVS["Dev state backend"]
STGR --> STGS["Stage state backend"]
PRODR --> PRODS["Prod state backend"]
DEVS --> PLANROLE["tf-plan role per account, assumed via OIDC on any PR"]
STGS --> PLANROLE
PRODS --> PLANROLE
PLANROLE --> GATE["Manual approval gate"]
GATE --> APPLYROLE["tf-apply role per account, assumed via OIDC only for approved runs"]
APPLYROLE --> PRODACC["Prod AWS account"]
Network topology across accounts
- Every spoke account (
dev/,staging/,prod/, and any future team account) runs the samenetworkmodule from the registry: one VPC, subnets, and route tables, laid out identically. The only thing that differs per account is thevpc_cidrinput. - CIDR ranges are allocated centrally, not picked ad hoc per account: a single CIDR registry (a
cidr-allocations.tfmap or table in the platform repo, or an actual AWS IPAM pool, a service that centrally allocates and tracks IP address ranges, if you want enforcement instead of just documentation) assigns each account a non-overlapping block up front, e.g.dev = 10.0.0.0/16,staging = 10.1.0.0/16,prod = 10.2.0.0/16,org-utilities = 10.255.0.0/16. Non-overlapping ranges are what make it possible to peer or Transit-Gateway-attach any two accounts later without a re-IP. org-utilities/is the shared-services account: it owns a Transit Gateway (or a hub VPC with peering, for a smaller org) that every spoke account attaches to; centralized egress (a NAT/firewall fleet spokes route their outbound traffic through, so you're not paying for and securing a NAT gateway fleet in every account); centralized DNS (a Route 53 Resolver with forwarding rules shared to spokes via AWS RAM, Resource Access Manager, a service for sharing resources across accounts, not memory, so on-prem and private-zone resolution is consistent everywhere); and centralized logging/monitoring aggregation (VPC Flow Logs, CloudTrail, and metrics from every spoke shipped to one log/metrics account).- Wiring: each spoke's
networkmodule output includes its VPC ID, and a smalltgw-attachmentmodule in that spoke requests attachment to the shared Transit Gateway inorg-utilities/;org-utilities/accepts the attachment and associates it with the shared route table, and each spoke's route tables get a route pointing anything outside their own CIDR at the Transit Gateway.
Cross-account access
- Each target account has two deployer roles, not one: a
tf-planrole with read-only/describe permissions (plus anyiam:PassRolescoped only to what plan-time data sources need) that any CI job on any branch can assume, and a separatetf-applyrole with the actual write permissions, scoped to the resource types and namespaces that account's environment needs, never a blanket admin role. - CI authenticates to a central identity (GitHub Actions OIDC, or similar). Every PR assumes the
tf-planrole via STS to runterraform planand post the diff, this can run unattended on any branch since it can't change anything. Only a merge to the environment's protected branch, after the required human approval on that environment's deployment gate (a GitHub Environment with required reviewers, or a Terraform Cloud/Enterprise run task), is allowed to assume thetf-applyrole and runterraform apply. The trust policy ontf-applyitself enforces this, it only accepts assumption from the specific CI workflow/job context tied to an approved deployment, not from an arbitrary PR run. No long-lived static credentials live in CI configuration either way. - SCPs (Service Control Policies, org-wide permission ceilings that apply even if a role's own IAM policy allows more) at the organizational-unit level are an additional guardrail on top of IAM: even a misconfigured
tf-applyrole can't exceed what the OU-level SCP allows.
Module design and reuse
- Modules expose typed inputs, sensible defaults, and stable outputs; each is reviewed and tested (
terraform testor Terratest) before a new version tag is cut. - Environment root configs pin an explicit module version constraint, so a module fix doesn't silently propagate to prod the moment it merges, prod picks it up on its own promotion cycle.
Promotion pipeline, dev to prod
- CI runs
planon every PR to an env repo and posts the diff for review. - Merge to the dev environment's branch auto-applies to the dev account.
- Promotion to staging and then prod is a version bump (of the module pin, or of a shared "release" tag) plus a pipeline stage that requires a passing plan, automated policy checks, and a manual approval before
applyruns against that account. - Prefer immutable-infrastructure patterns (new instances/versions rather than in-place mutation) and blue/green or canary rollout where the resource type supports it, so a bad compute promotion is a matter of shifting traffic back, not an emergency Terraform surgery.
- Rolling back a bad Terraform promotion itself, not just compute traffic: Terraform has no native "undo" for state. The default path is a forward-fix: re-run the pipeline against the last-good git tag or module version pin, so a normal
plan/applyreconciles infrastructure back to that prior declared config, and treat that as the standard rollback, not a special emergency procedure. If state itself got corrupted, or the bad change can't be cleanly re-declared forward, fall back to restoring the state object's previous version from the backend bucket's object versioning (kept enabled on the state bucket specifically for this) and re-planning against the restored state to reconcile real infrastructure with it. Blue/green covers the common case of a bad compute rollout; it does not cover a bad promotion to something stateful like an RDS parameter, an IAM policy, or a networking change, those need the tag-revert-and-reapply path above.
If you're using CloudFormation instead of Terraform
The topology problem is the same, roll one change across many AWS accounts and regions, but CloudFormation gives you two different native mechanisms with different partial-failure behavior:
- Nested stacks: a parent stack in one account/region calls child stacks as part of its own dependency graph. This does not natively fan out across accounts or regions on its own, you'd still need a pipeline stage (for example, one CodePipeline stage per account) to run the same template repeatedly. Because it's all one stack transaction, a failure rolls back the parent and any children it had already started updating within that single operation.
- StackSets: an administrator account deploys one template as "stack instances" into many target accounts and regions in a single StackSet operation. Each account's per-account update still goes through its own change set under the hood, so you get the same create/update/delete preview
terraform plangives you, just scoped to that one account. Partial failure is fundamentally different from nested stacks: a StackSet operation does not roll back instances that already succeeded, each stack instance is its own independent stack update. If instance 12 of 50 fails, instances 1 through 11 stay updated, instance 12 is where the operation stops (or continues, depending on settings), and you're left reconciling a fleet in a mixed state rather than a clean all-or-nothing rollback. - Limiting blast radius on a StackSet rollout: set a low
FailureToleranceCount(orFailureTolerancePercentage), for example 0 or 1, so the operation halts on the first failure instead of continuing through dozens of accounts; deploy in waves by targeting a canary OU first before the rest of the organization; and useMaxConcurrentCount(orMaxConcurrentPercentage) to cap how many accounts update in parallel, so a bad template can't hit every account before anyone notices.
Governance and guardrails
- Policy-as-code (OPA/Gatekeeper-style checks, or Terraform Cloud/Sentinel policy checks) enforces tagging, encryption, and network restrictions before
applyis allowed to run. - Scheduled drift detection (periodic
planruns with alerting on any diff) catches out-of-band changes; route safe fixes to automated remediation and anything ambiguous to a human ticket. - Cost guardrails: tag-based budgets and alerts, pre-deploy cost estimates in CI, automated shutdown policies for non-production resources outside business hours.
Trade-offs and pitfalls
- Mono-repo for modules simplifies discoverability but needs stricter CI (required reviews, version-tag discipline) to avoid noisy, hard-to-review changes landing across many modules at once.
- Centralizing state encryption keys under one security account increases control but makes that account a higher-value target, mitigate with strict key rotation, access logging, and alerting on key usage, not just "it's centralized so it's secure."
- Strict SCPs reduce blast radius but slow down legitimate one-off changes; pair them with a documented, fast exception process rather than leaving teams to work around them informally.
- The most common real failure mode in this kind of setup isn't a Terraform bug, it's an overly broad deployer role IAM policy that lets a dev-account CI job touch prod resources it was never meant to reach; review those policies as carefully as the Terraform code itself.
Team responsibilities
- Platform team: owns the module registry, shared CI/CD, central IAM/KMS boundaries, and policy-as-code.
- Security and governance: defines the policies, runs audits, owns SCPs and account-level guardrails.
- Application teams: own their environment repos, build infrastructure from approved modules, and are accountable for their own runtime operations and cost.
- Shared ops: on-call for platform-wide and cross-account incidents, first responder for drift and security alerts.
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.
Say you're writing Terraform (or similar IaC) for a service that needs a database password and a third-party API key. Walk me through how you'd keep those values out of your source code and out of the state file, and what would make one approach more appropriate than another for a team environment.
Sample Answer
Direct answer
Keep the raw secret out of any .tf/.tfvars file that gets committed, and separately make sure nothing in the resource graph ends up writing that same plaintext value into the state file either, those are two different problems with two different fixes. In practice: reference a secrets manager (Vault, AWS Secrets Manager) as the source of truth rather than hardcoding a value, prefer dynamically generated, short-lived credentials over static ones where the provider supports it, and if a value must live in a file, encrypt it at rest (SOPS, or KMS-wrapped) so what's committed is ciphertext.
Why "out of the state file" is a separate problem
Even a cleanly written lookup, with no hardcoded secret anywhere in your .tf files, still writes the resolved plaintext value into the state file as a plain attribute, because state has to record every attribute of every resource and data source it manages in order to compute future diffs. Marking a variable sensitive = true only redacts it from CLI and plan/apply console output, it does not encrypt it or omit it from the state file itself. That means the state backend needs to be treated as sensitive in its own right: encryption at rest (S3 SSE-KMS, or a managed backend's built-in encryption), tightly scoped access to who can read raw state, and, where a provider supports it, preferring patterns that never resolve the secret value into a Terraform-managed attribute at all (for example, passing a secrets-manager ARN into a workload's IAM-based runtime access instead of resolving the value itself into something Terraform writes to state).
Worked example:
variable "db_password" {
type = string
sensitive = true
}
data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/third-party/api-key"
}
resource "aws_db_instance" "this" {
# ...
password = var.db_password
}
resource "some_app_config" "this" {
api_key = data.aws_secretsmanager_secret_version.api_key.secret_string
}
Neither db_password (marked sensitive) nor the resolved secret_string from the data source appears in terraform plan/apply console output. Both still land in the state file as plain attribute values on aws_db_instance.this and some_app_config.this respectively, because state has to record what was actually applied. That's the concrete reason sensitive = true is a CLI-output control, not a state-security control.
Patterns compared
| Pattern | How the secret reaches the resource | Rotation | Access control | State exposure |
|---|---|---|---|---|
| Centralized secrets manager (Vault, AWS Secrets Manager) | App/resource reads the secret at runtime via API, not via Terraform | Built-in rotation (Vault leases, Secrets Manager rotation Lambda) | Fine-grained policy per role; CI gets scoped, short-lived access | Low, if Terraform only manages the ARN/reference, not the resolved value |
| Dynamic secrets | Vault (or similar) issues a short-lived, per-request credential | Auto-expires; no manual rotation | Time-limited, scoped to the requesting identity (for example, the CI job) | None, the credential never becomes a static Terraform attribute |
| Encrypted parameter files (SOPS, git-crypt) | Encrypted file committed to git; decrypted in CI or at apply | Manual: re-encrypt the file after the secret changes | KMS/GPG key access controls who can decrypt | Depends: if the decrypted value flows into a resource attribute, it still lands in state |
| KMS-wrapped secrets (envelope encryption) | Ciphertext stored in config or a parameter store; decrypted via KMS at apply or by the workload | Rotate the plaintext and/or the CMK | KMS key policy plus IAM controls who can call Decrypt | The ciphertext at rest is safe; the decrypted value used in a resource attribute is not |
| Injection at deploy time (env vars, Kubernetes Secret, sidecar) | Secret is injected into the running workload outside Terraform's resource graph entirely | Re-read on redeploy/restart | RBAC on the injection mechanism (Kubernetes Secret RBAC, CI runner scope) | None, Terraform never sees the value |
What makes one approach fit a team environment better
- A centralized secrets manager is the right default for a team: rotation and access are centrally auditable, and nobody needs local access to ciphertext or keys just to onboard.
- Encrypted parameter files (SOPS, git-crypt) suit small teams or the bootstrap secret Terraform itself needs to talk to a secrets manager in the first place, but they don't scale access control well: everyone with decrypt access can read everything in that file, and rotating means everyone involved re-decrypts and re-encrypts.
- Dynamic secrets and deploy-time injection work best when the CI/CD identity itself is short-lived and trustworthy (OIDC-based), since the whole benefit is that no static credential exists anywhere for someone to leak.
- Whichever pattern is used, scope the CI/CD identity's access to secrets narrowly: a pipeline that can create or rotate every secret in the vault just to apply one Terraform stack is itself a blast-radius problem if that pipeline's credentials leak.
Trade-offs and pitfalls
- Prefer short-lived, dynamically issued credentials over static ones wherever the provider supports it, the blast radius of a leaked credential shrinks to its TTL.
- Watch for secrets leaking through channels other than state or source: CI job logs that echo variables, or a plan output printed somewhere
sensitive = truedoesn't reach (for example, a custom script that prints an environment variable directly). - Audit who can read the state backend as carefully as who can read the secrets manager, a state bucket with broader read access than the CI identities that need it is a second, easy-to-forget path to the same secret.
Write a Terraform module that provisions a VPC with a configurable list of availability zones and subnets, using for_each rather than count. It should accept the VPC CIDR, the AZ list, and a flag for whether to create NAT gateways, and expose the resulting subnet and route table IDs. Walk through your variable and output design.
Sample Answer
Direct answer
Build one VPC, then iterate AZs with for_each (not count) so each subnet is keyed by AZ name instead of a list index. That matters because with count, removing an AZ from the middle of the list shifts every subsequent index and Terraform proposes destroying and recreating subnets that didn't actually change. Keying the resource by AZ name only fixes half of that: the CIDR block assigned to each subnet also has to be tied to the AZ itself, not to that AZ's position in a list, so each AZ gets a stable, explicitly-assigned subnet index (a small az => netnum map) instead of index(var.azs, each.value). NAT gateway creation is gated behind a boolean input and created per AZ only when enabled. Outputs expose subnet IDs and route table IDs as maps keyed by AZ, so a caller can look up exactly the subnet it needs by name instead of guessing a list position.
Approach
- One
aws_vpc, sized byvar.vpc_cidr. - One
aws_subnetper AZ viafor_each = var.az_netnums(a map of AZ name to a stable subnet index), with each subnet's CIDR carved out viacidrsubnet(var.vpc_cidr, var.subnet_newbits, each.value), whereeach.valueis that AZ's fixed index, not its position in a list. - One public route table (shared, routes to the internet gateway) and one private route table per AZ (routes to that AZ's NAT gateway only if NAT is enabled; otherwise no default route at all).
- NAT gateways and their EIPs are
for_each-keyed the same way as the subnets, and only exist whenvar.enable_nat_gateway = true.
The code below is illustrative HCL meant to be read, not executed output from a real terraform apply (no CLI available here to run it against real state).
variables.tf
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC, e.g. 10.0.0.0/16."
}
variable "az_netnums" {
type = map(number)
description = "Map of AZ name to a stable subnet index used by cidrsubnet, e.g. { \"us-east-1a\" = 0, \"us-east-1b\" = 1 }. Assign each AZ its number once, in the order it was first added, and treat this map as append-only: never renumber or reuse an existing AZ's index, even after that AZ is removed."
}
variable "subnet_newbits" {
type = number
description = "Additional bits for cidrsubnet when carving subnets out of vpc_cidr (e.g. 8 turns a /16 into /24s)."
}
variable "enable_nat_gateway" {
type = bool
default = false
description = "Whether to create one NAT gateway per AZ for private-subnet egress."
}
main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "vpc-module" }
}
resource "aws_subnet" "this" {
for_each = var.az_netnums
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.vpc_cidr, var.subnet_newbits, each.value)
tags = { Name = "subnet-${each.key}" }
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_eip" "nat" {
for_each = var.enable_nat_gateway ? var.az_netnums : {}
domain = "vpc"
}
resource "aws_nat_gateway" "this" {
for_each = var.enable_nat_gateway ? var.az_netnums : {}
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.this[each.key].id
tags = { Name = "nat-${each.key}" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.this
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
for_each = var.az_netnums
vpc_id = aws_vpc.this.id
tags = { Name = "rt-private-${each.key}" }
}
resource "aws_route" "private_egress" {
for_each = var.enable_nat_gateway ? aws_route_table.private : {}
route_table_id = each.value.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[each.key].id
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.this
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}
outputs.tf
output "subnet_ids" {
value = { for k, s in aws_subnet.this : k => s.id }
}
output "route_table_ids" {
value = {
public = aws_route_table.public.id
private = { for k, rt in aws_route_table.private : k => rt.id }
}
}
Key design decisions
for_eachkeyed by AZ name, notcount, and the CIDR index keyed the same way. The AZ string is a stable, meaningfulfor_eachkey, but that alone isn't enough:cidrsubnet's index argument also has to come from a fixedaz => netnummap (var.az_netnums) instead ofindex(var.azs, each.value), or removing/reordering an AZ silently recomputes every other AZ'sindex()result and reshuffles their CIDR blocks, forcing a destroy/recreate on subnets that didn't actually change (cidr_blockis aForceNewattribute onaws_subnet). With both the resource key and the CIDR index pinned to the AZ itself, adding or removing an AZ only touches the resources tied to that specific key.az_netnumsis append-only: never reuse or renumber an existing AZ's index, even after that AZ is removed.- A "private" subnet with NAT disabled gets no default route at all, rather than falling back to routing straight to the internet gateway. Routing a "private" route table to the IGW when NAT is off would silently make those subnets not actually private, which is a common mistake worth naming explicitly if you see it in someone else's module.
- Outputs are maps keyed by AZ, not lists. A consuming module or root config wants
module.vpc.subnet_ids["us-east-1a"], notmodule.vpc.subnet_ids[0]and a separate assumption about ordering.
Complexity
Resource count scales linearly with the number of AZs: for n AZs you get n subnets, n private route tables, and (if NAT is enabled) n EIPs and n NAT gateways, so the plan/apply graph is O(n) in both size and, roughly, cost. cidrsubnet with subnet_newbits = 8 splits a /16 into /24s, giving up to 28=256 possible subnet blocks, of which this module only allocates n of them (one per AZ), leaving room to grow the AZ list later without re-carving existing subnets, since each AZ's block is computed from its own fixed netnum in az_netnums, not from a list position.
Edge cases
- Fewer than 2 AZs: the module still works, but a single-AZ deployment has no real availability benefit; worth flagging back to whoever is calling it.
- The
az_netnumsmap is reordered in the config file, or an AZ is removed: since both thefor_eachkey and thecidrsubnetindex come fromaz_netnumsrather than a list position, every remaining AZ keeps its assigned netnum and therefore its exactcidr_block; HCL map literals have no meaningful order in the first place, so reordering entries in the file is purely cosmetic. Removing an AZ destroys only that AZ's own subnet, route table, and (if enabled) NAT gateway and EIP; its netnum must not be reassigned to a different AZ afterward, or that AZ's CIDR (and, sincecidr_blockisForceNew, its subnet) would change too. enable_nat_gatewayflips from true to false on an existing deployment: this destroys every NAT gateway and EIP and removes the private route tables' default routes, cutting off private-subnet egress. That is a real production-impacting change, not just a cosmetic one, and belongs behind the same "review the plan's destroy count" discipline as any other production change.subnet_newbitstoo small for the number of AZs:cidrsubneterrors out at plan time if you ask for more subnet bits than the parent CIDR has room for, so this fails fast rather than silently.
Trade-offs and pitfalls
Making this production-ready and reusable pulls in a few things beyond the base module:
- VPC Flow Logs: add an
aws_flow_logresource pointed at CloudWatch Logs or an S3 bucket, gated behind its own optional variable, so network visibility is available to callers who want it without forcing it (and its cost) on every caller. - An S3 gateway endpoint: add an
aws_vpc_endpointof typeGatewayfor S3, associated with the private route tables. This lets private-subnet workloads reach S3 without traversing the NAT gateway, which avoids NAT data-processing charges and keeps that traffic off the public path entirely. - Versioning, testing, and documentation for reuse: tag the module with semantic versions in git (
v1.2.0) and have consumers pin to a version (source = "git::...?ref=v1.2.0"or a registry version constraint) rather than tracking a branch. Write a README documenting every input and output with an example call. Test withterraform test(native since Terraform 1.6) or Terratest against a real or LocalStack-backed provider, asserting that togglingenable_nat_gatewayactually changes the private route tables' routes and that the exposed output maps contain exactly the AZ keys passed in. - One-shared-NAT-gateway alternative: creating one NAT gateway per AZ (as above) maximizes availability but multiplies NAT cost by the AZ count; a single shared NAT gateway in one AZ is cheaper but makes every other AZ's egress depend on that one AZ staying healthy, worth surfacing as an explicit cost/resilience trade-off rather than a silent default.
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.
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.