HomeInterview questionsTerraformTerraform

Terraform interview questions and answers

25 Terraform questions from real interviews, sorted by seniority: state and locking, modules, count against for_each, drift, and the production mistakes that get talked about out loud.

25 questions6 junior11 mid8 seniorLive exerciseWhat each level testsHow to prepare

All 25 questions

  1. 1

    What problem does Terraform solve that a shell script does not?

    Terraform is declarative and stateful. You describe the infrastructure you want, and it works out the difference between that and what exists, then makes only the changes needed. A script is imperative: it describes steps, and running it twice does something different from running it once.

    That gives you three things a script does not:

    • A plan you can review before anything changes.
    • Idempotence, so converging on the desired state is safe to repeat.
    • A dependency graph built from references between resources, so it knows a subnet needs its VPC first without you ordering the calls by hand.

    It also spans providers, so one configuration can cover AWS, Cloudflare DNS and a GitHub repository in the same apply.

    link
  2. 2

    Walk me through terraform init, plan and apply.

    1. 1`terraform init` prepares the working directory. It downloads the provider plugins, installs modules, and configures the backend where state will live. It is the only command that touches .terraform, and it is safe to re-run.
    2. 2`terraform plan` refreshes state, compares it to the configuration, and prints the actions needed. It changes nothing. The output is the review artefact, which is why teams run it in CI on pull requests.
    3. 3`terraform apply` executes that plan. Without a saved plan file it re-plans and asks for confirmation.

    The detail worth adding: in CI you should save the plan and apply exactly that file, so what was reviewed is what runs.

    terraform plan -out=tfplan
    terraform apply tfplan
    link
  3. 3

    What is the Terraform state file and why does it matter?

    State is Terraform's record of which real resources correspond to which configuration addresses, plus the attribute values it last saw. Without it, Terraform cannot tell an instance it created from one somebody made by hand, so it cannot know whether to create, update or destroy.

    Three consequences interviewers look for:

    • It is the source of truth for the mapping, so losing it means Terraform will try to create everything again.
    • It contains secrets in plain text, because any attribute a provider returns is stored, including generated passwords.
    • It must be shared and locked for a team, which is why a local terraform.tfstate does not survive contact with a second engineer.
    link
  4. 4

    What is the difference between a Terraform variable, a local and an output?

    DirectionSet from outsideUsed for
    `variable`InputYes, by tfvars, -var, env or a module callParameterising a configuration
    `local`InternalNoAvoiding a repeated expression
    `output`OutputNoExposing a value to the operator, a parent module or another state

    The distinction that gets probed: a local is computed, not configurable, so reaching for one where the caller needs control is a design mistake rather than a style choice.

    variable "env" { type = string }
    locals { name_prefix = "acme-${var.env}" }
    output "vpc_id" { value = aws_vpc.main.id }
    link
  5. 5

    How do you manage Terraform state for a team, and what does locking do?

    Put state in a remote backend that supports locking, so the file is shared and two applies cannot run at once. On AWS that is S3, with native locking since Terraform 1.10 and a DynamoDB table before that.

    terraform {
      backend "s3" {
        bucket       = "acme-tfstate"
        key          = "production/terraform.tfstate"
        region       = "eu-west-1"
        encrypt      = true
        use_lockfile = true
      }
    }

    Locking prevents the failure it is there for: two concurrent applies both reading the same state, each writing back its own view, and the second silently discarding the first one's resources from the record.

    Enable bucket versioning as well. It is the only undo you get if state is corrupted, and it costs nothing until you need it.

    link
  6. 6

    When would you use terraform import, and what does it not do?

    When a resource exists but Terraform does not know about it: something created by hand, inherited from another team, or made by a different tool.

    import {
      to = aws_s3_bucket.assets
      id = "acme-assets-prod"
    }

    The thing it does not do is write your configuration. Import only populates state. You still have to author HCL that matches the real resource, and if it does not match, the next plan will propose changes you did not intend. The workflow is import, then plan repeatedly, adjusting the configuration until the plan is empty.

    Since Terraform 1.5 the import block makes this reviewable in a plan rather than a side-effecting CLI call, which is a meaningful improvement for teams.

    link
  7. 7

    What makes a good Terraform module?

    Any directory of .tf files is a module, so the question is really about interface design.

    A good module does one thing at a coherent level of abstraction, such as "a VPC with public and private subnets", not "all of our infrastructure". It exposes a small number of meaningful inputs with types and descriptions, and sensible defaults where a default is genuinely sensible. It outputs the identifiers a caller will need, because a caller that has to reach into a module's internals means the interface is wrong.

    It also avoids two things. It does not configure providers internally, since that stops the caller controlling regions and aliases. And it does not hardcode environment names or account IDs, which is what makes it reusable at all.

    Version your modules and pin the version at the call site, so upgrading is a deliberate change that shows up in a plan.

    link
  8. 8

    When would you use for_each rather than count in Terraform?

    Almost always, and the reason is how the resources are addressed.

    Addressed byRemoving an itemRight for
    `count`Index, web[0]Every later index shifts down, so unchanged resources are destroyed and recreatedA conditional resource
    `for_each`Key, web["api"]Affects only that entryEverything else

    The index shift is the classic painful count bug, and it is what the question is really asking about.

    resource "aws_instance" "web" {
      for_each      = var.instances
      instance_type = each.value.size
      tags          = { Name = each.key }
    }

    count is still right for one thing: a conditional resource, count = var.enabled ? 1 : 0.

    link
  9. 9

    What is Terraform drift, and how do you handle it?

    Drift is the real infrastructure no longer matching state, because something changed outside Terraform: a console edit, another tool, or an autoscaling action.

    Detect it with a plan, which refreshes state before comparing. In CI, a scheduled plan that should be empty is an effective drift alarm:

    terraform plan -detailed-exitcode

    Exit code 0 means no changes, 2 means changes pending, 1 means an error, which makes it easy to wire into a pipeline.

    Then decide per case. Usually you apply, so Terraform reasserts the declared state. If the manual change was correct, bring it into configuration instead. And where a value is legitimately managed elsewhere, such as a desired count under autoscaling, use lifecycle { ignore_changes = [desired_capacity] } so it stops being reported as drift.

    link
  10. 10

    Why lock provider versions, and what does the lock file do?

    Because providers are separate software with their own releases, and a new major version can change defaults, rename attributes or alter behaviour. Without a constraint, a fresh init on a new machine can pull a different provider and produce a different plan from identical configuration.

    terraform {
      required_version = ">= 1.9"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.60"
        }
      }
    }

    .terraform.lock.hcl records the exact versions and their checksums, and belongs in version control. It is what makes CI and a laptop resolve the same providers, and the checksums also protect against a tampered plugin.

    link
  11. 11

    Terraform says the state is locked. What do you do?

    Assume it is legitimate first. The message names who holds the lock and when they took it, and most of the time that is a CI run or a colleague mid-apply.

    terraform apply -lock-timeout=10m

    That waits rather than failing, and usually the other apply finishes inside the window.

    Only when you are certain nothing is running do you break it:

    terraform force-unlock 8f3c1e2a-4b7d-11ef-9c2a

    Say out loud that this is the dangerous step. Breaking a lock during a real apply can leave state not matching reality, or corrupt it outright. The senior version of this answer mentions bucket versioning as the recovery path, and that a lock left behind by a cancelled CI job is best fixed by making the pipeline clean up rather than by unlocking by hand each time.

    link
  12. 12

    What is the precedence order for setting Terraform variables?

    Later wins. In order:

    1. 1Defaults in the variable block
    2. 2TF_VAR_ environment variables
    3. 3terraform.tfvars
    4. 4terraform.tfvars.json
    5. 5Any *.auto.tfvars, in lexical order
    6. 6-var and -var-file on the command line

    The practical point is that the command line beats everything, which is what makes CI overrides work, and that a file named terraform.tfvars is picked up automatically while any other name needs -var-file.

    Worth adding: a variable with no default and no value supplied makes Terraform prompt interactively, which hangs a pipeline. terraform plan -input=false turns that into an error instead, which is what you want in CI.

    link
  13. 13

    How do you structure Terraform state across environments, and what are the trade-offs?

    The main choice is separate state per environment, and the main mistake is one state for everything.

    Separate root modules per environment, each with its own backend key, is the usual answer: production/terraform.tfstate, staging/terraform.tfstate. Blast radius is contained, permissions can differ per environment, and a mistake in staging cannot destroy production. The cost is duplicated root configuration, which shared modules mitigate.

    Workspaces keep one configuration with multiple state files. They are convenient but weak for environments, because every workspace shares the same backend and therefore the same credentials and the same blast radius, and it is easy to apply to the wrong one. They suit short-lived parallel copies, such as per-branch review environments.

    Also split by lifecycle, not just by environment. Networking, data stores and applications change at very different rates, and separating them keeps a routine deploy from planning against the VPC. The cost is cross-state references through terraform_remote_state or data sources, which is real coupling to manage.

    link
  14. 14

    Someone deleted the Terraform state file. Walk me through recovery.

    Establish what still exists before touching anything, because the resources are almost certainly fine. Only Terraform's record of them is gone.

    If the backend bucket has versioning, this is a restore, not a rebuild:

    aws s3api list-object-versions --bucket acme-tfstate \
      --prefix production/terraform.tfstate \
      --query 'Versions[:5].[VersionId,LastModified]' --output table

    Fetch the last good version and push it back with terraform state push. Check a plan afterwards: it should be empty or close to it.

    Without versioning there is no undo, and you are rebuilding state by importing every resource. That is slow and error-prone, which is exactly why versioning and a lifecycle policy on the state bucket are part of setting up a backend, not an optimisation.

    The follow-up worth pre-empting: prevent it rather than recover it. Versioning, deny delete in the bucket policy for everyone but a break-glass role, and never let a local state file be the only copy.

    link
  15. 15

    What does the Terraform lifecycle block give you, and when is each part the right tool?

    Four behaviours, and each solves a specific real problem.

    create_before_destroy builds the replacement before removing the original, which avoids downtime for something like a launch template or an instance behind a load balancer. It requires that both can exist at once, so it fails on anything with a unique name.

    prevent_destroy makes Terraform error rather than delete. Correct for a production database or a state bucket. Note it blocks the whole apply, and does not stop somebody removing the block first.

    ignore_changes tells Terraform to stop managing named attributes after creation. Right for values legitimately changed elsewhere, such as a desired count under autoscaling or a tag applied by another system.

    replace_triggered_by forces a replacement when a referenced resource changes, which is how you rebuild an instance when its launch template version moves.

    The senior caveat: all four hide a modelling problem when overused. Reaching for ignore_changes on a long list of attributes usually means something else owns the resource and Terraform should not.

    link
  16. 16

    How do you run Terraform in CI safely?

    Four things, roughly in order of how much damage their absence causes.

    Separate plan from apply. Plan runs on the pull request with read-only credentials and posts its output for review. Apply runs after merge with write credentials, from the saved plan file, so the reviewed change is the applied change.

    Short-lived credentials. OIDC from the CI provider into a role, never a long-lived access key in a variable. This is the single biggest real-world risk on the list.

    Locking and serialisation. A remote backend with locking, plus concurrency control in the pipeline so two merges do not race. And handle cancellation, since a killed job leaves the lock behind.

    Guardrails. -input=false and -no-color, policy checks with OPA, Sentinel or Conftest, and a required human approval for destructive plans. Scanning with tfsec or checkov catches the obvious misconfigurations before they exist.

    Worth saying that state and plan files are sensitive. Plan output contains attribute values and should not be posted to a public pull request or kept as a build artefact indefinitely.

    link
  17. 17

    Terraform state holds secrets in plain text. What do you actually do about it?

    Accept that it does, and reduce the exposure rather than pretend to encrypt it away.

    Treat state as a secret store. Encrypt at rest, restrict the bucket to the roles that genuinely need it, enable access logging, and keep it out of version control. Most real leaks are an over-permissive bucket or a state file committed by accident, not a cryptographic failure.

    Do not generate secrets in Terraform where you can avoid it. A random_password written into an RDS instance is now in state forever. Prefer having the service generate it, or store it in a secret manager and have the application read it at runtime, so Terraform only manages the reference.

    Mark outputs sensitive so they are redacted from CLI output and plans. Be clear in the interview that this affects display only, not storage.

    Rotate. If state was exposed, the credentials in it are compromised and rotating them is the only real remedy.

    link
  18. 18

    How do you upgrade a widely used module without breaking its consumers?

    Treat it as an API, because that is what it is.

    Version it and let callers pin. Follow semantic versioning honestly: renaming an input or changing a default that alters a resource is a major bump, whatever it looks like in the diff.

    For a breaking change, add the new path alongside the old one and deprecate rather than replace. A new optional variable with a default that preserves current behaviour lets consumers migrate on their own schedule. Where a resource address has to change, ship a moved block so consumers get a no-op plan instead of a destroy and recreate:

    moved {
      from = aws_instance.web
      to   = aws_instance.app
    }

    That block is the detail that separates people who have done this from people who have read about it. Without it, a refactor inside a module looks like data loss to everyone using it.

    link
  19. 19

    A terraform plan shows a resource being destroyed and recreated that you did not change. Why?

    Something upstream of it changed in a way that forces replacement, and the plan says which attribute with a # forces replacement marker. Read that first, because it names the cause.

    The usual candidates: an attribute the provider cannot change in place, such as an EC2 subnet or an RDS engine version on some paths; a count index shift after removing an earlier element; a module refactor that changed the resource address, so Terraform sees a removal and an addition rather than a rename; or a provider upgrade that changed a default.

    terraform plan | grep -B5 "forces replacement"

    For the address-change case the fix is a moved block. For an index shift it is migrating from count to for_each. And if the replacement is genuinely required but the resource cannot take downtime, create_before_destroy is the mitigation.

    The habit worth stating: never approve a plan with an unexplained destroy. That is how databases disappear.

    link
  20. 20

    What is the difference between a Terraform data source and a resource?

    A resource is something Terraform creates, updates and destroys, and therefore owns. A data source is something Terraform only reads, so it can reference values it did not create.

    data "aws_ami" "ubuntu" {
      most_recent = true
      owners      = ["099720109477"]
      filter {
        name   = "name"
        values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
      }
    }

    Two practical points. Data sources are read during plan, so a data source that depends on a resource created in the same apply can fail on the first run, which is why you sometimes need depends_on or a two-stage apply. And a data source like the one above resolves to whatever is newest at plan time, so it is a source of surprise replacements unless you pin the AMI.

    link
  21. 21

    How do you deploy the same Terraform configuration to two regions or accounts?

    Provider aliases, passed explicitly to the resources or modules that need them.

    provider "aws" {
      region = "eu-west-1"
    }
    
    provider "aws" {
      alias  = "us"
      region = "us-east-1"
    }
    
    resource "aws_s3_bucket" "logs_us" {
      provider = aws.us
      bucket   = "acme-logs-us"
    }

    For modules you pass a mapping with providers = { aws = aws.us }. This is exactly why a reusable module should not declare its own provider block: doing so takes this control away from the caller.

    A CloudFront certificate is the canonical example, since ACM certificates for CloudFront must live in us-east-1 regardless of where everything else is.

    link
  22. 22

    What does it mean that Terraform builds a dependency graph?

    Terraform reads the references between resources and derives the order of operations from them. Writing subnet_id = aws_subnet.private.id tells it the subnet must exist first; you never specify the order yourself.

    It uses that graph to parallelise as well, applying independent resources at the same time, which is why an apply is much faster than the same calls in sequence.

    Where it needs help is a dependency that is real but not expressed as a reference, such as an IAM policy that must attach before a service can start. depends_on states that explicitly. Needing it often is usually a sign the configuration is passing values around that it should be referencing directly.

    link
  23. 23

    How would you introduce Terraform to infrastructure that was all built by hand?

    Incrementally, and starting with the parts where a mistake is cheap.

    Set up the foundations first: a state backend with versioning and locking, a module layout, CI running plan on pull requests, and OIDC credentials. That is all additive and breaks nothing.

    Then bring existing resources under management one bounded area at a time, lowest risk first. Networking and IAM are usually a bad place to start despite being foundational, because a wrong plan there is an outage. Something like a staging environment, or DNS records, gives you the workflow without the risk.

    For each area: write HCL, import, then iterate until terraform plan is empty. An empty plan is the acceptance test, and reaching it is most of the work because hand-built resources have accumulated attributes nobody documented.

    Two organisational points that matter as much as the technical ones. Close the console door progressively, or drift undoes the work as fast as you do it. And do not try to model everything: some things are genuinely better left out, and pretending otherwise produces a configuration nobody trusts.

    link
  24. 24

    How do you debug a provider error that is not obvious from the plan?

    Turn on logging, which is the part people forget exists.

    TF_LOG=DEBUG TF_LOG_PATH=./tf.log terraform apply

    TF_LOG=DEBUG includes the API requests and responses the provider makes, which is usually where the real error is. A permissions problem, for instance, shows as a specific denied API call rather than the generic message Terraform surfaces.

    Then narrow it. terraform plan -target=aws_instance.web isolates one resource, and terraform console lets you evaluate expressions against real state to check whether a value is what you think:

    terraform console
    > local.name_prefix
    > aws_vpc.main.cidr_block

    Say that -target is a debugging tool, not a workflow. Routinely applying with -target produces state that no plan has ever fully validated.

    link
  25. 25

    Which Terraform files should and should not be in version control?

    Commit the configuration and the lock file: *.tf, *.tf.json, and .terraform.lock.hcl. The lock file belongs in Git specifically so CI and every laptop resolve identical provider versions.

    Do not commit .terraform/, which is a local cache of plugins and modules and can be hundreds of megabytes. Do not commit terraform.tfstate or its backups, both because it belongs in a remote backend and because it contains secrets in plain text. Do not commit *.tfvars files holding real values, since that is a common way credentials end up in a repository, and do not commit saved plan files.

    .terraform/
    *.tfstate
    *.tfstate.*
    *.tfvars
    !example.tfvars
    tfplan
    crash.log

    Keeping a checked-in example.tfvars with placeholder values is a good habit, since it documents the inputs without carrying any.

    link

