Terraform Interview Questions and Answers

Last updated:

Check out 45 of the most common Terraform interview questions, then take an AI-powered practice interview

IaCHCLProvidersModulesState Management
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

How does the terraform plan and apply cycle actually work, and how is it different from Ansible or CloudFormation?

BasicCore Workflow

Answer

Terraform runs a three-way comparison. It loads your HCL configuration (desired state), reads the state file (what Terraform believes it created), and by default refreshes each tracked resource against the real provider API (what actually exists). It then builds a directed acyclic graph of resources and produces a diff: create, update in place, replace, or destroy. terraform apply walks that same graph and executes the diff by calling provider CRUD functions.

Nothing is imperative, you never write the order of operations, you write the desired end state and Terraform derives the order from references between resources. That is the fundamental difference from Ansible, which is a procedural configuration management tool that runs tasks top to bottom over SSH against machines that already exist. Ansible is excellent at configuring the inside of a server; Terraform is built to create and destroy the server, the VPC, the load balancer, the DNS record and the IAM role around it.

Many Indian platform teams run both: Terraform provisions the cloud footprint, then cloud-init or Ansible configures what runs inside. Against CloudFormation the difference is portability and state ownership. CloudFormation is AWS-only and AWS holds the stack state for you; Terraform is multi-provider (AWS, Azure, GCP, Kubernetes, Cloudflare, Datadog, GitHub, Razorpay-style SaaS APIs through community providers) and you own the state file, which is both the superpower and the operational burden.

terraform init                 # download providers, configure backend
terraform fmt -recursive       # canonical formatting
terraform validate             # syntax and type checking
terraform plan -out=tfplan     # write a binary plan artifact
terraform apply tfplan         # apply exactly what was reviewed
terraform destroy              # tear the whole configuration down

Key Points

  • Three-way diff: configuration, state file, real infrastructure
  • Execution order comes from the resource graph, not from file order
  • Ansible configures existing machines; Terraform provisions the infrastructure itself
  • CloudFormation is AWS-managed state; Terraform state is yours to protect
Q2

What exactly does terraform init do, and when do you need -reconfigure versus -migrate-state?

BasicCLI

Answer

terraform init prepares a working directory and does four distinct jobs. It initialises the backend (reading the backend or cloud block and connecting to S3, GCS, azurerm or HCP Terraform), downloads every provider named in required_providers into .terraform/providers, writes or verifies checksums in .terraform.lock.hcl, and installs child modules into .terraform/modules. It is safe to re-run and is idempotent; CI pipelines run it on every job because the .terraform directory is scratch space and normally not committed.

The flags matter in interviews. -upgrade re-evaluates version constraints and pulls newer provider and module versions, updating the lock file, without it Terraform keeps the locked versions. -backend-config=key=value or -backend-config=backend.hcl injects backend settings that cannot be interpolated in the backend block, which is how teams share one root module across environments. -reconfigure tells Terraform to ignore the existing backend configuration in .terraform/terraform.tfstate and start fresh, which is what you want when switching between two independent state locations. -migrate-state copies the existing state into the newly configured backend, which is what you want when genuinely moving state, for example from local to S3. Picking the wrong one is a classic production incident: -reconfigure when you meant -migrate-state leaves the old state orphaned and the next plan proposes creating your entire production estate again. Use -input=false in automation so Terraform fails instead of hanging on an interactive prompt.

terraform init \
  -backend-config=env/prod.s3.tfbackend \
  -input=false \
  -lockfile=readonly

# env/prod.s3.tfbackend
bucket       = "acme-tfstate-ap-south-1"
key          = "prod/network/terraform.tfstate"
region       = "ap-south-1"
use_lockfile = true

Key Points

  • Initialises backend, downloads providers, installs modules, writes the lock file
  • -upgrade is required to move past locked provider versions
  • -reconfigure discards prior backend config; -migrate-state copies state across
  • Always pass -input=false in CI so it fails loudly instead of prompting
💡 Pro Tip: Run init with -lockfile=readonly in CI. If a provider version drifts from .terraform.lock.hcl the pipeline fails instead of silently upgrading a provider mid-release.
Q3

What is stored in terraform.tfstate, and why can Terraform not operate without it?

BasicState

Answer

State is a JSON document that maps every resource address in your configuration (aws_instance.web, module.vpc.aws_subnet.private[0]) to the real object identifier at the provider (i-0abc123, subnet-0def456) plus a cached copy of that object's attributes. It also carries a serial number that increments on every write, a lineage UUID that identifies the state's ancestry, the Terraform version that last wrote it, and the outputs of the root module. Terraform needs this mapping because provider APIs have no idea which cloud objects belong to which configuration.

Without state, Terraform cannot know that aws_instance.web is i-0abc123 rather than a new instance to create, and it cannot detect deletions at all. State is also a performance cache: it lets Terraform show a plan without querying every attribute of every dependency, and it holds dependency metadata used to order destroys correctly after you have deleted the config. Three consequences follow, and interviewers test all three.

First, state contains every attribute the provider returned, including RDS passwords, generated private keys and access tokens, in plaintext, so state storage must be encrypted and access-controlled like a secrets store. Second, never hand-edit state, always go through terraform state commands so the serial and checksums stay consistent. Third, losing state is far worse than losing the config, because the config can be rewritten while the mapping to hundreds of live resource IDs cannot.

{
  "version": 4,
  "terraform_version": "1.11.4",
  "serial": 187,
  "lineage": "8c1f9d2a-4b77-11ef-9a3e-0242ac120002",
  "outputs": { "vpc_id": { "value": "vpc-0a1b2c3d", "type": "string" } },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [
        { "index_key": 0, "schema_version": 1,
          "attributes": { "id": "i-0abc123", "instance_type": "t3.micro" } }
      ]
    }
  ]
}

Key Points

  • Maps resource addresses to real provider IDs, plus cached attributes
  • Carries serial, lineage and terraform_version metadata
  • Contains secrets in plaintext regardless of sensitive = true
  • Deletion detection and destroy ordering are impossible without it
Q4

How do you configure an S3 remote backend with state locking in recent Terraform versions?

BasicBackends

Answer

Declare a backend block inside the terraform block. For S3 you set bucket, key and region at minimum. Locking is where the answer has changed recently: for years the only option was a DynamoDB table with a partition key named LockID, referenced through dynamodb_table.

Terraform 1.10 added native S3 locking through use_lockfile = true, which writes a .tflock object next to your state and uses S3 conditional writes for mutual exclusion. The DynamoDB argument is deprecated in favour of it, and new projects should use use_lockfile. You can enable both during a migration window, Terraform will acquire both locks.

Beyond locking, three settings are non-negotiable for production. Enable bucket versioning so you can restore a previous state generation after a bad apply. Enable encryption (encrypt = true plus SSE-KMS with a customer-managed key if you are under audit, which most Indian fintech teams are).

Apply a bucket policy that denies anything except the pipeline role, because read access to state is read access to secrets. The backend block cannot use variables, locals or interpolation of any kind, which surprises everyone once. The standard workaround is partial configuration: leave the changing values out of the block and pass them at init time with -backend-config, either as key=value pairs or as a .tfbackend file per environment. One state key per environment and per layer keeps blast radius small.

terraform {
  required_version = ">= 1.10.0"

  backend "s3" {
    bucket       = "acme-tfstate-ap-south-1"
    key          = "prod/payments/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    kms_key_id   = "arn:aws:kms:ap-south-1:111122223333:key/abcd-1234"
    use_lockfile = true
  }
}

Key Points

  • use_lockfile = true is the modern replacement for the DynamoDB lock table
  • Backend blocks accept no variables or interpolation, use -backend-config
  • Enable S3 versioning and KMS encryption, treat state as a secret store
  • One state key per environment and per layer limits blast radius
Q5

What goes in the terraform block, and how do version constraints like ~> 5.0 behave?

BasicProviders

Answer

The terraform block is configuration for Terraform itself rather than for infrastructure. It holds required_version (which Terraform CLI versions may run this configuration), required_providers (source address and version constraint for every provider used), the backend or cloud block, and optional experiments. required_providers is the important one because the source address is what pins you to a namespace: hashicorp/aws is the official AWS provider, while integrations/github or cloudflare/cloudflare come from other namespaces. Omit the source and Terraform assumes hashicorp/<name>, which silently breaks for community providers.

Version constraint operators follow a specific grammar. = pins exactly. >= sets a floor. ~> is the pessimistic constraint operator and its behaviour depends on how many segments you write: ~> 5.0 allows any 5.x but not 6.0, while ~> 5.31.0 allows 5.31.x but not 5.32.0. Multiple constraints can be comma separated, for example >= 5.0, < 6.0. The practical rule for production is to constrain to a major version in the config with ~>, then let .terraform.lock.hcl pin the exact patch.

That gives you reproducible builds without editing HCL for every patch bump. Constraining required_version matters too, because state written by a newer Terraform cannot be read by an older one, and a developer running a newer CLI locally can lock the whole team out of a shared state file until everyone upgrades.

terraform {
  required_version = ">= 1.10.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.40"
    }
    random = {
      source  = "hashicorp/random"
      version = "3.6.3"
    }
  }
}

Key Points

  • Always write an explicit source, not just the provider name
  • ~> 5.0 allows 5.x; ~> 5.31.0 allows only 5.31.x
  • Constrain the major version in HCL, pin the patch in the lock file
  • A newer CLI writing state can block older CLIs from reading it
Q6

What is the difference between a resource block and a data source, and when is a data source actually read?

BasicCore Concepts

Answer

A resource block declares an object whose lifecycle Terraform owns: it will be created, updated and destroyed by this configuration and it is tracked in state under mode managed. A data block only reads something that already exists, whether created by another Terraform state, by a different team, or by the cloud console. Data sources appear in state under mode data purely as a cache, and destroying the configuration never deletes what a data source pointed at.

Timing is the part interviewers probe. A data source whose arguments are fully known at plan time is read during plan, so its attributes appear as concrete values in the diff. If any argument depends on an attribute of a resource that has not been created yet, Terraform defers the read to apply time, and the plan shows the data source as will be read during apply with (known after apply) for anything derived from it.

That deferral cascades: everything downstream also becomes unknown, which is why a plan can suddenly go from precise to vague after someone adds a data source in the middle of a chain. Two practical gotchas. First, a data source that matches zero objects is a hard error, not an empty result, so aws_ami with a filter that matches nothing fails the plan. Second, data sources re-read on every plan, so a data source pointing at a mutable object (latest AMI, latest secret version) will produce diffs whenever that object changes upstream.

# Read something that already exists
data "aws_vpc" "shared" {
  filter {
    name   = "tag:Name"
    values = ["shared-prod-vpc"]
  }
}

# Own the lifecycle of something new
resource "aws_security_group" "api" {
  name_prefix = "api-"
  vpc_id      = data.aws_vpc.shared.id

  lifecycle {
    create_before_destroy = true
  }
}

Key Points

  • resource owns the lifecycle; data only reads and never destroys
  • Data sources with known arguments are read at plan time
  • Dependence on an uncreated resource defers the read to apply and makes downstream values unknown
  • Zero matches is an error, and mutable targets create recurring diffs
Q7

What is the precedence order when the same input variable is set in several places?

BasicVariables

Answer

Terraform merges variable sources in a fixed order and the last write wins. From lowest to highest priority: the default in the variable block, then the TF_VAR_name environment variable, then terraform.tfvars, then terraform.tfvars.json, then every *.auto.tfvars and *.auto.tfvars.json file in lexical filename order, and finally -var and -var-file on the command line, processed left to right so the rightmost occurrence wins. Anything still unset is prompted for interactively unless you pass -input=false, in which case Terraform errors with No value for required variable.

Two things trip people up. First, a .tfvars file passed with -var-file always beats the auto-loaded ones regardless of name, so a pipeline that passes -var-file=prod.tfvars can silently override a value a developer set in terraform.tfvars. Second, only terraform.tfvars and files ending in .auto.tfvars are loaded automatically; a file named prod.tfvars sitting in the directory does nothing unless you pass it explicitly, which causes the classic incident where someone applies a production root module with development defaults.

