Terraform (IaC) Interview Questions and Answers
20 hand-picked Terraform (IaC) interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
What is Infrastructure as Code, and why Terraform over ClickOps?
Infrastructure as Code means your servers, networks, databases, and permissions are defined in version-controlled files and provisioned by a tool — never clicked together in a console.
What you gain:
- Reproducibility — the same code builds dev, stage, and prod identically. No "it works in stage" mystery.
- Version control — infrastructure changes get PRs, review, blame, and revert.
- Auditability —
git log answers "who opened this security group and when". - Disaster recovery — rebuild a region from code.
- Scale — 50 identical environments is a loop, not 50 days of clicking.
Declarative vs imperative is the key distinction: you declare the desired end state and Terraform computes the diff to reach it. A script (imperative) describes steps and breaks when re-run; Terraform is idempotent — applying twice changes nothing the second time.
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.private[0].id
tags = {
Name = "${var.project}-web"
Environment = var.environment
ManagedBy = "terraform"
}
}
Explain the Terraform workflow: init, plan, apply, destroy.
terraform init — downloads providers and modules, configures the backend, creates .terraform.lock.hcl. Run it after cloning or whenever providers/modules change.terraform validate — syntax and internal consistency only; no cloud calls.terraform plan — the heart of Terraform. It refreshes real state, compares config vs state vs reality, and prints exactly what will be created (+), changed (~), destroyed (-), or replaced (-/+). Nothing changes yet.terraform apply — executes the plan, building resources in dependency order (parallel where possible), then writes the new state.terraform destroy — tears everything in the state down. Fine for ephemeral environments, never casually in prod.
In CI the professional pattern is to save the plan to a file and apply that exact file — otherwise the world could change between plan and apply and you'd approve one thing and execute another.
terraform init -backend-config=env/prod.hcl
terraform fmt -recursive
terraform validate
# CI pattern: plan to a file, apply that exact file
terraform plan -out=tfplan -var-file=env/prod.tfvars
terraform show -no-color tfplan > plan.txt # attach to the PR
terraform apply tfplan
# scoped operations
terraform plan -target=module.database # emergency use only
terraform apply -replace=aws_instance.web # force recreate (old 'taint')
terraform destroy -var-file=env/dev.tfvars
What is Terraform state and why does it matter?
terraform.tfstate is a JSON file mapping your configuration to real-world resource IDs. Without it, Terraform has no idea that aws_instance.web in your code is instance i-0abc123 in AWS.
It exists for four reasons:
- Mapping — config resource ↔ real resource ID.
- Metadata — dependency order, which is how Terraform knows what to destroy first.
- Performance — cached attributes so plans don't need to query everything.
- Diffing — the baseline for "what changed".
Critical warnings:
- State contains secrets in plaintext — RDS passwords, generated keys. It must be encrypted at rest and access-controlled like a credential store. Never commit it to Git.
- Never hand-edit state. Use
terraform state subcommands. - Losing state is worse than losing code — Terraform will try to recreate everything it thinks doesn't exist. Enable versioning on the state bucket.
terraform state list # everything Terraform manages
terraform state show aws_instance.web # attributes of one resource
terraform state mv aws_instance.web module.compute.aws_instance.web # after refactor
terraform state rm aws_s3_bucket.legacy # forget it (does NOT delete the real bucket)
terraform import aws_instance.web i-0abc123def456 # adopt an existing resource
terraform state pull > backup.tfstate # back up before anything risky
How do you manage Terraform state for a team? Explain remote backends and locking.
Local state breaks the moment a second person or a CI runner is involved: no sharing, no locking, and the file lives on one laptop. The fix is a remote backend — S3 + DynamoDB, Azure Storage, GCS, Terraform Cloud, or Consul.
What a good backend gives you:
- Shared state — everyone and CI read the same source of truth.
- State locking — two simultaneous applies would race and corrupt state; the lock makes the second one wait. (S3 backend uses a DynamoDB table for this; newer versions support native S3 lockfiles.)
- Encryption at rest + versioning for recovery.
- Access control — prod state readable only by the prod pipeline role.
Isolate state per environment. One giant state file for dev+stage+prod means a bad plan can destroy prod, and every apply locks everyone. Use separate state keys/buckets per environment and per component (network / data / apps), and read across them with terraform_remote_state data sources.
terraform {
required_version = "~> 1.9"
backend "s3" {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "ap-south-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-south-1:123456789012:key/abc"
dynamodb_table = "terraform-locks" # state locking
}
}
# read another stack's outputs
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "ap-south-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
What are Terraform modules? How do you structure and version them?
A module is any directory of .tf files — a reusable component with inputs (variables), resources, and outputs. Your top level is the "root module"; anything it calls is a child module.
Standard layout:
modules/vpc/
main.tf # resources
variables.tf # inputs, with types + descriptions + validation
outputs.tf # what consumers need
versions.tf # required_providers
README.md
Versioning is not optional. Source modules from a Git tag or registry version, never an unpinned branch — otherwise someone merging to main changes every consumer's infrastructure on their next apply.
Design rules that matter:
- Don't over-abstract. A module wrapping a single resource with 30 pass-through variables adds indirection and no value.
- No hardcoded environment values inside a module — pass them in.
- Don't set providers inside modules; inherit them from the root.
- Output everything a consumer might reasonably need; adding outputs later is a breaking-ish change.
module "vpc" {
source = "git::https://github.com/acme/tf-modules.git//vpc?ref=v2.3.1" # PINNED
# or: source = "terraform-aws-modules/vpc/aws"
# version = "~> 5.0"
name = "${var.project}-${var.environment}"
cidr = var.vpc_cidr
availability_zones = var.azs
enable_nat_gateway = var.environment == "prod" # cost control in dev
tags = local.common_tags
}
# modules/vpc/variables.tf — validated inputs
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
Variables, locals, outputs, and tfvars — how do they differ and what's the precedence?
variable — an input to the module. Give it a type, description, optional default, validation, and sensitive = true where relevant.locals — computed values used inside the module. Not settable from outside. Perfect for naming conventions and common tags (DRY).output — values exposed to the caller or to other stacks..tfvars — files that supply variable values per environment.
Precedence, lowest to highest (later wins):
- Variable
default TF_VAR_name environment variablesterraform.tfvars, then *.auto.tfvars (alphabetical)-var-file=... and -var=... on the command line (last one wins)
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
variable "db_password" {
type = string
sensitive = true # hidden in output, STILL plaintext in state
}
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
Owner = var.team
}
is_prod = var.environment == "prod"
}
output "alb_dns_name" {
description = "Public DNS of the load balancer"
value = aws_lb.main.dns_name
}
# env/prod.tfvars
# environment = "prod"
# instance_type = "m6i.xlarge"
# min_capacity = 3
count vs for_each — which should you use and why?
Both create multiple instances of a resource, but they index differently and that difference causes real outages.
count — creates a list indexed by position: aws_instance.web[0], [1], [2].for_each — creates a map indexed by key: aws_instance.web["api"], ["worker"].
The problem with count: remove the middle element from the list and every subsequent resource shifts index. Terraform sees [1] changing identity and [2] disappearing — so it destroys and recreates resources that should not have been touched. On stateful resources that's data loss.
With for_each, removing the "worker" key destroys only ["worker"]. Everything else is untouched.
Rule of thumb: use for_each for anything with a natural identity (named services, users, buckets, subnets per AZ). Use count only for N identical, interchangeable copies — or as a conditional toggle (count = var.enabled ? 1 : 0).
# ❌ risky: removing "stage" recreates "prod"
variable "envs" { default = ["dev", "stage", "prod"] }
resource "aws_s3_bucket" "logs" {
count = length(var.envs)
bucket = "logs-${var.envs[count.index]}"
}
# ✅ safe: keys are stable identities
resource "aws_s3_bucket" "logs" {
for_each = toset(["dev", "stage", "prod"])
bucket = "logs-${each.key}"
}
# for_each over a map of objects — the powerful form
variable "services" {
type = map(object({ cpu = number, memory = number }))
default = {
api = { cpu = 512, memory = 1024 }
worker = { cpu = 1024, memory = 2048 }
}
}
resource "aws_ecs_task_definition" "svc" {
for_each = var.services
family = each.key
cpu = each.value.cpu
memory = each.value.memory
}
# count as a conditional toggle — legitimate use
resource "aws_nat_gateway" "main" {
count = var.enable_nat ? 1 : 0
}
How does Terraform determine resource creation order? When do you need depends_on?
Terraform builds a dependency graph and creates resources in order, running independent branches in parallel (default 10 concurrent operations).
Implicit dependencies are created automatically whenever one resource references another's attribute. If your instance uses aws_subnet.main.id, Terraform knows the subnet must exist first. This covers ~95% of cases and is always preferred, because the dependency is self-documenting and precise.
depends_on is the explicit escape hatch for dependencies Terraform cannot see — where the relationship exists in the cloud provider's behaviour but not in the config. Classic examples:
- An IAM policy attachment must exist before a Lambda that assumes the role actually runs.
- A resource needs the NAT gateway/route to exist before it can reach the internet during creation, without referencing it.
- Ordering around
null_resource/provisioners.
Overusing depends_on is an anti-pattern: it serialises the graph, slows applies, and often hides the fact that you should have referenced the attribute instead.
# implicit — preferred. The reference IS the dependency.
resource "aws_instance" "app" {
subnet_id = aws_subnet.private.id
vpc_security_group_ids = [aws_security_group.app.id]
}
# explicit — for invisible relationships
resource "aws_lambda_function" "processor" {
function_name = "processor"
role = aws_iam_role.lambda.arn
depends_on = [
aws_iam_role_policy_attachment.lambda_logs, # must exist before first invoke
aws_cloudwatch_log_group.lambda
]
}
terraform graph | dot -Tsvg > graph.svg # visualise the dependency graph
terraform apply -parallelism=20
Someone changed infrastructure manually in the console. What happens on the next terraform apply?
That's configuration drift — reality no longer matches state/config.
On the next plan, Terraform refreshes state from the real API, sees the difference, and proposes to revert the manual change back to what the code says. Terraform's job is to enforce the declared state, so it treats a console edit as something to undo.
Your options, in order of preference:
- Let Terraform revert it — the correct default. If the manual change was a mistake, this fixes it.
- Codify it — if the change was legitimate (a genuine hotfix), update the Terraform config to match so the next plan is clean and the change is now version-controlled.
terraform import — if entirely new resources were created by hand, import them into state rather than letting Terraform create duplicates.ignore_changes — for attributes deliberately managed elsewhere (e.g. autoscaling adjusting desired_count, or tags applied by a separate policy tool).
Preventing drift is the real answer: remove human write access to prod consoles (read-only + break-glass role), run terraform plan on a schedule and alert on any non-empty diff, and use CloudTrail to catch manual changes.
terraform plan -detailed-exitcode
# 0 = no changes, 1 = error, 2 = drift detected ← perfect for a cron/CI check
terraform apply -refresh-only # accept reality into state without changing infra
terraform import aws_security_group.manual sg-0abc123
resource "aws_ecs_service" "api" {
desired_count = 2
lifecycle {
ignore_changes = [desired_count] # autoscaling owns this attribute
}
}
Terraform workspaces vs directory-per-environment — how do you separate dev/stage/prod?
Workspaces give you multiple named state files from one configuration (terraform workspace new prod), branching on terraform.workspace.
They're convenient but a poor fit for dev/stage/prod in most teams:
- All environments share one backend and one set of credentials — prod state sits next to dev state.
- It's far too easy to forget
workspace select and apply dev changes to prod. There's no visual separation in the code. - Environments inevitably diverge (prod has Multi-AZ, WAF, bigger instances) and you end up with conditionals like
count = terraform.workspace == "prod" ? 3 : 1 scattered everywhere.
Preferred: directory (or repo) per environment — separate root configs each with their own backend key, credentials, and tfvars, all calling the same shared modules. Explicit, safe, independently reviewable, and prod can have a different pipeline with approvals.
Workspaces are genuinely good for short-lived, identical environments — per-PR preview stacks, per-developer sandboxes.
# preferred layout
├── modules/
│ ├── vpc/
│ ├── eks/
│ └── rds/
└── environments/
├── dev/ { main.tf backend.tf dev.tfvars }
├── stage/ { main.tf backend.tf stage.tfvars }
└── prod/ { main.tf backend.tf prod.tfvars }
# each environment: its own state key + credentials
cd environments/prod
terraform init && terraform plan -var-file=prod.tfvars
# workspaces — good for ephemeral PR previews
terraform workspace new pr-482
terraform apply -var="name_suffix=pr-482"
terraform workspace delete pr-482
Explain the lifecycle meta-arguments: prevent_destroy, create_before_destroy, ignore_changes.
prevent_destroy = true — Terraform errors out rather than destroying this resource. A safety net for databases, state buckets, and production data stores. Note it blocks terraform destroy entirely for that resource, so removing it requires a deliberate code change (which is exactly the point — it forces a reviewed PR).create_before_destroy = true — when a change forces replacement, build the new resource first, then delete the old one. This is how you avoid downtime on things like launch templates and ASGs. Requires unique names (use name_prefix instead of name, or the create will collide with the existing resource).ignore_changes = [...] — stop Terraform fighting over attributes that another system legitimately owns: autoscaling changing desired_count, a deployment pipeline updating an image tag, tags applied by an org policy. Use all sparingly.replace_triggered_by — force replacement when another resource changes.
resource "aws_db_instance" "main" {
identifier = "prod-postgres"
lifecycle {
prevent_destroy = true # refuse to delete prod data
ignore_changes = [password, engine_version]
}
}
resource "aws_launch_template" "app" {
name_prefix = "app-" # NOT name — avoids collision
image_id = var.ami_id
lifecycle {
create_before_destroy = true # zero-downtime replacement
}
}
resource "aws_ecs_service" "api" {
desired_count = 2
lifecycle {
ignore_changes = [desired_count, task_definition] # autoscaling + CD own these
}
}
How do you handle secrets in Terraform, given that state stores them in plaintext?
Start by accepting the constraint: anything Terraform manages ends up in state, in plaintext. Marking a variable sensitive only hides it from CLI output. So the strategy is to minimise what secrets Terraform ever touches.
In order of preference:
- Don't put the secret in Terraform at all. Have Terraform create an empty secret container (AWS Secrets Manager entry, Vault path) and let the application or a separate process populate the value at runtime. Terraform manages the infrastructure, not the credential.
- Reference, don't define. Use a
data source to read an existing secret at apply time — though note the value still lands in state. - Generate and store — let Terraform generate a random password, write it straight to Secrets Manager, and never output it.
- Protect the state itself — this is mandatory regardless: encryption at rest (KMS), bucket policies restricting read access to the pipeline role only, versioning, and access logging. Treat the state bucket as a credential store.
Never: hardcode secrets in .tf, commit .tfvars containing secrets, or output a secret unnecessarily.
# 1. Terraform creates the CONTAINER; something else sets the value
resource "aws_secretsmanager_secret" "db" {
name = "${var.environment}/db/credentials"
kms_key_id = aws_kms_key.secrets.id
}
# 2. Or generate + store, never output
resource "random_password" "db" {
length = 32
special = true
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = jsonencode({ username = "app", password = random_password.db.result })
}
# 3. Read an existing secret rather than defining it
data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/external/api-key"
}
# 4. Lock down state — mandatory
# backend "s3" { encrypt = true, kms_key_id = "..." } + restrictive bucket policy
# .gitignore
# *.tfvars
# *.tfstate*
# .terraform/
How do you run Terraform in a CI/CD pipeline safely?
The standard, safe pattern:
- On PR —
fmt -check, validate, security scan (Checkov/tfsec), then plan -out=tfplan. Post the plan as a PR comment so a human reviews exactly what will change. - On merge to main —
apply tfplan using the saved plan file. Never re-plan at apply time: the world could have changed, and you'd be executing something nobody approved. - Production — a manual approval gate (protected environment) before apply.
Controls that make it safe:
- OIDC/workload identity for cloud credentials — no long-lived keys in the CI system.
- Separate roles per environment; the dev pipeline physically cannot touch prod.
- State locking plus
concurrency limits so two pipelines never apply at once. - Never
-auto-approve on prod without an approved saved plan. - Scheduled
plan -detailed-exitcode for drift alerting.
Managed options — Terraform Cloud, Spacelift, Atlantis, env0 — give you plan-in-PR, policy-as-code (OPA/Sentinel), and RBAC out of the box.
jobs:
plan:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/tf-plan # OIDC, read-mostly
aws-region: ap-south-1
- run: terraform init
- run: terraform fmt -check -recursive
- run: terraform validate
- uses: bridgecrewio/checkov-action@master
- run: terraform plan -out=tfplan -var-file=env/prod.tfvars
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: tfplan }
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production # manual approval gate
concurrency: terraform-prod # never two applies at once
steps:
- uses: actions/download-artifact@v4
with: { name: tfplan }
- run: terraform apply tfplan # the APPROVED plan, not a new one
What are Terraform providers? How do you pin versions and use multiple regions?
A provider is the plugin that translates Terraform's resource model into a specific API — AWS, Azure, Google, Kubernetes, Helm, GitHub, Datadog. Terraform core knows nothing about AWS; the provider does all of it.
Version pinning uses two mechanisms:
required_providers with a constraint — ~> 5.0 means ">= 5.0, < 6.0" (allow minor/patch, block breaking major)..terraform.lock.hcl — records the exact resolved versions and checksums. Commit this file. It's what guarantees your laptop and CI use identical provider versions.
Multiple regions/accounts use provider alias: declare a second provider block with an alias and pass it explicitly to resources or modules. Common for a replica in another region, or an ACM certificate that must live in us-east-1 for CloudFront.
Also pin required_version for Terraform itself — a state file written by a newer version can't be read by an older one.
terraform {
required_version = "~> 1.9"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.31" }
}
}
provider "aws" {
region = "ap-south-1"
default_tags { tags = local.common_tags } # tag everything, automatically
}
provider "aws" {
alias = "us_east" # CloudFront certs must be here
region = "us-east-1"
}
resource "aws_acm_certificate" "cdn" {
provider = aws.us_east
domain_name = var.domain
validation_method = "DNS"
}
module "dr" {
source = "./modules/app"
providers = { aws = aws.us_east }
}
# terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
What is a data source? How is it different from a resource?
A resource is something Terraform creates and manages — it's in state and Terraform will modify or destroy it. A data source only reads existing infrastructure; Terraform never creates, changes, or deletes it.
Use data sources to:
- Look up values dynamically instead of hardcoding — the latest AMI ID, the current account ID, available AZs in this region.
- Reference infrastructure owned by another team or stack — an existing VPC, a shared security group, a manually created hosted zone.
- Read outputs from another Terraform state (
terraform_remote_state).
Data sources are resolved during plan, so their values are available to compute the diff — which is why a data source that depends on a not-yet-created resource can force a two-stage apply.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# reference a VPC another team owns
data "aws_vpc" "shared" {
tags = { Name = "shared-services" }
}
resource "aws_instance" "app" {
ami = data.aws_ami.al2023.id
availability_zone = data.aws_availability_zones.available.names[0]
tags = { Account = data.aws_caller_identity.current.account_id }
}
What are provisioners, and why does HashiCorp call them a last resort?
Provisioners run scripts as part of resource creation — remote-exec (SSH into the machine), local-exec (run on the Terraform host), file (copy a file up).
Why they're discouraged:
- They break the declarative model. Terraform can't see what a script did, so it isn't in state and drift is invisible.
- Not idempotent — the script only runs at creation, so changing it does nothing to existing resources.
- Fragile — they need SSH/WinRM connectivity, credentials, and correct security groups at create time. A failed provisioner marks the resource tainted, so the next apply destroys and recreates it.
- They turn infrastructure provisioning into configuration management, which is a different job.
Do this instead: bake configuration into the image with Packer (immutable infrastructure), use user_data/cloud-init for boot-time setup, or hand off to a proper config-management tool (Ansible) after Terraform provisions. For containers, the Dockerfile is the image build.
# ❌ avoid — fragile, invisible to state, only runs at create
resource "aws_instance" "web" {
provisioner "remote-exec" {
inline = ["sudo apt-get update", "sudo apt-get install -y nginx"]
connection { type = "ssh", host = self.public_ip, private_key = file(var.key) }
}
}
# ✅ preferred — cloud-init at boot, or a pre-baked AMI
resource "aws_instance" "web" {
ami = data.aws_ami.baked.id # built by Packer, nginx already inside
user_data = templatefile("${path.module}/init.sh.tftpl", {
app_env = var.environment
})
user_data_replace_on_change = true
}
What HCL expressions and functions do you use most — for, dynamic blocks, try, templatefile?
HCL is declarative but has real expression power:
for expressions — transform lists/maps: [for s in var.subnets : s.id] or build a map with { for k, v in ... : k => v }.dynamic blocks — generate repeated nested blocks (ingress rules, tags) from a variable. Use sparingly; they hurt readability fast.- Conditionals —
var.env == "prod" ? 3 : 1. templatefile() — render an external file with variables (user-data scripts, config files, policy JSON). Much cleaner than heredocs inline.try() / coalesce() / lookup() — safe fallbacks for values that may not exist.- Collection functions —
merge, concat, flatten, toset, zipmap, cidrsubnet (the last one is essential for VPC design).
locals {
# for expression → list
subnet_ids = [for s in aws_subnet.private : s.id]
# for expression → map, with a filter
prod_services = { for k, v in var.services : k => v if v.enabled }
# auto-calculate /24 subnets from the VPC CIDR
subnet_cidrs = [for i, az in var.azs : cidrsubnet(var.vpc_cidr, 8, i)]
common_tags = merge(var.extra_tags, { ManagedBy = "terraform" })
}
# dynamic block — repeated nested blocks from data
resource "aws_security_group" "app" {
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidrs
}
}
}
# external template instead of an inline heredoc
user_data = templatefile("${path.module}/init.sh.tftpl", {
cluster_name = var.cluster_name
region = data.aws_region.current.name
})
# safe fallback
image_tag = try(var.overrides["image_tag"], "latest")
terraform apply failed halfway through. What's the state of your infrastructure and how do you recover?
Terraform applies resources incrementally and writes state as it goes, so a mid-apply failure leaves you partially applied: everything created before the failure exists and is in state; everything after it doesn't exist.
Recovery steps:
- Don't panic-destroy. State is usually fine — Terraform is designed for this.
- Read the actual error — most are mundane: insufficient IAM permissions, a name collision, a quota limit, a dependency that wasn't ready, an invalid value the API rejected.
- Run
terraform plan again. It will show only the remaining work. Fix the underlying cause and re-apply — Terraform is idempotent and will continue from where it stopped. - If a resource was created but not recorded (rare — an API timeout after creation succeeded), the next apply fails with "already exists". Fix by
terraform import-ing it into state. - If state is locked because the process died,
force-unlock <ID> — after verifying nothing is actually running. - If state is genuinely corrupted, restore the previous version from the versioned S3 bucket (this is why versioning is mandatory).
terraform plan # what's actually left to do?
terraform state list # what got created before the failure
TF_LOG=DEBUG terraform apply 2> tf-debug.log # full API traffic
# resource exists in cloud but not in state
terraform import aws_s3_bucket.logs my-logs-bucket
# stuck lock after a killed CI job
terraform force-unlock 8f3a1b2c-...
# restore a previous state version (S3 versioning)
aws s3api list-object-versions --bucket acme-tfstate --prefix platform/terraform.tfstate
aws s3api get-object --bucket acme-tfstate --key platform/terraform.tfstate \
--version-id <ID> restored.tfstate
terraform state push restored.tfstate
Terraform vs Ansible vs CloudFormation vs Pulumi — when do you use each?
| Tool | Type | Best at |
|---|
| Terraform | Declarative provisioning, multi-cloud | Creating infrastructure — VPCs, clusters, databases, DNS, SaaS resources. State-tracked, huge provider ecosystem. |
| Ansible | Procedural config management, agentless | Configuring inside machines — packages, files, services, app deployment, orchestrated ops runbooks. |
| CloudFormation | Declarative, AWS-native | Pure-AWS shops wanting no state file to manage and native drift detection/rollback. Locked to AWS. |
| Pulumi / CDK | Declarative via real languages | Teams who want TypeScript/Python with loops, types, and unit tests instead of HCL. |
The honest answer is that Terraform and Ansible are complements, not competitors: Terraform creates the servers, Ansible configures them. Many teams use both, plus Packer for baking images.
# Terraform provisions the instance
resource "aws_instance" "web" { ami = data.aws_ami.base.id }
output "web_ip" { value = aws_instance.web.private_ip }
# Ansible then configures it (dynamic inventory reads from AWS tags)
# ansible-playbook -i aws_ec2.yml site.yml
# - hosts: tag_Role_web
# tasks:
# - name: install nginx
# ansible.builtin.package: { name: nginx, state: present }
What are your Terraform best practices for a production codebase?
Structure
- Reusable versioned modules + one root config per environment; separate state per environment and per component (network / data / apps) to limit blast radius.
- Consistent naming and mandatory tags via
default_tags and locals — untagged resources are how cloud bills become unattributable.
Safety
- Remote encrypted state with locking and bucket versioning;
prevent_destroy on data stores. - Pin provider and module versions; commit
.terraform.lock.hcl. - Plan in PR, apply the saved plan, manual approval for prod.
Quality
fmt + validate + tflint + checkov/tfsec in CI; policy-as-code (OPA/Sentinel) for org rules like "no public S3 buckets".- Document module inputs/outputs (
terraform-docs). - Variable
validation blocks to fail fast on bad input.
Discipline
- No manual console changes in prod — enforce with read-only access + scheduled drift detection.
- Never commit
.tfstate or secret-bearing .tfvars. - Avoid
-target except in emergencies; it produces state that doesn't match a full apply.
# CI quality gate
terraform fmt -check -recursive
terraform validate
tflint --recursive
checkov -d . --framework terraform --soft-fail-on LOW
terraform-docs markdown table --output-file README.md modules/vpc
# tag everything, automatically
provider "aws" {
default_tags {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
CostCenter = var.cost_center
}
}
}