Live exercise: the plan wants to destroy something you did not touch

Asked in almost every senior Terraform interview, usually with a plan on screen. The habit being tested is that you never approve an unexplained destroy.

  1. Does the plan name an attribute with # forces replacement?

    terraform plan | grep -B5 'forces replacement'
    yes
    An immutable attribute changed. The provider cannot alter it in place, so it must replace the resource. Read which attribute, because that is the actual cause.
    no
    Nothing forced a replacement, so Terraform is seeing a removal and a separate addition. That means the resource address changed, not the resource.
  2. Did you rename a resource or move it between modules?

    yes
    Terraform has no way to know that is a rename. Add a moved block and the plan becomes a no-op.
    no
    Check whether a provider upgrade changed a default, which shows up as a change nobody made. The lock file tells you whether the provider moved.
  3. Is the resource created with count?

    terraform state list | grep '\['
    yes
    Removing an earlier element shifts every later index down one, so resources that did not change are planned for replacement. Migrate to for_each so addressing is by key.
    no
    If it is genuinely a required replacement, the question becomes whether it can be done without downtime.
  4. Can the resource tolerate being replaced?

    yes
    Apply, and prefer create_before_destroy so the replacement exists before the original goes.
    no
    Stop. For a database or anything holding state, add prevent_destroy, and change the approach so the attribute does not need to move.