Variables also support type constraints, including object types with optional() attributes and defaults, plus validation blocks. Since Terraform 1.9 a validation condition can reference other variables, data sources and locals rather than only the variable being validated, which makes cross-field rules like an environment-dependent instance size expressible directly in HCL instead of in a wrapper script.

variable "instance_type" {
  type        = string
  default     = "t3.micro"
  description = "EC2 size for the API tier"

  validation {
    condition     = can(regex("^(t3|m6i|c6i)\\.", var.instance_type))
    error_message = "Only t3, m6i and c6i families are approved for this account."
  }
}

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

Key Points

  • Order: default, TF_VAR_, terraform.tfvars, *.auto.tfvars, then -var/-var-file
  • Only terraform.tfvars and *.auto.tfvars auto-load; other files need -var-file
  • -var-file entries are processed left to right, rightmost wins
  • Since 1.9 validation conditions may reference other variables and data sources
Q8

What do output blocks do, and what does sensitive = true actually protect?

BasicOutputs

Answer

Outputs expose values from a module to its caller and, for the root module, to the CLI and to anything reading remote state. In a child module, outputs are the only way for the parent to consume a value, since resources inside a module are not addressable from outside it. At the root, outputs are persisted into state and can be fetched with terraform output, terraform output -json for machine consumption, or terraform output -raw name for scripting.

Setting sensitive = true stops Terraform from printing the value in plan output, apply output and terraform output without -json. That is the entire protection. The value is still stored in state in plaintext, still visible via terraform output -json, and still readable by anyone with access to the state object in S3.

If you mark an output sensitive and then feed it into an unmarked output or a resource argument, Terraform propagates the sensitivity marker and will refuse to print the derived value too, which is helpful but occasionally produces the confusing (sensitive value) in a diff where you expected to review a change. Use nonsensitive() deliberately and rarely to strip the marker when you genuinely need to see it. Terraform 1.10 added ephemeral = true for outputs, which lets a value flow between modules during a single run without ever being written to state. That is the real answer when an interviewer asks how to pass a password between modules safely.

output "db_endpoint" {
  value       = aws_db_instance.main.endpoint
  description = "Writer endpoint for the primary RDS instance"
}

output "db_password" {
  value     = aws_db_instance.main.password
  sensitive = true   # hidden in CLI output, still plaintext in state
}

output "session_token" {
  value     = ephemeral.aws_secretsmanager_secret_version.api.secret_string
  ephemeral = true   # never written to state at all
  sensitive = true
}

Key Points

  • Outputs are the only export surface of a child module
  • sensitive = true only suppresses console printing, state still holds plaintext
  • Sensitivity propagates to derived values; nonsensitive() strips it
  • ephemeral = true outputs (1.10+) are never persisted to state
💡 Pro Tip: If an interviewer asks how to keep a database password out of state, sensitive = true is the wrong answer. Ephemeral resources, write-only arguments, or generating the credential outside Terraform are the right ones.
Q9

When should you use a local value instead of an input variable?

BasicLanguage

Answer

An input variable is a parameter: the caller supplies it, it can be overridden per environment, and it forms part of the module's public interface. A local value is an internal expression computed inside the module and cannot be set from outside. The rule is simple: if a value should differ between callers, make it a variable; if it is derived from other values, make it a local.

The most common use of locals is composing naming conventions and merging tag maps, so that a change to the tagging policy touches one place instead of two hundred resources. Locals are evaluated lazily and can reference other locals, variables, data sources and resource attributes, but not each other cyclically, Terraform will report a self-referential cycle. They add zero runtime cost and do not appear in state, so a local is free abstraction.

Two anti-patterns show up in interviews. The first is turning every constant into a variable with a default, which bloats the module interface and makes it unclear which knobs are actually meant to be turned; if no caller will ever set it, it is a local. The second is a giant locals block full of conditional expressions replicating environment differences inside a shared module, which recreates the if-statement soup that Terraform is supposed to eliminate. Push environment differences up to the root module's tfvars, keep the shared module dumb and parameterised.

locals {
  name_prefix = "${var.project}-${var.environment}-${var.region_short}"

  common_tags = merge(var.extra_tags, {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
    Repo        = "platform-infra"
    CostCentre  = var.cost_centre
  })

  is_prod = var.environment == "prod"
}

resource "aws_s3_bucket" "artifacts" {
  bucket = "${local.name_prefix}-artifacts"
  tags   = local.common_tags
}

Key Points

  • Variables are the public interface; locals are internal derived values
  • Locals are ideal for naming conventions and merged tag maps
  • A value no caller will ever override should be a local, not a variable with a default
  • Do not encode per-environment branching inside a shared module's locals
Q10

What does terraform validate check, and what will it never catch?

BasicTooling

Answer

terraform validate performs offline checks against the configuration in the current directory after init has run. It verifies HCL syntax, that referenced variables, locals, resources and modules exist, that attribute names and types match the provider schemas downloaded during init, that required arguments are present, and that expressions type-check. It does not contact any provider API, does not read state, does not need credentials, and completes in under a second, which makes it the right first gate in a pre-commit hook or CI job.

What it never catches is anything that depends on the real world. It will not tell you that the S3 bucket name is already taken globally, that your IAM role lacks ec2:RunInstances, that the AMI ID does not exist in ap-south-1, that a subnet CIDR overlaps an existing one, or that an instance type is unavailable in your availability zone. All of those surface at plan or apply.

It also cannot evaluate variable validation blocks that depend on values not yet supplied, and it will not run for a directory that has not been initialised, failing with Module not installed. In a mature pipeline the ladder is terraform fmt -check for formatting, terraform validate for structural correctness, tflint for provider-aware lint rules like invalid instance types and deprecated arguments, checkov or trivy for security policy, and only then terraform plan against real credentials. Each rung is cheaper and faster than the next, so failures surface early.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_docs
      - id: terraform_trivy

Key Points

  • Offline structural and type checking against provider schemas, needs init first
  • No API calls, no state read, no credentials required
  • Blind to permissions, quotas, name collisions and region availability
  • Pair with tflint for provider-aware rules that validate cannot express
Q11

count versus for_each: why does removing one item from a count list destroy unrelated resources?

BasicMeta-Arguments

Answer

count creates instances addressed by integer index: aws_instance.web[0], aws_instance.web[1], aws_instance.web[2]. Those indices are positional, so if you remove the middle element from the list driving the count, everything after it shifts down by one. Terraform compares the new index 1 against the state's index 1, sees a completely different resource, and plans a destroy and recreate for every element after the removal point.

Deleting one subnet from a list of five can therefore replace three unrelated subnets, which in production means dropping live network interfaces. for_each avoids this entirely because it addresses instances by a string key from a map or a set of strings: aws_instance.web["api"], aws_instance.web["worker"]. Keys are stable, so removing one entry affects exactly one instance. The rule almost every team eventually adopts is to use for_each for any collection of distinct named things and reserve count for two narrow cases: creating N identical copies where identity genuinely does not matter, and the conditional-resource idiom count = var.enabled ? 1 : 0.

Note that count and for_each cannot both appear on the same block, that each.key and each.value are only available inside for_each blocks while count.index is only available inside count blocks, and that for_each over a list is invalid, you must wrap it in toset() or convert it to a map. If your existing code uses count and you want to migrate, use moved blocks to rename each index to its new key without destroying anything.

# Fragile: removing "staging" re-indexes and replaces the rest
variable "envs" { default = ["dev", "staging", "prod"] }
resource "aws_ssm_parameter" "bad" {
  count = length(var.envs)
  name  = "/config/${var.envs[count.index]}/tier"
  type  = "String"
  value = "api"
}

# Stable: keys are addresses
resource "aws_ssm_parameter" "good" {
  for_each = toset(var.envs)
  name     = "/config/${each.key}/tier"
  type     = "String"
  value    = "api"
}

Key Points

  • count indices are positional, so a deletion re-indexes everything after it
  • for_each keys are stable strings, so changes are surgical
  • for_each needs a map or set, not a list, use toset() to convert
  • Migrate count to for_each with moved blocks, never by deleting and re-applying
💡 Pro Tip: aws_ssm_parameter.good["prod"] survives the deletion of dev and staging. aws_ssm_parameter.bad[2] does not.
Q12

How does Terraform decide the order of operations, and when do you actually need depends_on?

BasicDependency Graph

Answer

Terraform builds a directed acyclic graph from references. Writing subnet_id = aws_subnet.private.id creates an implicit edge, so the subnet is created before the instance and destroyed after it. This covers the overwhelming majority of ordering, and because the graph is derived rather than declared, the order is correct even when you refactor files. depends_on is the escape hatch for hidden dependencies that Terraform cannot see because no attribute is referenced.

The canonical example is an IAM policy attachment that must exist before an EC2 instance boots and calls an AWS API: the instance never references the attachment, so without depends_on Terraform may create them in parallel and the instance user data fails with AccessDenied. Other real cases include an S3 bucket policy that must land before an object is written by a downstream tool, and a Kubernetes namespace that must exist before a Helm release, when the release argument uses a plain string rather than the namespace resource attribute. Use depends_on sparingly, because it is coarse: it forces ordering against the whole resource, not a specific attribute, and it makes the graph wider and less parallel.

It also propagates, module-level depends_on (supported since 0.13) makes every resource in the module wait for the target. The debugging tool is terraform graph, which emits DOT that you can render, though for large configurations reading terraform plan -json output or simply grepping references is usually faster.

resource "aws_iam_role_policy_attachment" "s3_read" {
  role       = aws_iam_role.app.name
  policy_arn = aws_iam_policy.s3_read.arn
}

resource "aws_instance" "app" {
  ami                  = data.aws_ami.al2023.id
  instance_type        = "t3.small"
  iam_instance_profile = aws_iam_instance_profile.app.name
  user_data            = file("${path.module}/bootstrap.sh")

  # user_data calls s3:GetObject, but nothing references the attachment
  depends_on = [aws_iam_role_policy_attachment.s3_read]
}

Key Points

  • References create implicit graph edges; destroy order is the reverse
  • depends_on is only for dependencies with no attribute reference, like IAM propagation
  • Overusing depends_on reduces parallelism and hides real coupling
  • terraform graph renders the DAG when ordering is genuinely mysterious
Q13

How do you read a terraform plan, and what do the +/- and -/+ symbols mean?

BasicCore Workflow

Answer

Terraform prints one line per changed attribute with an action symbol in the left gutter. A plus sign means create, a minus means destroy, a tilde means update in place, minus-slash-plus means destroy then create a replacement, and plus-slash-minus means create the replacement first then destroy the old one, which is what create_before_destroy produces. A left angle bracket followed by an equals sign marks a data source that will be read during apply.

Attributes whose value cannot be computed yet render as (known after apply). The single most important thing to look for is the comment # forces replacement next to a specific attribute, because that tells you exactly which field triggered a destroy and create rather than an in-place update. In production that comment is the difference between a rolling update and an outage.

The summary line at the end (Plan: 2 to add, 1 to change, 3 to destroy) is what most people skim, but reviewing a plan properly means scanning for replacements and for anything you did not expect to touch. For automation, write the plan to a file with terraform plan -out=tfplan and then apply exactly that artifact, which guarantees the reviewer approved the same change that runs. terraform show -json tfplan gives a stable machine-readable representation with a resource_changes array that policy engines like conftest, Sentinel and Checkov consume. Note that a saved plan is bound to the state serial at plan time, so if another apply lands first, the apply fails with Saved plan is stale rather than clobbering the newer state.

terraform plan -out=tfplan -input=false -lock-timeout=5m
terraform show -json tfplan > plan.json

# List every resource that will be destroyed or replaced
jq -r '.resource_changes[]
      | select(.change.actions | index("delete"))
      | .address' plan.json

terraform apply -input=false tfplan

Key Points

  • Watch for # forces replacement, not just the summary counts
  • -/+ is destroy then create; +/- is create first (create_before_destroy)
  • terraform show -json tfplan is the contract policy engines read
  • Saved plans are rejected if state changed since the plan was generated
💡 Pro Tip: Gate CI on the jq query above. A pull request that plans any delete in production should require a second approver.
Q14

When are provisioners like remote-exec acceptable, and what should you use instead?

BasicProvisioners

Answer

The Terraform documentation itself calls provisioners a last resort, and interviewers expect you to know why. local-exec, remote-exec and file run imperative commands inside a declarative graph, which breaks the model in several ways. They only run at create time (or destroy time with when = destroy), so they never re-run when their script changes and never appear in a plan diff. They have no notion of the result other than exit status, so nothing they configure is tracked in state and drift is invisible.

If a provisioner fails, the resource is marked tainted and the next apply destroys and recreates it, which for a database or a stateful node is exactly what you do not want. remote-exec also forces you to open SSH or WinRM from wherever Terraform runs, which in a locked-down Indian enterprise VPC usually means a bastion, a connection block with credentials, and a security exception. The alternatives are almost always better. For bootstrapping a VM use user_data or cloud-init, which the cloud provider executes and which you can change without touching the resource lifecycle.

For configuration management run Ansible, Chef or SSM Run Command after Terraform finishes, driven by inventory discovered from Terraform outputs. For calling an API that has no provider, prefer a real provider (restapi, http) or a small Lambda triggered by an event. When you genuinely must shell out, use the terraform_data resource with triggers_replace rather than the older null_resource, since terraform_data is built into Terraform and needs no extra provider.

# Preferred: declarative bootstrap the cloud runs for you
resource "aws_instance" "api" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.small"
  user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
    app_version = var.app_version
  })
  user_data_replace_on_change = true
}

# Escape hatch when you truly must shell out
resource "terraform_data" "warm_cache" {
  triggers_replace = [aws_instance.api.id, var.app_version]

  provisioner "local-exec" {
    command = "./scripts/warm-cache.sh ${aws_instance.api.private_ip}"
  }
}

Key Points

  • Provisioners run once at create time and are invisible to plan and drift detection
  • A failed provisioner taints the resource, forcing recreate on the next apply
  • Prefer user_data, cloud-init, SSM Run Command or a real provider
  • terraform_data with triggers_replace replaces the old null_resource pattern
Q15

What is .terraform.lock.hcl for, and why do CI builds fail with a checksum mismatch?

BasicProviders

Answer

The dependency lock file records the exact provider versions selected during init plus cryptographic checksums for the provider packages. It is committed to git, unlike the .terraform directory, and it gives you reproducible builds: every developer and every pipeline run resolves the same provider binaries even though the constraint in required_providers allows a range. Terraform updates it only when you run terraform init -upgrade or add a new provider.

The classic failure is a checksum mismatch in CI. The lock file stores two kinds of hashes: h1: hashes cover the full provider package for a specific platform, and zh: hashes come from the registry signature. If a developer on an Apple Silicon Mac runs init, the lock file may contain hashes only for darwin_arm64, and the Linux CI runner then fails with a message stating the provider package does not match any of the checksums recorded in the lock file.

The fix is to record hashes for every platform your team uses with terraform providers lock, listing each -platform explicitly, and commit the result. The second common failure is a pipeline that runs plain terraform init and silently upgrades a provider because someone widened a constraint; passing -lockfile=readonly makes that an error instead. Treat the lock file like package-lock.json: reviewed in pull requests, updated deliberately, and never regenerated as a drive-by change in an unrelated commit, because a provider major bump can rewrite plans across the entire estate.

# Record hashes for every platform the team and CI use
terraform providers lock \
  -platform=darwin_arm64 \
  -platform=linux_amd64 \
  -platform=linux_arm64

# Excerpt from .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.72.1"
  constraints = "~> 5.0"
  hashes = [
    "h1:0Nc0kM6z0LDgLKGSl0Yv+Ymzkm8ZQPHKh4mBBhwqPGE=",
    "zh:1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809",
  ]
}

Key Points

  • Commit .terraform.lock.hcl; it pins exact versions and package checksums
  • h1: hashes are platform-specific, which is why Mac to Linux CI breaks
  • terraform providers lock -platform=... records hashes for every target
  • -lockfile=readonly turns silent provider upgrades into pipeline failures
Q16

How do you call a module, and what does the version argument do for different source types?

BasicModules

Answer

A module block has a source, optional version, and whatever input variables the module declares. Outputs are read as module.<name>.<output>. The source argument determines where Terraform fetches the code during init, and crucially it determines whether version is even allowed.

Registry sources (either the public registry, written as namespace/name/provider, or a private registry in HCP Terraform) support the version argument with the same constraint grammar as providers. Git, HTTP, S3 and local path sources do not: for git you pin with a ref in the URL, ideally a tag or a commit SHA rather than a branch, because a branch means your infrastructure changes when someone else merges. Local paths starting with ./ or ../ are never versioned and are re-read on every init, which is why they are right for splitting one root module into readable pieces and wrong for sharing code between repositories.

In interviews the follow-up is usually about upgrade discipline. Pin exact versions in production root modules, pin ranges only in non-production, and run terraform init -upgrade deliberately as its own pull request so the plan diff is reviewable in isolation. Modules also accept the meta-arguments count, for_each, depends_on and providers. A module that contains its own provider block cannot use count or for_each, Terraform errors with a message saying the module is incompatible with count, for_each and depends_on, which is why reusable modules should always receive providers from the caller instead of configuring them internally.

# Registry source: version constraint allowed
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.13.0"

  name = "prod-ap-south-1"
  cidr = "10.20.0.0/16"
  azs             = ["ap-south-1a", "ap-south-1b", "ap-south-1c"]
  private_subnets = ["10.20.1.0/24", "10.20.2.0/24", "10.20.3.0/24"]
  enable_nat_gateway = true
}

# Git source: pin with ref, no version argument
module "payments" {
  source = "git::ssh://git@github.com/acme/tf-modules.git//payments?ref=v2.4.1"
  environment = "prod"
}

Key Points

  • version is only valid for registry sources, use ?ref= for git
  • Pin a tag or commit SHA, never a branch, for shared modules
  • Local path modules are unversioned and re-read on every init
  • A module containing a provider block cannot use count or for_each
Q17

What does prevent_destroy do, and why is terraform destroy -target dangerous?

BasicSafety

Answer

prevent_destroy is a lifecycle argument that makes Terraform reject any plan that would destroy that resource, failing with an error naming the resource instead of producing a diff. It is the standard guard on RDS instances, production S3 buckets, KMS keys, Route53 zones and anything holding data. Two limits matter.

First, it blocks the plan rather than protecting the object, so if someone deletes the resource block from the configuration entirely, the lifecycle rule disappears with it and the next plan happily destroys the resource. Real protection therefore combines prevent_destroy with provider-side deletion protection (deletion_protection on RDS, MFA delete or an S3 bucket policy) and IAM deny rules for the pipeline role. Second, it cannot be set from a variable, the value must be a literal, so you cannot write prevent_destroy = var.is_prod.

The -target flag restricts an operation to a subset of the graph plus its dependencies. It exists as a recovery tool for situations where the graph cannot be planned as a whole, for example after a partial apply. It is dangerous as a routine workflow because it silently skips everything else, so the state you leave behind reflects a configuration that was never applied in full. HashiCorp prints a warning saying targeting should be used as an exception, and any reviewer seeing -target in a pipeline definition should push back. terraform destroy -target is worse still: it destroys the target and everything that depends on it, which can cascade far beyond what the operator intended.

resource "aws_db_instance" "primary" {
  identifier          = "payments-prod"
  engine              = "postgres"
  instance_class      = "db.r6g.xlarge"
  deletion_protection = true       # enforced by AWS, survives config deletion
  skip_final_snapshot = false
  final_snapshot_identifier = "payments-prod-final"

  lifecycle {
    prevent_destroy = true         # enforced by Terraform at plan time
    ignore_changes  = [password]
  }
}

Key Points

  • prevent_destroy fails the plan but vanishes if the resource block is deleted
  • It must be a literal value, not a variable
  • Layer provider deletion protection and IAM denies on top of it
  • -target skips the rest of the graph and is a recovery tool, not a workflow
Q18

How do CLI workspaces work, and why are they a poor way to separate environments?

BasicState

Answer

A CLI workspace is a named state instance inside a single backend configuration. terraform workspace new staging creates one, terraform workspace select switches, and the current name is available in HCL as terraform.workspace. With the S3 backend, non-default workspaces store state under env:/<workspace>/<key>, so all workspaces share one bucket, one key prefix and one backend configuration. That sharing is the problem.

Every workspace runs the same code with the same provider configuration, so dev and prod point at the same AWS account unless you smuggle credentials in through environment variables, and one careless terraform workspace select leaves an engineer applying production changes with a development plan already reviewed. Access control is also coarse: anyone who can write to the bucket can write to every workspace's state, so you cannot give an intern permission to break dev without also giving them permission to break prod. The pattern nearly all production teams use instead is directory-per-environment: separate root modules under envs/dev, envs/staging and envs/prod, each with its own backend key, its own tfvars, its own credentials and its own pipeline, all calling shared versioned modules.

Workspaces remain genuinely useful for short-lived parallel copies of the same environment, ephemeral review apps per pull request, load-test stacks, or a scratch copy while testing a refactor. HCP Terraform workspaces are a different concept entirely despite the shared name: there each workspace has its own variables, credentials and run history, so they are a reasonable environment boundary.

terraform workspace list
terraform workspace new pr-4821
terraform workspace select pr-4821
terraform apply -var="environment=pr-4821"
terraform destroy -auto-approve
terraform workspace select default
terraform workspace delete pr-4821

# Guard against the wrong workspace in HCL
locals {
  guard = terraform.workspace == "default" && var.environment == "prod" ? true : true
}

Key Points

  • CLI workspaces share one backend config, one bucket and one set of credentials
  • State lands under env:/<name>/<key> for the S3 backend
  • Directory-per-environment gives separate credentials, IAM and pipelines
  • HCP Terraform workspaces are a different, stronger boundary than CLI workspaces
💡 Pro Tip: If your answer to environment separation is workspaces, expect a follow-up about how you stop a plan reviewed for dev from being applied to prod. Separate root modules and separate credentials are the answer interviewers want.
Q19

What causes Error acquiring the state lock, and when is terraform force-unlock safe?

IntermediateState

Answer

Before any operation that could write state, Terraform acquires an exclusive lock through the backend. With S3 and use_lockfile that is a conditional write of a .tflock object; with the older setup it is a conditional PutItem into a DynamoDB table keyed on LockID; HCP Terraform queues runs instead. If the lock is held, Terraform prints Error acquiring the state lock with a Lock Info block showing the lock ID, path, operation, who (user and hostname), and creation timestamp.

That Who field is the diagnostic: it tells you whether a colleague is mid-apply or whether a CI runner died. Genuine contention is normal, and the right response is to wait, ideally by passing -lock-timeout=5m so Terraform retries instead of failing instantly. A stale lock happens when the process holding it was killed without releasing, a runner was evicted, a spot node was reclaimed, or someone pressed Ctrl-C twice during apply. terraform force-unlock <LOCK_ID> removes the lock record, and it is safe only when you have positively confirmed no apply is still running.

That confirmation matters because the killed process may still have in-flight API calls; unlocking and applying concurrently can produce duplicate resources or a state file that loses writes. The correct runbook is to check the CI job status, check the Who field, confirm with whoever is named, then force-unlock. The deeper fix is to make pipelines the only writer, with concurrency groups so two runs of the same stack never overlap, and to always set -lock-timeout in automation.

$ terraform apply
Error: Error acquiring the state lock

Lock Info:
  ID:        7f3c1c2e-9a1b-4a2f-8c33-1f8a9e2b7d41
  Path:      acme-tfstate/prod/payments/terraform.tfstate
  Operation: OperationTypeApply
  Who:       runner@gh-runner-prod-14
  Created:   2026-08-11 06:41:22.118 UTC

# Only after confirming the job is dead:
terraform force-unlock 7f3c1c2e-9a1b-4a2f-8c33-1f8a9e2b7d41