What each level is testing

  1. 1

    Junior6 questions

    Whether you understand it is declarative and stateful. The init, plan, apply cycle, what state is for, and the difference between a variable, a local and an output. Saying that state maps configuration to real resources is most of the answer.

  2. 2

    Mid11 questions

    Whether you have worked in a team. Remote backends and locking, count against for_each and why the index shift matters, module interfaces, drift, and provider version pinning.

  3. 3

    Senior8 questions

    Whether you have cleaned up after an incident. State layout across environments, recovering a deleted state file, moved blocks when refactoring a shared module, running Terraform in CI without long-lived credentials, and the fact that state holds secrets in plain text.

What the round is like

Terraform rounds concentrate on state, because that is where the expensive mistakes live. Expect definitions early, then a stretch on modules and iteration, and then a scenario: a plan proposing something destructive, a lock that will not clear, or state that has been lost. Interviewers are listening for whether you have operated Terraform with other people, not whether you have read the documentation.

How to prepare with this

  1. 1Learn the state answers cold. State is the single most asked area, and the locking and recovery questions are where candidates either sound experienced or do not.
  2. 2Be able to say why for_each beats count in one sentence about stable addressing. This comes up constantly and a vague answer is very visible.
  3. 3Practise the destructive-plan scenario out loud. Reading forces replacement from a plan and explaining what caused it is a common live exercise.
  4. 4Know the moved block exists. It is the clearest single signal that you have refactored a module other people depended on.
  5. 5Have an opinion on workspaces against separate root modules, and be able to defend it. Interviewers ask this to see whether you reason about blast radius.

Learn the underlying material

Other question sets