Key Points

  • Lock Info shows ID, operation, who and created time, use it before unlocking
  • -lock-timeout=5m turns instant failures into polite retries
  • force-unlock is safe only after confirming no apply is still in flight
  • Pipeline concurrency groups prevent most lock contention structurally
Q20

How do import blocks work compared with the terraform import command?

IntermediateImport

Answer

The legacy terraform import command writes an existing object into state immediately and out of band: no plan, no review, no record in git, and it fails if the resource block does not already exist in the configuration. If your handwritten HCL does not match the real object, the very next plan proposes changes you did not intend, sometimes destructive ones. Terraform 1.5 replaced that workflow with the import block, which is declarative.

You add an import block naming the target address and the provider-specific ID, run terraform plan, and the import appears in the plan output as a proposed import alongside any configuration drift, so you can review it in a pull request like any other change. Applying performs the import and, if the config differs from reality, the same apply shows exactly what will be modified. The killer feature is terraform plan -generate-config-out=generated.tf, which writes a skeleton resource block for every import block that has no matching configuration.

That turns adopting a hand-built estate (the situation in most Indian enterprises migrating off click-ops) from days of transcription into an afternoon of review. From Terraform 1.7 import blocks accept for_each, so you can import a whole map of existing objects in one pass. Practical notes: the ID format is provider-specific and documented per resource (an S3 bucket imports by name, an IAM role by name, an aws_route_table_association by a compound rtb-id/subnet-id string), generated config always needs manual cleanup because it emits every attribute including read-only ones, and import blocks are safe to leave in the repo but most teams delete them after the apply lands.

import {
  to = aws_s3_bucket.legacy_reports
  id = "acme-reports-mumbai"
}

# Bulk import an existing set of parameters
variable "legacy_params" {
  type    = map(string)
  default = { api = "/prod/api/tier", worker = "/prod/worker/tier" }
}

import {
  for_each = var.legacy_params
  to       = aws_ssm_parameter.legacy[each.key]
  id       = each.value
}

# Generate starter HCL, then review and trim it
# terraform plan -generate-config-out=generated.tf

Key Points

  • import blocks are plan-visible and reviewable; terraform import is not
  • -generate-config-out writes skeleton HCL for unmatched imports
  • for_each on import blocks (1.7+) bulk-imports an existing estate
  • Import IDs are resource-specific, check the provider docs for the exact format
Q21

What problem do moved blocks solve that terraform state mv does not?

IntermediateRefactoring

Answer

Renaming a resource, wrapping resources in a module, or converting count to for_each all change the resource address. Terraform keys state by address, so without help it sees the old address gone and the new one absent from state, and plans a destroy plus a create. For an S3 bucket or an RDS instance that is data loss. terraform state mv fixes it, but only on the machine where you run it: it is an imperative one-off, it is not in git, it is not in the pull request, and every other engineer plus every CI runner still needs someone to remember to run it against the shared state.

In practice someone always forgets and a plan in a different branch proposes the destroy anyway. The moved block, added in Terraform 1.1, makes the refactor part of the configuration. You write moved { from = ..., to = ... }, and any apply against any state that still holds the old address silently updates the address instead of destroying anything.

It is idempotent and safe to run repeatedly, and the plan output states clearly that a resource has moved. Moved blocks handle resources, module calls, count indices and for_each keys, and chains are allowed (A moved to B, B moved to C). Terraform 1.8 extended them to cover moves between resource types from different providers, which matters during provider migrations. Convention is to keep moved blocks for a release or two so every environment and every long-lived branch has applied them, then delete them in a follow-up commit.

# Renamed a resource
moved {
  from = aws_instance.web
  to   = aws_instance.api
}

# Wrapped bare resources into a module
moved {
  from = aws_security_group.api
  to   = module.api_network.aws_security_group.this
}

# Converted count to for_each
moved {
  from = aws_ssm_parameter.config[0]
  to   = aws_ssm_parameter.config["dev"]
}

moved {
  from = aws_ssm_parameter.config[1]
  to   = aws_ssm_parameter.config["prod"]
}

Key Points

  • Address changes cause destroy-and-create unless state is told about the rename
  • state mv is imperative, machine-local and invisible to code review
  • moved blocks are declarative, idempotent and apply in every environment
  • They cover resources, modules, count indices and for_each keys
Q22

What is a removed block, and how does it differ from deleting the resource from your configuration?

IntermediateRefactoring

Answer

Deleting a resource block from configuration tells Terraform to destroy the real object. Sometimes you want the opposite: stop managing something while leaving it running, because ownership is moving to another team, another state file, or another tool. Before Terraform 1.7 the only way was terraform state rm, which has the same problems as state mv: imperative, local, invisible in review, and easy to forget in one of five environments.

The removed block, added in 1.7, declares the intent in code. You delete the resource block and add removed { from = <address>, lifecycle { destroy = false } }, and the next apply drops the entry from state without touching the infrastructure. Setting destroy = true instead makes it an explicit destroy, useful when you want the deletion recorded in configuration rather than implied by absence. removed works for module calls too, so you can hand an entire module's worth of resources to another state file: add removed blocks in the source configuration, add matching import blocks in the destination, apply both, and nothing is recreated.

The lifecycle block inside removed is mandatory, omitting destroy is an error. Two caveats worth mentioning in an interview. The resource block must actually be gone from configuration or Terraform reports a conflict. And once removed from state, drift on that object is invisible to you forever, so removal should be paired with a documented new owner rather than used as a way to silence a stubborn diff.

# Ownership of this bucket moved to the data platform team.
# The resource block has been deleted from this configuration.

removed {
  from = aws_s3_bucket.analytics_raw

  lifecycle {
    destroy = false   # forget it, do not delete it
  }
}

# Hand over an entire module
removed {
  from = module.legacy_reporting

  lifecycle {
    destroy = false
  }
}

Key Points

  • removed with destroy = false forgets a resource without deleting it
  • Declarative replacement for terraform state rm, visible in pull requests
  • Works for module calls, enabling clean state-to-state handovers with import blocks
  • The lifecycle block is required; the resource block must already be deleted
Q23

How do you detect and reconcile drift, and what does terraform apply -refresh-only actually change?

IntermediateDrift

Answer

Drift is any difference between what state records and what the provider actually reports, usually caused by console changes, autoscaling, another tool, or a support engineer applying an emergency fix. A normal terraform plan refreshes every managed resource in memory and folds the results into the diff, so drift shows up mixed together with your intended changes, which makes review harder. -refresh-only separates the two. terraform plan -refresh-only shows only the difference between state and reality, with no configuration changes proposed, and terraform apply -refresh-only writes those observed values into state without calling any create, update or delete API. The result is that state now matches the world, and a subsequent normal plan shows only what your configuration wants to change.

That is the correct first move after any out-of-band edit. The opposite flag is -refresh=false, which skips the refresh entirely and plans purely from state. It is a legitimate performance lever on large stacks where refreshing a few thousand resources takes minutes and burns provider API quota, and it is how many CI pipelines keep pull request plans fast, at the cost of not seeing drift in that run.

If you use it, run a scheduled full refresh separately so drift is still detected daily. For drift you want to tolerate rather than reconcile, use lifecycle ignore_changes on the specific attribute (an autoscaling desired_count, a tag written by a cost tool). For drift you want to be alerted about, check blocks with assert conditions run on every plan and emit warnings, and HCP Terraform health assessments do the same on a schedule.

# Someone resized the instance in the console. Reconcile state first.
terraform plan -refresh-only
terraform apply -refresh-only

# Then plan normally, seeing only intended changes
terraform plan

# Fast PR plans on a very large stack
terraform plan -refresh=false -parallelism=20 -out=tfplan

# Tolerate expected drift
resource "aws_ecs_service" "api" {
  name            = "api"
  desired_count   = 4
  lifecycle {
    ignore_changes = [desired_count, task_definition]
  }
}

Key Points

  • -refresh-only updates state to match reality without changing infrastructure
  • -refresh=false speeds up plans on large states but hides drift in that run
  • ignore_changes tolerates expected drift on specific attributes
  • check blocks and scheduled refresh runs turn drift into an alert, not a surprise
Q24

Walk through the lifecycle meta-arguments including preconditions and replace_triggered_by.

IntermediateMeta-Arguments

Answer

The lifecycle block changes how Terraform sequences and guards a resource. create_before_destroy inverts the default replacement order so the new object is created before the old one is destroyed, which is how you replace a launch template backed instance or a security group without downtime. It requires uniqueness, so pair it with name_prefix instead of name, otherwise the create fails with an already exists error. It also propagates: any resource the target depends on must also be create_before_destroy, or Terraform reports a cycle. prevent_destroy fails the plan on any destroy. ignore_changes lists attributes whose drift Terraform should not act on, taking either specific attributes (including single map keys such as tags["LastScanned"]) or the blunt all. replace_triggered_by, added in 1.2, forces replacement of this resource when another resource or attribute changes, which is how you rebuild an instance when its user data template changes without wiring an artificial reference. precondition and postcondition, added in 1.2, are assertions: a precondition is checked before the resource is planned and stops the run early with your own error message, while a postcondition is checked after apply against the resulting attributes.

These matter because they let a module author enforce invariants at the right place. A precondition on an aws_instance that asserts the chosen AMI is x86_64 gives a clear failure in the plan, rather than an opaque provider error twenty minutes into an apply. Interviewers like preconditions because they distinguish candidates who write defensive modules from those who write happy-path HCL.

resource "aws_instance" "api" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.instance_type
  user_data     = local.bootstrap

  lifecycle {
    create_before_destroy = true

    replace_triggered_by = [terraform_data.bootstrap_version]

    precondition {
      condition     = data.aws_ami.al2023.architecture == "x86_64"
      error_message = "Selected AMI is not x86_64; check the AMI filter for this region."
    }

    postcondition {
      condition     = self.private_ip != null
      error_message = "Instance came up without a private IP."
    }
  }
}

Key Points

  • create_before_destroy needs name_prefix and propagates to dependencies
  • ignore_changes can target individual map keys, not just whole attributes
  • replace_triggered_by rebuilds a resource when an unrelated value changes
  • precondition fails early with your message; postcondition validates the result
Q25

When do you need a dynamic block, and how do for expressions build the collection it iterates over?

IntermediateLanguage

Answer

A dynamic block generates repeatable nested blocks whose count is not known when you write the code. Nested blocks such as ingress in aws_security_group, setting in aws_elasticache_parameter_group, or filter in an S3 lifecycle configuration cannot take for_each directly because they are blocks, not resources. dynamic wraps them: dynamic "ingress" { for_each = ...; content { ... } } produces one ingress block per element, with each.key and each.value available inside content. The collection usually comes from a for expression.

A for expression over a list produces a list ([for p in var.ports : tostring(p)]), and adding a key expression with the fat arrow produces a map ({ for r in var.rules : r.name => r }). You can filter with an if clause and flatten nested loops with the flatten() function, which is the standard trick for building a subnet-by-availability-zone matrix out of two lists using setproduct(). Two cautions interviewers listen for.

First, dynamic blocks hurt readability fast; if the content block is more than a handful of lines or the for expression needs a comment to understand, three explicit static blocks are better engineering than one clever dynamic one. Second, for a resource whose nested blocks are order-sensitive, changing the ordering of the input collection produces spurious diffs, so sort or key the collection deterministically. Modern AWS practice sidesteps the security group case entirely by using aws_vpc_security_group_ingress_rule as separate resources with for_each, which gives stable addresses and much cleaner plans.

variable "rules" {
  type = list(object({
    port        = number
    cidr        = string
    description = string
  }))
}

resource "aws_security_group" "api" {
  name_prefix = "api-"
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    for_each = { for r in var.rules : "${r.port}-${r.cidr}" => r }

    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = [ingress.value.cidr]
      description = ingress.value.description
    }
  }
}

Key Points

  • dynamic exists because nested blocks cannot take for_each themselves
  • for expressions produce a list, or a map when you supply a key with =>
  • flatten() plus setproduct() builds cross-product matrices such as subnet per AZ
  • Prefer separate rule resources with for_each over deeply nested dynamic blocks
Q26

How do you design a reusable module, and what is the thin wrapper anti-pattern?

IntermediateModules

Answer

A good module encapsulates a decision, not a resource. terraform-aws-modules/vpc is worth using because it encodes dozens of decisions about subnets, route tables, NAT gateways and flow logs. A module that wraps a single aws_s3_bucket and passes through eight variables one to one is the thin wrapper anti-pattern: it adds an indirection layer, a version to bump, and a place for bugs to hide while providing no abstraction. If removing the module would not lose any encoded knowledge, do not create it.

Concretely, a well-designed module has a small typed interface using object types with optional() attributes and defaults, opinionated defaults that make the common case a three-line call, validation blocks that fail fast with human error messages, outputs that expose only what callers legitimately need, and no provider block, no backend block and no hardcoded environment names. It takes tags as a map and merges rather than replacing. It is versioned with semantic version tags and has a CHANGELOG that flags any change forcing resource replacement, because in Terraform a minor version bump that renames an internal resource is effectively a breaking change.

Documentation is generated with terraform-docs from variable and output descriptions, so it cannot rot. Repository layout matters too: one repository with a modules/ directory works for a single team, while a module-per-repository layout suits an organisation where different teams own different modules and need independent release cycles. Tests belong in the module repository, using native .tftest.hcl runs for plan-level assertions and Terratest for anything that must actually be created.

variable "bucket" {
  type = object({
    name              = string
    versioning        = optional(bool, true)
    lifecycle_days    = optional(number, 90)
    replication_arn   = optional(string)
    allowed_prefixes  = optional(list(string), [])
  })

  validation {
    condition     = can(regex("^[a-z0-9-]{3,63}$", var.bucket.name))
    error_message = "Bucket names must be lowercase alphanumeric with hyphens, 3 to 63 characters."
  }
}

output "arn" {
  value       = aws_s3_bucket.this.arn
  description = "ARN for IAM policy references"
}

Key Points

  • Encapsulate decisions, not single resources; skip pass-through wrappers
  • Typed object variables with optional() give ergonomic, validated interfaces
  • No provider, backend or environment names inside a shared module
  • Version with semver tags and treat resource replacement as a breaking change
Q27

How do you share values between separate state files, and what are the trade-offs of terraform_remote_state?

IntermediateComposition

Answer

Once you split infrastructure into layers (network, data, platform, applications), the application layer needs the VPC ID the network layer created. The direct option is the terraform_remote_state data source, which reads another state file and exposes its root outputs. It is simple and always current, but it creates tight coupling in three ways: the consumer needs read access to the producer's entire state file, which as established contains secrets in plaintext; the consumer depends on the producer's output names as an implicit API with no versioning; and a producer refactor silently breaks every consumer at plan time.

The looser alternative is publishing values to a neutral registry: write the VPC ID to SSM Parameter Store or Secrets Manager from the producer, and read it in the consumer with a data source. That gives you IAM control per parameter instead of per state file, a stable contract that survives state restructuring, and a value other tools can read without knowing Terraform exists. A third option is discovery by tag, using data sources with filters on a well-known tag such as Name or a custom Layer tag, which decouples completely at the cost of failing confusingly when the tag is missing.

Most mature setups in India use SSM parameters for cross-team contracts and terraform_remote_state only within a single team's own layers, where the coupling is acceptable and the state access is already granted. Whichever you choose, treat the boundary as an API: document it, change it deliberately, and never rename a published output without a deprecation window.

# Tight coupling: reads the whole producer state
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-tfstate-ap-south-1"
    key    = "prod/network/terraform.tfstate"
    region = "ap-south-1"
  }
}

# Looser: a published contract with its own IAM policy
resource "aws_ssm_parameter" "vpc_id" {
  name  = "/platform/prod/network/vpc_id"
  type  = "String"
  value = module.vpc.vpc_id
}

data "aws_ssm_parameter" "vpc_id" {
  name = "/platform/prod/network/vpc_id"
}

Key Points

  • terraform_remote_state grants read access to the producer's entire state, secrets included
  • SSM or Secrets Manager gives per-value IAM and a contract independent of state layout
  • Tag-based data source discovery decouples fully but fails opaquely
  • Cross-layer outputs are an API, so version and deprecate them like one
Q28

Terraform state stores secrets in plaintext. How do you handle credentials in a real deployment?

IntermediateSecurity

Answer

Start by accepting the constraint: any attribute a provider returns is written to state, so an RDS password, a generated tls_private_key, a Kubernetes secret or an API token created by Terraform ends up in the state file as readable JSON. sensitive = true only affects console output. The mitigations layer up. First, protect the store: SSE-KMS on the state bucket with a customer-managed key, a bucket policy that denies everyone except the pipeline role and a break-glass role, versioning on, access logging on, and no human read access in normal operation.

Second, avoid putting the secret in state at all. Generate credentials outside Terraform and reference them, or use ephemeral resources introduced in Terraform 1.10, which fetch a value during a run and discard it afterwards without persisting to state or plan. Third, use write-only arguments, added in 1.11: provider attributes ending in _wo accept an ephemeral value that is sent to the API but never stored, paired with a _wo_version attribute you bump when you want the value re-sent.

That combination is the current correct answer for setting a database password from Vault or Secrets Manager. Fourth, never put secrets in tfvars committed to git or in CI environment variables that get echoed by debug logging; pull them at runtime from Vault, AWS Secrets Manager or an internal secrets platform. Finally, scan for accidents: git-secrets or trufflehog in pre-commit, and a policy check that fails any plan introducing a hardcoded credential pattern.

ephemeral "aws_secretsmanager_secret_version" "db" {
  secret_id = "prod/payments/db"
}

resource "aws_db_instance" "primary" {
  identifier     = "payments-prod"
  engine         = "postgres"
  instance_class = "db.r6g.large"
  username       = "app"

  # never written to state or to the plan file
  password_wo         = jsondecode(ephemeral.aws_secretsmanager_secret_version.db.secret_string)["password"]
  password_wo_version = var.db_password_version

  skip_final_snapshot = false
}

Key Points

  • Everything a provider returns lands in state, sensitive = true does not change that
  • Ephemeral resources (1.10) fetch values that are never persisted
  • Write-only arguments (1.11) send a secret to the API without storing it, versioned by _wo_version
  • KMS-encrypted state, tight bucket policy and secret scanning are the baseline
💡 Pro Tip: Bumping db_password_version is what makes Terraform re-send a rotated write-only value. Without the version bump the provider has no way to know the secret changed, since it cannot read the old one back.
Q29

How do provider aliases work for multi-region and multi-account deployments?

IntermediateProviders

Answer

A provider block without an alias is the default configuration for that provider. Adding alias = "name" creates an additional configuration you can attach to specific resources with the provider meta-argument, written as provider = aws.name. This is how one configuration manages ap-south-1 and us-east-1 at the same time, which you need constantly because ACM certificates for CloudFront must live in us-east-1 while the rest of your Mumbai stack does not.

The same mechanism handles multiple accounts: each aliased provider carries its own assume_role block pointing at a different account's role, so a single apply can create a Route53 record in the shared networking account and an ECS service in the workload account. Credentials themselves should come from the environment or from an OIDC-assumed role, never from access keys in HCL. Three practical points come up in interviews.

Aliased providers are not inherited automatically by child modules; the default configuration is passed down implicitly, but any alias must be handed over explicitly through the providers map on the module block. A module that needs two regions should declare configuration_aliases in its own required_providers so the interface is explicit and Terraform validates that the caller supplied them. And removing a provider configuration that still has resources in state produces an error saying the provider configuration is required but has been removed, which you resolve by keeping the block until the resources are destroyed or moved, or by using terraform state replace-provider.

provider "aws" {
  region = "ap-south-1"
}

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

provider "aws" {
  alias  = "shared_services"
  region = "ap-south-1"
  assume_role {
    role_arn = "arn:aws:iam::444455556666:role/TerraformNetworkAdmin"
  }
}

resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = "cdn.example.in"
  validation_method = "DNS"
}

module "dns" {
  source    = "./modules/dns"
  providers = { aws = aws.shared_services }
}

Key Points

  • alias plus the provider meta-argument targets a specific configuration
  • assume_role per alias enables multi-account applies from one run
  • Aliases must be passed to modules explicitly through the providers map
  • Modules declare configuration_aliases so the requirement is part of their interface
Q30

Why can a module that declares its own provider block not use for_each, and how do you structure around it?

IntermediateModules

Answer

Terraform must know every provider configuration before it evaluates the resource graph, because providers are separate plugin processes that have to be started and configured up front. count, for_each and depends_on on a module are evaluated as part of that graph, which would mean the number of provider instances depends on graph evaluation, a circular requirement. So Terraform rejects it with an error stating that the module is incompatible with count, for_each and depends_on because it contains provider configuration blocks. The fix is architectural and is considered best practice regardless: shared modules should never configure providers.

They declare what they need in required_providers, optionally with configuration_aliases when they need more than one, and the root module supplies actual configurations through the providers map. That keeps credentials, regions and assume_role logic in exactly one place, the root, where they belong, and it makes the module usable in any account without editing it. The same rule explains a related error people hit when destroying: if a legacy module embedded a provider block and you delete the module call, Terraform can no longer configure the provider for the resources still in state and refuses to plan.

The remedy is to keep the module in place, migrate the resources out with moved or removed blocks, and only then delete it. If you genuinely need N copies of a stack across N accounts, the accepted patterns are one root module per account driven by a pipeline matrix, or a wrapper that passes an aliased provider per instantiation explicitly.

# Inside the module: declare, do not configure
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = "~> 5.0"
      configuration_aliases = [aws.primary, aws.dr]
    }
  }
}

resource "aws_s3_bucket" "primary" {
  provider = aws.primary
  bucket   = "${var.name}-primary"
}

# In the root module
module "storage" {
  source = "./modules/storage"
  name   = "payments"
  providers = {
    aws.primary = aws
    aws.dr      = aws.ap_southeast_1
  }
}

Key Points

  • Provider configurations are resolved before graph evaluation, so they cannot be dynamic
  • Shared modules declare required_providers and configuration_aliases, never provider blocks
  • Root modules own credentials, regions and assume_role and pass them via providers
  • Deleting a module that embeds a provider strands its resources in state
Q31

Which terraform state subcommands do you actually use in production, and how does -replace differ from taint?

IntermediateState

Answer

terraform state list prints every address in state and is the starting point for any investigation, usually piped through grep. terraform state show <address> prints the recorded attributes for one resource, which is how you check what Terraform believes exists without running a plan. terraform state pull writes the raw JSON to stdout, and it is the correct way to take a backup before anything risky; terraform state push writes a modified file back, which you should treat as a last-resort operation because it bypasses the normal write path. terraform state rm forgets a resource (now better expressed as a removed block), terraform state mv renames (now better expressed as a moved block), and terraform state replace-provider rewrites the provider source address across state, which is what you run when migrating between provider namespaces or between Terraform and OpenTofu registries. On replacement: terraform taint used to mark a resource for destruction and recreation by mutating state, and it is deprecated. The modern equivalent is a plan-time flag, terraform apply -replace=aws_instance.api, which produces a normal reviewable plan showing the replacement instead of silently editing state.

That difference is the point: -replace is visible in the plan, works with saved plan files, can be reviewed by a colleague, and requires no state mutation if you change your mind. Use it for the classic cases where a resource is healthy according to the API but broken in reality, such as an instance whose bootstrap failed or a container instance stuck in a bad state.

terraform state pull > backup-$(date +%s).tfstate

terraform state list | grep aws_db_instance
terraform state show module.data.aws_db_instance.primary

# Recreate a resource that is healthy to the API but broken in reality
terraform plan -replace=aws_instance.api -out=tfplan
terraform apply tfplan

# Migrate provider source addresses across the whole state
terraform state replace-provider \
  registry.terraform.io/-/aws \
  registry.terraform.io/hashicorp/aws

Key Points

  • state list and state show are the diagnostic pair for any state question
  • state pull is your backup before risky operations; state push is a last resort
  • Prefer moved and removed blocks over state mv and state rm
  • -replace is the reviewable, plan-visible successor to the deprecated taint
Q32

How does the native terraform test framework work, and what can you assert with mock_provider?

IntermediateTesting

Answer

Terraform 1.6 added terraform test, which runs .tftest.hcl files found in the module directory or in a tests directory. Each file contains one or more run blocks. A run block sets variables, chooses command = plan or command = apply (apply is the default), and contains assert blocks with a condition expression and an error_message.

Inside assertions you can reference the module's outputs and, importantly, the planned or applied resource attributes through their normal addresses, so you can assert that aws_s3_bucket.this.tags["Environment"] equals what you passed in. Runs execute in order and share state within a file, so you can apply a base configuration in run one and then plan a change in run two to assert that a specific attribute forces replacement. Anything created by an apply run is destroyed automatically when the file finishes.

Terraform 1.7 added mocking, which is what makes the framework practical for fast unit tests: mock_provider replaces a real provider so no API calls happen and no credentials are needed, with generated fake values for computed attributes, and you can pin specific values with mock_resource and override_resource, override_data or override_module blocks. expect_failures asserts that a variable validation or precondition fails as designed, which is how you test your guardrails rather than only your happy path. The practical split is mocked plan-only tests running on every pull request in seconds, and a small number of real apply tests running nightly against a sandbox account.

# tests/naming.tftest.hcl
mock_provider "aws" {}

variables {
  project     = "payments"
  environment = "prod"
}

run "bucket_name_follows_convention" {
  command = plan

  assert {
    condition     = aws_s3_bucket.artifacts.bucket == "payments-prod-artifacts"
    error_message = "Bucket name did not follow the project-environment-purpose convention."
  }
}

run "rejects_unapproved_instance_family" {
  command = plan

  variables {
    instance_type = "p4d.24xlarge"
  }

  expect_failures = [var.instance_type]
}

Key Points

  • run blocks with command = plan or apply, plus assert conditions on outputs and attributes
  • mock_provider removes the need for credentials and makes tests fast
  • override_resource and override_data pin specific computed values
  • expect_failures verifies that validation blocks and preconditions actually reject bad input
Q33

When would you reach for Terratest instead of the built-in terraform test?

IntermediateTesting

Answer

Native terraform test is written in HCL, needs no extra toolchain, and is excellent for what it covers: input validation, naming conventions, conditional resource creation, and plan-level assertions about what will be built. Its ceiling is that assertions are HCL expressions over Terraform values, so it can tell you a load balancer resource will be created with certain attributes but it cannot tell you the load balancer serves traffic. Terratest is a Go library from Gruntwork that drives Terraform programmatically, terraform.InitAndApply, then arbitrary Go code, then a deferred terraform.Destroy.

Because the assertions are Go, you can do real behavioural verification: send an HTTP request to the URL Terraform output and assert on the response and TLS certificate, connect to the RDS endpoint and run a query, call the AWS SDK to confirm the bucket policy denies public access, wait with retries for an EKS node group to become ready, or fail the test if provisioning takes longer than a threshold. That power costs time and money, a Terratest suite creates real infrastructure and can take twenty minutes per case, so it belongs in a nightly or pre-release pipeline against an isolated sandbox account, not on every pull request. The pattern most teams settle on is a pyramid: tflint and validate on every commit, mocked terraform test runs on every pull request, a small Terratest suite for critical modules nightly, and policy checks with Checkov or OPA at both plan and apply gates. Mention cleanup discipline in an interview, orphaned Terratest resources are a real and recurring cloud-bill problem, so a scheduled sweeper on the sandbox account is part of the answer.

package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/terraform"
	http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
	"time"
)

func TestApiStackServesTraffic(t *testing.T) {
	opts := &terraform.Options{
		TerraformDir: "../examples/api",
		Vars: map[string]interface{}{"environment": "sandbox"},
	}
	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)

	url := terraform.Output(t, opts, "alb_url")
	http_helper.HttpGetWithRetry(t, url+"/healthz", nil, 200, "ok", 30, 10*time.Second)
}

Key Points

  • Native tests assert on Terraform values; Terratest asserts on real behaviour
  • Terratest is Go, with defer terraform.Destroy and SDK-level verification
  • Real applies are slow and costly, so run them nightly in a sandbox account
  • Always pair Terratest with a scheduled sweeper for orphaned resources
Q34

How do you enforce policy on Terraform changes before they reach production?

IntermediatePolicy and Security

Answer

Policy enforcement happens at three points and mature teams use all three. Statically, before any plan, tflint catches provider-aware mistakes that validate cannot see, such as an instance type that does not exist in the target region or a deprecated argument, and it supports custom rules plus a ruleset per cloud. Checkov and Trivy (which absorbed tfsec) scan HCL for security misconfiguration: public S3 buckets, unencrypted EBS volumes, security groups open to 0.0.0.0/0 on port 22, IAM policies with wildcard actions.

These run in seconds and are the right pre-commit and pull-request gate. At plan time you get much stronger guarantees because you can inspect the actual diff rather than the source. terraform show -json produces a resource_changes array, and Open Policy Agent with conftest, or Sentinel in HCP Terraform and Terraform Enterprise, evaluates rules against it: deny any plan that destroys an RDS instance, deny an instance type outside the approved list, require a CostCentre tag on every taggable resource, deny changes to production outside a change window. Because this runs on the plan, it catches problems that source scanning misses, such as a module input that resolves to a non-compliant value only in one environment.

At apply time the last line of defence is IAM: the pipeline role should not have permission to do what policy forbids, so a bypass of the CI gate still fails at the AWS API. Add Infracost to the pull request comment for cost visibility, which in Indian startups running lean cloud budgets is often the check that gets the most attention.

# policy/no_prod_db_destroy.rego
package terraform.guardrails

import rego.v1

deny contains msg if {
  change := input.resource_changes[_]
  change.type == "aws_db_instance"
  "delete" in change.change.actions
  msg := sprintf("Refusing to destroy database %v", [change.address])
}

deny contains msg if {
  change := input.resource_changes[_]
  change.change.after.tags.CostCentre == null
  msg := sprintf("%v is missing the CostCentre tag", [change.address])
}

# In CI
# terraform show -json tfplan > plan.json
# conftest test --policy policy plan.json

Key Points

  • tflint for provider-aware lint, Checkov and Trivy for security scanning of HCL
  • OPA or Sentinel over terraform show -json evaluates the real diff, not just source
  • IAM on the pipeline role is the enforcement that cannot be bypassed
  • Infracost surfaces the money impact directly in the pull request
Q35

Design a Terraform CI/CD pipeline. What runs on a pull request and what runs on merge?

IntermediateCI/CD

Answer

On a pull request the pipeline should run read-only work and post the result for review: terraform fmt -check -recursive, terraform init -input=false -lockfile=readonly, terraform validate, tflint and a security scanner, then terraform plan -out=tfplan -input=false -lock-timeout=5m. Convert the plan with terraform show -json, run policy checks against it, render terraform show -no-color tfplan into a collapsed comment on the pull request, and upload the binary plan file as a build artifact. On merge to the main branch, the apply job downloads that exact artifact and runs terraform apply -input=false tfplan, so the change applied is byte-for-byte the change reviewed.

If state moved in between, Terraform refuses with a stale plan error, which is the safe outcome. Authentication should use OIDC federation rather than long-lived keys: GitHub Actions requests an id-token, AWS trusts the GitHub OIDC provider, and the workflow assumes a role scoped to that repository and environment. This removes static secrets from CI entirely, and it is increasingly a compliance requirement for Indian fintech teams under RBI and PCI scrutiny.

Add a concurrency group keyed on the state path so two applies for the same stack never overlap, a required approval on the production environment, and a scheduled drift job that runs plan -refresh-only nightly and alerts on any difference. Atlantis, Spacelift, env0 and HCP Terraform all package this workflow if you would rather not maintain it, and their main added value is plan output in the pull request plus policy gating and state access control.

name: terraform
on: [pull_request, push]

permissions:
  id-token: write      # required for OIDC
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    concurrency:
      group: tf-prod-payments
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/gh-terraform-prod
          aws-region: ap-south-1
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -input=false -lockfile=readonly
      - run: terraform plan -out=tfplan -input=false -lock-timeout=5m
      - uses: actions/upload-artifact@v4
        with: { name: tfplan, path: tfplan }

Key Points

  • Plan on the pull request, apply the saved plan artifact on merge
  • OIDC federation replaces static cloud credentials in CI
  • Concurrency groups keyed on state path prevent overlapping applies
  • A nightly -refresh-only job turns drift into an alert instead of a surprise
Q36

Terragrunt, HCP Terraform, or plain Terraform with a wrapper script: how do you choose?

IntermediateTooling

Answer

The problem all three solve is repetition across environments and layers: the same backend block copied into forty directories with only the key changing, the same provider configuration, the same variable plumbing, and orchestrating apply order between layers. Plain Terraform handles this reasonably well now: partial backend configuration through -backend-config files, a shared modules directory, and a thin Makefile or shell script per environment. This is the lowest-dependency option and the easiest for a new joiner to read, which matters more than teams expect.

Terragrunt is a third-party wrapper that generates backend and provider blocks from a hierarchy of terragrunt.hcl files, adds dependency blocks so one unit can consume another unit's outputs with correct ordering, and provides run-all to apply many units in dependency order. It is genuinely good at large multi-account estates and is widely used in Indian consulting and platform teams. The cost is an extra tool, an extra abstraction layer that hides plain Terraform mechanics from juniors, and version-compatibility work whenever Terraform itself changes.

HCP Terraform (the product formerly called Terraform Cloud) is the managed option: remote state with proper access control, remote runs with logs and approvals, a private module registry, Sentinel policy sets, drift detection through health assessments, and no-code modules for self-service. It solves governance and audit, which is why regulated companies choose it, but it is priced per resource under management and you give up some control over the runner environment. A reasonable decision rule: start plain, add Terragrunt when you are copy-pasting backend configuration across dozens of units, and move to HCP Terraform or a competitor like Spacelift when governance, audit trails and self-service become the bottleneck rather than mechanics.

# terragrunt.hcl in a leaf unit
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::ssh://git@github.com/acme/tf-modules.git//ecs-service?ref=v3.2.0"
}

dependency "network" {
  config_path = "../network"
  mock_outputs = {
    vpc_id     = "vpc-mock"
    subnet_ids = ["subnet-mock"]
  }
}

inputs = {
  vpc_id     = dependency.network.outputs.vpc_id
  subnet_ids = dependency.network.outputs.private_subnet_ids
  service    = "payments-api"
}

Key Points

  • Plain Terraform plus -backend-config files covers most single-team needs
  • Terragrunt shines on large multi-account estates with dependency ordering
  • HCP Terraform buys governance, audit and a private registry, priced per resource
  • Every abstraction layer hides mechanics a junior engineer still has to learn
Q37

Why does Terraform fail with Invalid for_each argument when the keys depend on a resource attribute, and how do you fix it properly?

AdvancedUnknown Values

Answer

Terraform must know the full set of instance keys during plan, because each key becomes a distinct node in the graph with its own diff. If the keys come from an attribute that will only exist after apply, for example a list of ARNs from resources being created in the same run, Terraform cannot enumerate them and fails with a message saying the for_each value depends on resource attributes that cannot be determined until apply, and suggesting -target as a workaround. Values are also allowed to be unknown, so a for_each over a map whose values are unknown but whose keys are known is fine; only unknown keys break the plan.

That distinction is the heart of a good answer. The suggested -target workaround is a two-apply hack, and using it in a pipeline means production is applied in stages that were never reviewed together. The correct fixes are structural.

Derive keys from configuration rather than from provider output: iterate over the input variable that produced the resources, not over the resources themselves, since var.subnets is known at plan time while aws_subnet.this[*].id is not. If the keys genuinely come from another system, split the layers so the producing resources live in a different state that is applied first, and consume their identifiers through SSM parameters or a data source. Also watch for accidental unknowns: a key built with a random_id, a timestamp() call, or a value read from a data source that itself depends on a new resource will all poison an otherwise fine for_each. Reading the plan for (known after apply) markers upstream of your for_each is the fastest way to find which value is the culprit.

# Fails: keys come from resources created in the same run
resource "aws_iam_role_policy_attachment" "bad" {
  for_each   = toset(aws_iam_policy.app[*].arn)
  role       = aws_iam_role.app.name
  policy_arn = each.value
}

# Works: keys come from configuration, values may stay unknown
variable "policies" {
  type    = set(string)
  default = ["s3-read", "sqs-consume", "kms-decrypt"]
}

resource "aws_iam_policy" "app" {
  for_each = var.policies
  name     = "app-${each.key}"
  policy   = data.aws_iam_policy_document.app[each.key].json
}

resource "aws_iam_role_policy_attachment" "good" {
  for_each   = var.policies
  role       = aws_iam_role.app.name
  policy_arn = aws_iam_policy.app[each.key].arn
}

Key Points

  • Unknown keys break the plan; unknown values inside a known key set do not
  • -target is a two-stage hack, not a design
  • Key off input variables and locals, never off attributes of resources being created
  • random_id, timestamp() and chained data sources are common accidental sources of unknowns
Q38

Explain Terraform's concurrency model. What does -parallelism actually control and why do large states get slow?

AdvancedPerformance

Answer

Terraform compiles the configuration into a directed acyclic graph and walks it with a bounded pool of worker goroutines, ten by default, adjustable with -parallelism. Each worker evaluates one graph node: refreshing a resource, planning it, or applying it. Nodes with no dependency relationship run concurrently; nodes with an edge between them serialise.

That means parallelism is a ceiling on concurrent node evaluation, not a guarantee of speedup, because a deep dependency chain runs at width one no matter what you set. Providers are separate plugin processes communicating with the Terraform core over gRPC, and each provider configuration gets its own process, so concurrent work fans out into concurrent provider API calls. Raising parallelism therefore raises the rate of calls to the cloud API, which is exactly how teams hit AWS throttling and see ThrottlingException or Rate exceeded errors part way through an apply.

Lowering it to four or five is a legitimate mitigation for a throttled account. Memory is the other axis. Terraform loads the entire state into memory, builds the full graph, and keeps plan structures for every resource, so a state with several thousand resources can consume gigabytes and a plan can take many minutes mostly in refresh.

The levers, in order of impact: split the state so no single stack holds thousands of resources, use -refresh=false for routine pull request plans while running a full refresh on a schedule, target providers that support batching, and remove data sources that fan out expensive list calls. Measure before tuning, TF_LOG=trace with timestamps or the timing information in plan JSON tells you whether time goes to refresh, to provider calls, or to graph construction.

# Throttled account: slow down
terraform apply -parallelism=4

# Big read-only plan on a fast API: speed up
terraform plan -parallelism=30 -refresh=false -out=tfplan

# Find where the time actually goes
TF_LOG=trace TF_LOG_PATH=./tf.log terraform plan
grep -E "aws_[a-z_]+: (Refreshing|Reading|Creating)" ./tf.log | head -50

# Provider-level retry tuning for throttled AWS accounts
provider "aws" {
  region     = "ap-south-1"
  max_retries = 10
  retry_mode  = "adaptive"
}

Key Points

  • -parallelism bounds concurrent graph node evaluation, default 10
  • Dependency depth, not the flag, sets the real concurrency floor
  • Higher parallelism means more provider API calls and more throttling risk
  • Whole state is held in memory, so splitting state beats every tuning flag
Q39

How does Terraform communicate with providers, and what is involved in writing one with the plugin framework?

AdvancedProvider Internals

Answer

Providers are standalone binaries, not libraries. Terraform core launches each one as a subprocess and speaks to it over gRPC using go-plugin, exchanging a defined protocol (version 5 for the older SDK, version 6 for the modern framework). Core owns the graph, the state and the plan; the provider owns the schema and the CRUD implementations.

That separation explains several behaviours: provider crashes surface as a plugin process exited unexpectedly error rather than a Go panic in Terraform, provider logs need TF_LOG_PROVIDER to appear, and a provider upgrade can change plan output without any configuration change because the schema or the plan-modification logic changed. Writing a provider today means terraform-plugin-framework rather than the legacy SDKv2. You implement Metadata, Schema, Configure, and then per-resource Create, Read, Update, Delete plus optional ImportState.

The framework has an explicit type system that distinguishes null, unknown and known values, which is what lets you write correct plan modifiers, for example marking an attribute as requiring replacement or preserving a value the API normalises. Data sources implement Read only. Terraform 1.8 added provider-defined functions, so a provider can now ship pure functions callable as provider::name::function, which removes a whole category of ugly regex and split() gymnastics from configuration.

Most teams never write a full provider, but internal providers do appear: wrapping an internal platform API, a feature-flag service, or an on-premise appliance with no public provider. In an interview the point is to show you understand that providers are processes with their own lifecycle and their own schema versioning, including state upgraders that migrate stored attributes when a schema version bumps.

# Provider-defined function, no regex gymnastics needed
locals {
  parsed = provider::aws::arn_parse(aws_db_instance.primary.arn)
  account = local.parsed.account_id
}

# Debugging a misbehaving provider
TF_LOG=debug TF_LOG_PROVIDER=trace TF_LOG_PATH=./provider.log terraform plan

# Running a locally built provider without publishing it
# ~/.terraformrc
provider_installation {
  dev_overrides {
    "acme/internal" = "/Users/dev/go/bin"
  }
  direct {}
}

Key Points

  • Providers are separate processes speaking gRPC protocol 5 or 6 to Terraform core
  • terraform-plugin-framework replaces SDKv2 and models null, unknown and known explicitly
  • Schema versions plus state upgraders migrate stored attributes across provider releases
  • Provider-defined functions (1.8+) are called as provider::name::function
Q40

How do you decide where to split state, and how do you move resources between state files without downtime?

AdvancedArchitecture

Answer

State boundaries determine blast radius, plan duration and who can break what, so they are an architectural decision rather than a filing convention. The usual split is by rate of change and by ownership. Slow-moving foundation (accounts, VPCs, transit gateways, DNS zones, IAM baseline) goes in its own state applied rarely by a platform team.

Shared data services (RDS, ElastiCache, MSK, OpenSearch) go in another, because their applies are risky and infrequent. Each application or team gets its own state per environment, so a bad plan for one service cannot destroy another. Regions and accounts are always separate states.

The test to apply is: if this plan goes wrong, what is the worst thing it can delete, and who is paged. If the answer spans two teams, split it. The cost of splitting is cross-state wiring, which you solve with published SSM parameters or, within one team, terraform_remote_state.

Moving existing resources between states is a mechanical procedure you should be able to recite. Back up both states with terraform state pull. In the destination configuration, write the resource blocks and matching import blocks with the real IDs.

In the source configuration, delete the resource blocks and add removed blocks with lifecycle destroy = false. Apply the destination first, verify with terraform plan that it reports no changes, then apply the source. Nothing is created or destroyed at any point, only state entries move. The older equivalent using terraform state mv with -state-out works but is imperative and unreviewable, so prefer the import and removed pairing, and run both applies in the same maintenance window with locks held so no one plans against a half-migrated pair.

# 1. Back up both states
terraform -chdir=stacks/platform state pull > platform.backup.tfstate
terraform -chdir=stacks/payments state pull > payments.backup.tfstate

# 2. Destination (stacks/payments/import.tf)
import {
  to = aws_elasticache_replication_group.sessions
  id = "payments-sessions"
}

# 3. Source (stacks/platform/removed.tf), resource block deleted
removed {
  from = aws_elasticache_replication_group.sessions
  lifecycle { destroy = false }
}

# 4. Apply destination, confirm clean plan, then apply source

Key Points

  • Split by rate of change, ownership and blast radius, not by resource type
  • Cross-state contracts belong in SSM or a registry, not in shared state reads
  • Move resources with import blocks in the destination and removed blocks in the source
  • Verify the destination plans clean before removing anything from the source
Q41

How do you replace a stateful resource such as an RDS instance or a security group without downtime?

AdvancedProduction Operations

Answer

First establish what forces replacement, because the plan tells you: the # forces replacement annotation names the exact attribute. For RDS that might be engine major version on some engines, storage type changes on others, or the identifier itself. For a security group it is almost always the name or a VPC change.

Then choose a strategy. For resources where a second copy can coexist, create_before_destroy plus name_prefix is the mechanism: Terraform creates the replacement, moves dependencies, then destroys the old one. It only works if names are unique, which is why security groups, IAM roles, launch templates and target groups should use name_prefix rather than name in any configuration expected to change.

Remember that create_before_destroy propagates, every dependency of the resource must also use it or Terraform reports a cycle. For resources where two copies cannot coexist meaningfully, such as a primary database, Terraform is the wrong tool for the cutover. The pattern is to provision the new instance alongside as a real resource, use the database's own replication to sync, cut over the application through DNS or a connection string in SSM, verify, and only then remove the old resource from configuration.

Terraform manages the endpoints, your runbook manages the switch. Two guards make this survivable: prevent_destroy plus provider-side deletion_protection on the old instance so nothing can accidentally delete it mid-migration, and ignore_changes on attributes the migration mutates out of band. Finally, always test the replacement path in a staging stack with the same lifecycle settings, because create_before_destroy cycles and name collisions only appear at plan time on a real state.

# Safe to replace: unique names generated per revision
resource "aws_security_group" "api" {
  name_prefix = "api-"
  vpc_id      = var.vpc_id
  lifecycle { create_before_destroy = true }
}

resource "aws_launch_template" "api" {
  name_prefix   = "api-"
  image_id      = data.aws_ami.al2023.id
  instance_type = var.instance_type
  lifecycle { create_before_destroy = true }
}

# Not safe to replace: guard it and migrate out of band
resource "aws_db_instance" "primary" {
  identifier          = "payments-prod"
  deletion_protection = true
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [engine_version]
  }
}

Key Points

  • Read # forces replacement in the plan to find the exact triggering attribute
  • create_before_destroy needs name_prefix and propagates to all dependencies
  • Databases need replication-based cutover, orchestrated outside Terraform
  • prevent_destroy plus provider deletion protection guards the old resource during migration
Q42

Your production state file is corrupted or lost. Walk through the recovery.

AdvancedIncident Response

Answer

Stop all pipelines first. Every additional plan or apply against a bad state increases the damage, so disable the workflow and hold the lock deliberately if you can. Then classify the failure.

If state is corrupted but present, S3 bucket versioning is the fastest recovery: list object versions for the state key, download the last known good version, verify it parses and that its serial is plausible, and restore it. If you use HCP Terraform, the state versions list in the workspace does the same thing with a rollback button. If a bad apply wrote a legitimate but wrong state, restoring the previous version is still correct, followed by a terraform plan -refresh-only to reconcile whatever really changed.

Common corruption signals are a JSON parse error, a serial that went backwards, or the message stating the state snapshot was created by a newer version of Terraform, which is a version mismatch rather than corruption and is fixed by upgrading the CLI, never by hand-editing the version field. If state is genuinely gone with no version history, you rebuild it: enumerate the real resources through the cloud console or CLI, write import blocks for each, and run terraform plan -generate-config-out where the configuration is missing. This is hours of work for a large stack and is the argument for versioning, for splitting state, and for a nightly terraform state pull archived to a separate bucket in a separate account.

Once recovered, run a full plan and read every line before applying anything. Afterwards, write the postmortem action items that actually prevent recurrence: versioning and MFA delete on the state bucket, no human write access, locking enforced, and an automated state backup job.

# 1. Freeze: disable the workflow, then inspect versions
aws s3api list-object-versions \
  --bucket acme-tfstate-ap-south-1 \
  --prefix prod/payments/terraform.tfstate \
  --query "Versions[:5].[VersionId,LastModified,Size]" --output table

# 2. Pull a known good version and sanity check it
aws s3api get-object --bucket acme-tfstate-ap-south-1 \
  --key prod/payments/terraform.tfstate \
  --version-id 3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY \
  restored.tfstate
jq ".serial, .lineage, (.resources | length)" restored.tfstate

# 3. Restore, then reconcile against reality
terraform state push restored.tfstate
terraform plan -refresh-only

Key Points

  • Freeze pipelines before touching anything, every plan makes it worse
  • S3 object versioning or HCP state versions is the primary recovery path
  • A newer-version error is not corruption, upgrade the CLI instead of editing JSON
  • Total loss means rebuilding through import blocks and -generate-config-out
💡 Pro Tip: Set up a nightly job that runs terraform state pull and archives the output to a bucket in a different AWS account. Cross-account copies survive the failure modes that also delete your version history.
Q43

How do you roll out a major provider version bump, for example AWS provider v5 to v6, across dozens of state files?

AdvancedProduction Operations

Answer

A provider major bump can rename attributes, remove deprecated arguments, change default values, alter how an attribute is normalised, and occasionally change what forces replacement. Because the provider computes the diff, a plan can propose destroying resources purely because the provider changed, with no configuration edit at all. So the rollout is a project, not a dependency bump.

Read the upgrade guide the provider publishes and grep the estate for every removed or renamed argument. Bump in one low-risk stack first, ideally a sandbox that mirrors production resource types, with terraform init -upgrade, and then diff the plans: capture terraform show -json before and after and compare resource_changes, rather than eyeballing console output. Anything that moves from no-op to update or replace is a finding to investigate before going further.

Roll out in waves, non-production first, then low-criticality production stacks, then the crown jewels, with a day or two between waves so latent problems surface. Keep the version constraint pinned in required_providers during the rollout so a stack that has not been migrated cannot accidentally pick up the new major version, and commit the updated .terraform.lock.hcl per stack as its own reviewable pull request. Provider schema upgrades also rewrite state, so take state backups before the first apply in each stack.

If a stack has hundreds of resources and the diff is ambiguous, apply with -target on a handful of representative resources first to confirm the behaviour, then apply fully. The same discipline applies to module major versions, where a renamed internal resource silently means destroy and create unless the module author shipped moved blocks.

# Capture a baseline plan on the current provider
terraform plan -out=before.tfplan && terraform show -json before.tfplan > before.json

# Bump and re-plan
sed -i "" 's/version = "~> 5.0"/version = "~> 6.0"/' versions.tf
terraform init -upgrade
terraform plan -out=after.tfplan && terraform show -json after.tfplan > after.json

# Anything that newly wants to replace is a blocker
jq -r '.resource_changes[]
      | select(.change.actions | index("delete"))
      | .address' after.json > after-deletes.txt
diff <(jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' before.json) after-deletes.txt

Key Points

  • Provider upgrades can change plans and force replacements with zero config edits
  • Diff terraform show -json plans before and after, do not eyeball the console
  • Roll out in waves with pinned constraints and per-stack lock file pull requests
  • Provider schema upgrades rewrite state, so back up before the first apply
Q44

An apply fails halfway with a provider error. How do you debug it and what state are you left in?

AdvancedDebugging

Answer

Understand the failure model first. Terraform applies node by node, and it writes state incrementally as each resource completes, so a failure at resource forty leaves the first thirty-nine recorded and applied. You are not left with a rolled-back environment, you are left with a partially applied one, which is why re-running apply is normally the correct next step: the graph recomputes and only the outstanding work runs.

The exception is a resource that was created at the API but whose state write failed, which produces an orphan the next plan tries to create again, failing with an already exists error; that is an import case. For diagnosis, TF_LOG=debug or trace with TF_LOG_PATH writes a structured log including every provider gRPC request and response, and TF_LOG_PROVIDER=trace narrows it to the plugin. Read the actual API error rather than the Terraform wrapper: throttling shows as ThrottlingException or Rate exceeded and calls for lower -parallelism plus provider max_retries and retry_mode = adaptive; timeouts show as context deadline exceeded and are usually fixed with a timeouts block on the resource, since defaults are conservative for things like RDS and EKS; eventual consistency shows as a resource not found immediately after creation and is a provider bug or a missing dependency.

A plugin process exited unexpectedly message means the provider crashed, which is worth reporting upstream with the crash log Terraform writes. Also check whether the failure left a lock behind, and whether the resource was marked tainted, in which case decide deliberately whether recreating it is safe before re-running.

TF_LOG=debug TF_LOG_PROVIDER=trace TF_LOG_PATH=./apply.log \
  terraform apply -parallelism=5 -lock-timeout=10m

grep -iE "error|throttl|deadline exceeded" ./apply.log | tail -30

# Long-running resources need explicit timeouts
resource "aws_rds_cluster" "payments" {
  cluster_identifier = "payments-prod"
  engine             = "aurora-postgresql"

  timeouts {
    create = "90m"
    update = "90m"
    delete = "60m"
  }
}

Key Points

  • State is written incrementally, so failures leave a partially applied environment
  • Re-running apply is usually correct; already exists errors mean import instead
  • TF_LOG with TF_LOG_PATH captures the raw provider gRPC calls and API errors
  • Throttling, context deadline exceeded and plugin crashes each have distinct fixes
Q45

Terraform, OpenTofu, Pulumi, CDKTF or Crossplane: how do you argue for one in 2026?

AdvancedEcosystem

Answer

Terraform moved from MPL to the Business Source License in August 2023, which prohibits competing commercial use of the code but has no practical effect on a company using it to manage its own infrastructure. That change produced OpenTofu, a Linux Foundation fork that stayed open source. OpenTofu is a drop-in replacement at the CLI and state level for the versions it tracks, and it has shipped features Terraform does not have, notably client-side state encryption and earlier evaluation of variables in backend and module source blocks.

It is a defensible choice when licence risk matters to your legal team or when state encryption at the tool level is a hard requirement, and migration is mostly changing the binary and the provider registry host. Pulumi takes a different position: real programming languages (TypeScript, Python, Go, C#) instead of HCL, with the same declarative engine and provider ecosystem underneath. It wins when your team is genuinely software-engineering-first and wants loops, abstraction and unit tests in a language they already use; it loses on readability for operations people and on the size of the copy-pasteable community corpus.

CDKTF sits in between, generating Terraform JSON from TypeScript or Python, which gives you language power while keeping Terraform providers and state. Crossplane is not really a competitor: it manages infrastructure as Kubernetes custom resources reconciled continuously by controllers, so it fits teams whose control plane is already Kubernetes and who want continuous reconciliation rather than plan-and-apply. For most Indian platform teams in 2026 the honest recommendation is still Terraform or OpenTofu, because the hiring pool, the module ecosystem and the tooling around plan review are all deepest there.

Key Points

  • BUSL affects vendors reselling Terraform, not companies managing their own infrastructure
  • OpenTofu is the open source fork, with state encryption and early variable evaluation
  • Pulumi and CDKTF trade HCL readability for real programming languages
  • Crossplane reconciles continuously through Kubernetes controllers, a different model entirely
💡 Pro Tip: If asked which you would pick, answer with the decision criteria (licence exposure, team language skills, existing control plane, hiring pool) rather than a favourite. Panels are testing judgement, not loyalty.

Companies Hiring Terraform

Razorpay
Swiggy
Flipkart
PhonePe
Freshworks
Dream11
Zomato
Infosys

Salary Insights

Average in India
₹8-26 LPA

Frequently Asked Questions

What does a Terraform engineer earn in India in 2026?

Roughly ₹8-26 LPA depending on level and how much of the platform you own. A DevOps engineer two to four years in, using Terraform alongside Kubernetes and CI/CD, typically sits at ₹8-15 LPA. Five to eight years with real ownership of multi-account cloud architecture, state design and policy enforcement moves you into ₹18-30 LPA at product companies in Bengaluru, Hyderabad, Pune and the NCR. Staff and principal platform roles at well-funded fintech and consumer internet firms go higher. Services and consulting firms generally pay below product companies for the same years of experience, so the biggest single salary jump for most Terraform engineers in India is a services to product move rather than another year of experience.

How long does it take to prepare for a Terraform interview?

If you already write Terraform at work, two to three focused weeks is enough: revise state mechanics and locking, practise explaining import, moved and removed blocks out loud, write a module with typed variables and .tftest.hcl tests, and rehearse two production incidents you personally handled. Starting from zero with existing cloud knowledge, budget six to ten weeks of hands-on work, and build something real, a VPC with subnets across three availability zones, an ECS or EKS workload, RDS with a secret pulled at runtime, all in a remote backend with a CI pipeline. Reading documentation alone does not survive contact with an interviewer who asks what you did when a state lock went stale at midnight.

What is the difference between fresher and experienced Terraform interviews?

Freshers are asked about the language and the workflow: resource versus data, variables and outputs, count versus for_each, what state is, how modules are called. Getting those crisp and correct is enough. From about three years the questions shift to consequences: how you split state and why, what happens when two pipelines apply at once, how you refactored a module without destroying production, how secrets stay out of state, how you gate a plan that wants to delete a database. Senior rounds are almost entirely design and incident conversation, and the strongest signal is that you can describe a specific failure you caused or fixed, including what you changed afterwards so it could not happen again.

Is Terraform still worth learning in 2026 given OpenTofu and Pulumi?

Yes. The skill transfers almost entirely across all three because they share the same provider ecosystem and the same plan-apply-state model, so learning Terraform is learning infrastructure as code rather than learning one vendor's CLI. Terraform remains the default in Indian job descriptions, has the deepest module ecosystem, and is what most existing estates are written in, which means maintenance and migration work will exist for years. OpenTofu matters mainly as a licence-driven alternative and is close enough that switching is a binary change plus a registry change. Learn Terraform first, then read the OpenTofu differences in an afternoon.

Do I need Ansible and Kubernetes too, or is Terraform enough on its own?

Terraform on its own qualifies you for a narrow provisioning role. Most Indian platform job descriptions pair it with Kubernetes and a CI/CD system, because provisioning is only useful when something runs on top of it. The strongest common combination is Terraform for cloud resources, Kubernetes plus Helm or Argo CD for workloads, GitHub Actions or GitLab CI for delivery, and Prometheus or an OpenTelemetry stack for observability. Ansible is less universally required now that most compute is immutable and built from images, but it still appears in enterprises with substantial virtual machine and on-premise estates, which in India is a large slice of the market.

Is the HashiCorp Certified: Terraform Associate exam worth doing in India?

It has real value early in a career and little value later. For a fresher or someone switching from support or systems administration into DevOps, the certification gets a resume past screening filters at services companies and system integrators, and preparing for it forces you to cover state, backends, modules and the CLI systematically. For anyone with three or more years of hands-on experience, interviewers weigh what you have built far more than the badge, and a public repository containing a well-structured module with tests and a working pipeline is a stronger signal than the exam. Treat it as a study plan you happen to get a certificate for, not as the goal.

Introduction

Terraform is the tool most Indian platform teams reach for when infrastructure has to be repeatable across dev, staging and production. It reads HCL configuration, builds a dependency graph, compares that graph against recorded state, and issues the minimum set of provider API calls needed to reconcile the two. That reconciliation loop is the whole product, and it is also where every interesting interview question lives: what state actually holds, what happens when two pipelines apply at once, how a plan behaves when a value is unknown until apply, and what you do at 2 AM when a state file no longer matches reality.

Interviews for Terraform roles in 2026 have moved well past syntax. Hiring panels at fintech, consumer internet and cloud consulting firms assume you can write a resource block, so they probe the operational edges instead: remote backends and locking, import and moved and removed blocks, module versioning discipline, secrets that leak into state, the native terraform test framework, policy gates in CI, and the failure modes of large monolithic state files. Candidates who have only used Terraform through a Terragrunt wrapper or a golden pipeline tend to get exposed quickly, because those layers hide exactly the mechanics interviewers want to hear you reason about.

This guide covers 45 Terraform interview questions asked in 2026, ordered from fundamentals through production failure modes. Each answer explains the actual behaviour rather than the marketing description, names the exact commands, config keys and error strings you will see, and calls out the gotcha that separates someone who has run Terraform in anger from someone who has only followed a tutorial. Most questions carry runnable HCL or CLI examples. Work through the basic tier to firm up the mental model, then spend real time on the intermediate and advanced tiers, since those decide senior platform offers.

Ready to practice Terraform interviews?

Don't just read, practice these Terraform questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview