50 real Terraform interview questions with detailed answers on state, modules, workspaces, drift and IaC best practices — grouped by difficulty.
Terraform is an open-source Infrastructure as Code (IaC) tool from HashiCorp that lets you define and provision infrastructure — servers, networks, databases, DNS records — using a declarative configuration language called HCL. You describe the end state you want, and Terraform figures out what needs to be created, changed, or destroyed to get there.
The distinction that actually matters in an interview is provisioning versus configuration. Terraform's job is to bring infrastructure into existence: spin up a VM, create a VPC, register a DNS zone. Ansible's job is to configure what's already running: install packages, write config files, restart services. Terraform is also declarative — you state the desired end result and it computes the diff — while Ansible is closer to a procedural, ordered list of tasks executed top to bottom. In practice, teams use both together: Terraform provisions the VM, then hands off to Ansible (or a cloud-init script, or Packer-built image) to configure the software on it.
Further reading: Terraform language overview
Infrastructure as Code (IaC) means defining your servers, networks, and other infrastructure in version-controlled configuration files instead of creating them by clicking through a cloud console or running one-off CLI commands.
The problem it solves is consistency and repeatability. Manually clicking through a console doesn't leave an audit trail, can't easily be reviewed by a teammate, and is nearly impossible to reproduce exactly in a second environment. With IaC, the configuration file is the source of truth: you can diff it, code-review it, roll it back with git, and reuse it to spin up a near-identical staging environment. It also removes a whole class of "it worked before, nobody knows what changed" incidents, because every change to infrastructure goes through the same reviewed, versioned path as application code.
Further reading: HashiCorp Developer: Terraform
A provider is a plugin that lets Terraform talk to a specific platform's API — AWS, Azure, GCP, Kubernetes, GitHub, Datadog, and hundreds of others each have one. The provider translates the resource blocks you write in HCL into actual API calls against that platform.
You declare which providers you need and, ideally, pin their version:
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }} provider "aws" { region = "us-east-1"}terraform init downloads the provider plugin from the Terraform Registry (or a private registry) into a local cache before any resources can be planned or applied. Without the right provider installed, Terraform has no idea how to create an aws_instance or a google_compute_instance — the core Terraform binary itself knows nothing about any specific cloud.
Further reading: Providers overview
The everyday loop is four commands, always in this order:
terraform init # download providers and modules, set up the backendterraform plan # show what would change, without touching anythingterraform apply # execute the plan and actually change infrastructureterraform destroy # tear down everything this configuration managesinit is required once per working directory (and again any time you add a provider, module, or change the backend). plan is a dry run — it compares your configuration against the current state and shows a diff of what would be added, changed, or destroyed, without making any real change. apply executes that diff. In a team or CI/CD setting, it's common to run terraform plan -out=tfplan and then terraform apply tfplan, so the exact plan that was reviewed is the exact plan that gets applied — nothing can drift between review and execution.
Further reading: Terraform CLI workflow (plan/apply/destroy)
The state file (terraform.tfstate) is a JSON file that maps every resource in your configuration to the real object it created in the cloud — for example, mapping aws_instance.web in your HCL to the actual EC2 instance ID i-0a1b2c3d. It also caches metadata about each resource, like attributes that were generated by the provider and aren't written anywhere in your config.
Terraform needs it because HCL alone doesn't tell you anything about what currently exists — it only describes what you want. Without state, Terraform would have no way to know that aws_instance.web was already created on a previous apply, so it would try to create it again on every run, or have no idea what to delete when you remove a resource block. State is what turns "here's my desired configuration" into "here's the specific diff between what's real and what I want."
Further reading: Terraform state overview
A resource block tells Terraform to create, manage, and eventually destroy something — Terraform owns its full lifecycle. A data block does the opposite: it reads information about something that already exists, without managing or being able to destroy it.
# Terraform creates and owns thisresource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = "t3.micro"} # Terraform only reads this — it did not create it and won't destroy itdata "aws_vpc" "default" { default = true}A common use for data sources is looking up something managed outside Terraform — a shared VPC, an AMI ID that changes weekly, an existing IAM policy — so you can reference its attributes (like data.aws_vpc.default.id) inside resources you do manage, without importing that thing into your state.
Further reading: data block reference
Variables are how you parameterize a configuration so the same code can be reused with different inputs — a different instance size for staging versus prod, a different region, a different tag. You declare them with a variable block and reference them with var.name.
variable "instance_type" { type = string default = "t3.micro" description = "EC2 instance size to launch"} resource "aws_instance" "web" { instance_type = var.instance_type}Values can come from a default in the block itself, a .tfvars file, -var on the command line, or an environment variable prefixed TF_VAR_. Declaring a type is worth doing even though it's optional — Terraform will reject a value that doesn't match at plan time instead of failing halfway through an apply.
Further reading: variable block reference
A variable is an input — something the caller of the module or configuration supplies from outside. A local is a computed value used only inside that same configuration; nothing outside can set it.
variable "environment" { type = string} locals { name_prefix = "${var.environment}-web"} resource "aws_instance" "web" { tags = { Name = local.name_prefix }}Locals are useful for avoiding repetition — computing a value once (like a naming convention or a merged tag map) and reusing it across many resource blocks — without exposing it as something a caller could override. If you find yourself repeating the same expression in three resource blocks, that's usually a sign it belongs in a locals block.
Further reading: Terraform style guide (locals/variables)
terraform fmt rewrites .tf files to Terraform's canonical formatting — consistent indentation, alignment of = signs, spacing — without changing what the configuration actually does.
On a solo project it's a nice-to-have. On a team, it removes an entire category of noisy pull request diffs and bikeshedding about style, the same way gofmt or prettier does for code. Most teams run terraform fmt -check as a CI step that fails the build if someone forgot to format their files, and some pair it with terraform fmt -recursive in a pre-commit hook so it never reaches review in the first place.
Further reading: Format and validate configuration
terraform validate checks that your configuration is syntactically valid HCL and internally consistent — correct argument names, correct types, no obvious reference errors — without talking to any provider or looking at real infrastructure. It runs fast and doesn't need credentials.
terraform plan goes much further: it calls out to your providers, refreshes state against real infrastructure, and computes an actual diff of what would change. validate catches typos and structural mistakes early, often in a pre-commit hook or the first step of CI, before spending time or API calls on a full plan. It will not catch things like "this AMI ID doesn't exist" or "you don't have permission to create this resource" — those only show up during plan or apply, once Terraform is actually talking to the provider.
Further reading: Format and validate configuration
A module is just a directory of .tf files that Terraform treats as a reusable unit. Every Terraform configuration is technically a module — the directory you run terraform apply in is the "root module" — but the useful pattern is writing a module once (say, for "a standard VPC" or "an S3 bucket with our standard tags and encryption") and calling it from multiple places with different inputs.
module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" environment = "staging"}Modules exist to avoid copy-pasting the same fifty lines of HCL into every project. A well-designed module exposes a small set of variables as its interface and hides the implementation details, the same way a function hides its internals behind a signature.
Further reading: Modules language docs
required_providers (inside a terraform block) declares which providers a configuration needs, where to get them, and which versions are acceptable.
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.20" } }}Without a version constraint, terraform init will grab the latest provider release available, which can silently change resource schemas, default behaviors, or even argument names between runs — someone re-running init on a laptop three months later can get a different provider version than what CI used, and see plan diffs that have nothing to do with any code change. Pinning to a range (or exact version, paired with a committed .terraform.lock.hcl file) makes provider upgrades a deliberate, reviewed decision instead of something that happens by accident.
Further reading: Provider requirements
.terraform.lock.hcl is generated automatically by terraform init and records the exact provider versions (and their checksums) that were resolved for your configuration — separate from, and much smaller than, terraform.tfstate. Your required_providers block sets a version constraint (like ~> 5.0); the lock file records the specific version that was actually selected and hashed.
# .terraform.lock.hcl — generated, not hand-editedprovider "registry.terraform.io/hashicorp/aws" { version = "5.47.0" constraints = "~> 5.0" hashes = [ "h1:...", ]}HashiCorp's own guidance is to commit it, for the same reason you'd commit a package-lock.json or Pipfile.lock: without it, every fresh terraform init re-resolves the version constraint against whatever the newest matching release is at that moment, so a teammate running init next month can silently get a different provider version than the one CI tested against, with no diff or review anywhere. With it committed, provider upgrades only happen when someone deliberately runs terraform init -upgrade, and that version bump shows up as an ordinary, reviewable line change in a pull request. The one operational gotcha worth naming: the lock file records hashes per platform, so a team mixing macOS and Linux (or Linux-only CI) needs to run terraform providers lock -platform=<platform> for every platform in use, or CI will fail with a missing-hash error the first time someone's local init didn't generate an entry for it.
Further reading: Dependency lock file
Two separate problems, both serious. First, the state file is not just bookkeeping — it routinely contains sensitive data in plaintext, including things like database passwords or private keys that ended up as resource attributes, even when the corresponding variable was marked sensitive. Committing it to a public (or even private) git repo means that data is now sitting in history forever, even if you delete it in a later commit.
Second, a local-only state file means only one person's laptop knows what infrastructure actually exists. If a teammate applies changes from their own local state, or the laptop with the "real" state file dies, you end up with either a stale or missing source of truth and Terraform trying to recreate resources that already exist. The standard fix is a remote backend — S3, Azure Blob Storage, GCS, or Terraform Cloud — so state lives in one shared, access-controlled, versioned location that every teammate and every CI job reads from and writes to consistently.
Further reading: State: Remote Storage
State locking prevents two terraform apply (or plan) operations from writing to the same state file at the same time. If supported by the backend, Terraform automatically acquires a lock before any operation that could write state, and releases it when the operation finishes.
Without locking, two people (or two CI jobs) running apply concurrently against the same state can race: both read the same "before" state, both compute a diff, both write their result — and whichever one finishes last silently overwrites the other's changes in the state file, even though both sets of changes exist in the real infrastructure. That mismatch between what state says exists and what's actually there is exactly the kind of corruption that turns into a multi-hour incident. Locking just serializes writes so only one operation can hold the lock at a time; everyone else waits or gets told the state is locked.
Further reading: State locking
Both let you create multiple instances of a resource from a single block, but they address those instances differently. count uses a numeric index (aws_instance.web[0], [1], [2]); for_each uses a stable key from a map or set of strings (aws_instance.web["dev"], ["prod"]).
That difference matters the moment the list changes. With count, removing the first element in a three-item list shifts every subsequent index down by one — Terraform sees [1] and [2] as "new" addresses and destroys-and-recreates resources that never actually needed to change. With for_each, removing one key only affects that key; everything else is untouched because the addressing isn't positional.
# count — index shifts if the list changesresource "aws_instance" "web" { count = length(var.subnet_ids) subnet_id = var.subnet_ids[count.index]} # for_each — keyed, stable even if the map changesresource "aws_instance" "web" { for_each = toset(var.environments) subnet_id = local.subnet_by_env[each.value]}The practical rule: use for_each by default for anything that might grow, shrink, or reorder over time. count is still fine for a genuinely fixed, order-independent quantity, or the simple on/off pattern (count = var.enabled ? 1 : 0) to conditionally create a single resource.
Further reading: Meta-arguments overview
Count vs for_each, in depth
plan does a three-way comparison, not just a two-way diff against your .tf files. First it refreshes: for every resource in state, it calls the provider's API to read the object's current real-world attributes. Then it compares three things — the state file (what Terraform last recorded), the real infrastructure (what the refresh just found), and your configuration (what you've declared you want) — and produces a plan that reconciles all three into one target.
This is why plan can show a change even when you haven't touched any .tf file: if someone manually resized an EC2 instance in the console, the refresh step picks that up as drift, and the plan will show Terraform intending to change it back to match your configuration (since config, not the manually-changed reality, is the declared source of truth). As of newer Terraform versions you can also run terraform plan -refresh-only to see just the drift, without proposing any config-driven change alongside it.
Further reading: terraform plan reference
terraform_remote_state reads another Terraform configuration's state file (not its module code) and exposes its outputs as data, typically used when two configurations are deliberately separate — different repos, different teams, different apply cadences — but one needs a value the other created, like a VPC ID from a networking stack feeding into an application stack.
data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "my-tf-state" key = "networking/terraform.tfstate" region = "us-east-1" }} resource "aws_instance" "app" { subnet_id = data.terraform_remote_state.network.outputs.subnet_id}Passing values directly (as a module input) only works when both pieces live in the same root configuration and are applied together. Once you deliberately split state into separate layers — which most teams do for exactly the reasons covered in the multi-account state-layout question — terraform_remote_state (or an equivalent data lookup, like reading an SSM Parameter Store value the other stack wrote) becomes the way to bridge them without merging state or coupling apply schedules.
Further reading: Remote state data source
A workspace is a named, isolated state file within the same configuration and backend — terraform workspace new staging creates a separate state without duplicating any .tf code. You switch with terraform workspace select and reference terraform.workspace in your config to vary behavior per workspace.
resource "aws_instance" "web" { instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.micro"}The alternative — a directory per environment (environments/dev, environments/staging, environments/prod, each with its own backend config and .tfvars) — duplicates some boilerplate but keeps environments fully independent: different provider credentials, different variable files, no risk of running apply against prod because you forgot which workspace was selected. Workspaces are lighter-weight and fine for genuinely similar environments with the same variables and provider config, but most teams managing prod alongside lower environments prefer separate directories (or separate root modules with a shared child module) specifically because workspace selection is an easy, low-visibility mistake to make, and the blast radius of applying against the wrong workspace is high.
Further reading: Workspaces (config)
Workspaces (CLI)
A provisioner runs a script or command on a resource at creation or destruction time — for example, running a shell command on a new EC2 instance right after it boots. They exist for cases with no better alternative, not as a default tool.
resource "aws_instance" "web" { # ... provisioner "remote-exec" { inline = ["sudo apt-get update", "sudo apt-get install -y nginx"] }}HashiCorp's own docs describe provisioners as a "last resort" because they break Terraform's core model: the result of a provisioner isn't tracked in state the way a resource attribute is, so Terraform has no idea whether it actually succeeded on a later run, can't detect drift in what the script did, and can't cleanly roll it back. The preferred approach is almost always to push that work upstream — bake configuration into the machine image with Packer, use cloud-init / user-data, or hand off to a proper configuration-management tool like Ansible after Terraform provisions the resource. Provisioners are more defensible for genuinely one-off bootstrap actions than for ongoing configuration management.
Further reading: Provisioners
local-exec runs a command on the machine running Terraform itself (your laptop, or the CI runner) — useful for things like triggering a webhook or writing a value to a local file after a resource is created. remote-exec runs the command on the newly created resource, over SSH or WinRM, and needs connection details for that resource.
resource "aws_instance" "web" { # ... provisioner "local-exec" { command = "echo ${self.public_ip} >> inventory.txt" } provisioner "remote-exec" { inline = ["echo ready"] connection { type = "ssh" user = "ubuntu" host = self.public_ip } }}remote-exec is more fragile in practice — it needs network reachability, correct credentials, and the target to actually be up and accepting connections at the moment Terraform tries to connect, which is a common source of flaky applies in CI.
Further reading: Provisioners
lifecycle is a meta-argument available on every resource that changes how Terraform handles its create/update/destroy behavior, separate from the resource's own arguments.
resource "aws_launch_template" "web" { # ... lifecycle { create_before_destroy = true prevent_destroy = true ignore_changes = [tags["LastDeployedBy"]] }}create_before_destroy flips the default order: Terraform provisions the replacement resource first and only destroys the old one once the new one exists. This matters whenever some other resource depends on this one staying available — replacing a launch template used by an autoscaling group, for instance — since the default destroy-then-create order would leave a gap with nothing serving traffic.prevent_destroy makes terraform destroy (or a plan that would replace/delete this resource) fail outright, as a guardrail against accidentally deleting something like a production database.ignore_changes tells Terraform to stop flagging drift on specific attributes — useful when something outside Terraform (an autoscaler, another team's automation, a tag added by a compliance scanner) legitimately changes that attribute, and you don't want every plan to propose reverting it.Further reading: Meta-arguments overview (lifecycle)
Most of the time you never need depends_on at all: if one resource's argument references another resource's attribute (subnet_id = aws_subnet.main.id), Terraform automatically knows the subnet must exist first — that's an implicit dependency, inferred from the reference graph.
depends_on is for the cases where a real dependency exists but nothing in the configuration references it directly — most commonly IAM: an EC2 instance needs an IAM role's policy to be fully attached before it starts, but the instance resource never references the policy attachment resource by name.
resource "aws_instance" "web" { # no direct reference to the policy attachment... depends_on = [aws_iam_role_policy_attachment.web]}The rule of thumb: reach for depends_on only when you've confirmed there's no way to express the dependency through an actual attribute reference, because it's a blunt instrument — it forces full sequential ordering rather than letting Terraform parallelize independent resources, and it's easy to overuse as a band-aid for a plan that's failing for an unrelated reason.
Further reading: depends_on meta-argument
Terraform builds a dependency graph from every reference between resources (plus any explicit depends_on), then walks that graph — resources with no dependencies on each other get scheduled in parallel, and anything that depends on another resource waits until that resource finishes first.
You can inspect this yourself with terraform graph, which outputs the dependency graph in DOT format for visualization. Within a single apply, this parallelism is one reason Terraform is fast even for large configurations — it isn't running top-to-bottom through your .tf files, it's running through a graph, so the physical order of blocks in your files has no bearing on execution order.
Further reading: terraform graph reference
sensitive = true on a variable or output tells Terraform to redact that value from CLI output — plan and apply show (sensitive value) instead of the real string, and it's hidden from the Terraform Cloud/HCP UI too.
What it does not do is remove the value from the state file. The state file stores real, unredacted resource attributes in plaintext JSON, regardless of any sensitive flag on a variable — that flag only controls what's printed to a terminal or shown in a UI. This is a common interview trap because it sounds like encryption but isn't. Actually protecting a secret in Terraform means encrypting the state at rest (S3 server-side encryption, or a backend that encrypts by default), tightly restricting who can read the state file, and ideally not putting long-lived secrets into Terraform variables at all — generating them at apply time with something like random_password and immediately pushing them into a secrets manager (Vault, AWS Secrets Manager) that the application reads from directly, rather than storing them as a Terraform-managed attribute.
Further reading: variable block reference (sensitive)
Both mark a resource for destruction and recreation on the next apply, without any change to its configuration — useful when a resource is degraded, corrupted, or you just need a clean rebuild. taint does this as a separate step: it writes a "tainted" marker into the state file immediately, and the actual replace happens on whatever apply runs next, possibly run by someone else who didn't ask for it.
-replace folds that into a single plan/apply invocation:
terraform apply -replace="aws_instance.web"HashiCorp deprecated taint (as of Terraform 0.15.2) in favor of -replace for one specific reason: with -replace, the intended replacement shows up in the plan output for review before anything happens, and there's no window where the state is silently marked tainted and someone else's unrelated apply picks it up. taint still exists as of recent Terraform versions but is deprecated, not removed — HashiCorp's guidance is to use -replace going forward.
Further reading: terraform taint reference
Recreate resources with -replace
All three edit or inspect the state file directly, without touching real infrastructure — they're for fixing a mismatch between what state says and what you actually want it to say.
terraform state list — prints every resource address currently tracked in state. Usually the first thing you run to find the exact address of something before running mv or rm against it.terraform state mv — renames a resource's address in state (e.g. after renaming a resource block, or moving it into a module) without destroying and recreating the real object. terraform state mv aws_instance.web module.app.aws_instance.web tells Terraform "this is the same real EC2 instance, just addressed differently now."terraform state rm — removes a resource from state without destroying it in the real world. Common use: you're splitting one Terraform configuration into two, and a resource needs to stop being managed by this state so it can be imported into the other one instead.terraform state listterraform state mv aws_s3_bucket.old aws_s3_bucket.newterraform state rm aws_s3_bucket.legacyThe newer moved block (Terraform 1.1+) handles the rename/refactor case declaratively and gets reviewed in a normal plan, so many teams now prefer it over manually running state mv — see the moved-block question for the difference.
Further reading: terraform state mv reference
A dynamic block generates nested configuration blocks programmatically from a list or map, for situations where the number of nested blocks (not just a value inside one) needs to vary — most commonly repeated ingress/egress rules on a security group.
variable "ingress_rules" { type = list(object({ port = number cidr = string }))} resource "aws_security_group" "web" { dynamic "ingress" { for_each = var.ingress_rules content { from_port = ingress.value.port to_port = ingress.value.port protocol = "tcp" cidr_blocks = [ingress.value.cidr] } }}The trap to name here: dynamic blocks are more powerful than they need to be for most cases and can make configuration genuinely harder to read. If the number of nested blocks is fixed and known, just write them out statically — reach for dynamic only when the set of rules is a real input that varies by caller.
Further reading: Dynamic blocks
A splat expression (aws_instance.web[*].id) is a shorthand for pulling one attribute out of every instance of a resource created with count or for_each, producing a simple list. A for expression is the general-purpose tool — it can filter, transform, and reshape into a list or a map.
# splat — just the idsoutput "instance_ids" { value = aws_instance.web[*].id} # for — filtered and reshaped into a mapoutput "large_instances" { value = { for k, v in aws_instance.web : k => v.id if v.instance_type == "m5.large" }}Use splat for the simple "give me this one attribute from all of them" case — it's shorter and reads cleanly. Reach for a for expression the moment you need filtering, a computed key, or a map instead of a list; trying to force that through a splat expression usually ends up less readable than just writing the for expression directly.
Further reading: Splat expressions
From lowest to highest priority, later sources override earlier ones: the default in the variable block, then TF_VAR_* environment variables, then terraform.tfvars (and terraform.tfvars.json), then any *.auto.tfvars files (alphabetically), then -var-file flags in the order given, and finally -var flags on the command line — which win over everything else.
export TF_VAR_instance_type=t3.smallterraform apply -var="instance_type=m5.large"# m5.large wins — CLI -var beats everythingThe practical reason to know this order: CI pipelines often set environment variables for defaults but let an explicit -var-file per environment override them, and any teammate debugging "why did it apply with the wrong instance type" needs to check the whole chain, not just the .tfvars file that seems most obvious.
Further reading: Environment variables (TF_VAR_*)
A validation block inside a variable definition rejects bad input immediately, with a clear error message, before Terraform does anything else with it.
variable "environment" { type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be one of: dev, staging, prod." }}The advantage over catching a bad value downstream is where and how it fails. Without validation, a typo like "produciton" might pass silently into some conditional logic and produce a confusing plan diff three resources later, or worse, apply cleanly with wrong behavior. With validation, it fails at terraform plan with a message that names exactly which input was wrong and why — which matters a lot for a module that other teams consume, since they get a useful error instead of having to read your module's internals to figure out what went wrong.
Further reading: variable block reference (validation)
null_resource is a resource that does nothing on its own — it exists purely as a place to attach provisioners or to participate in the dependency graph, when you need Terraform-managed behavior that isn't tied to any real cloud object.
resource "null_resource" "deploy_trigger" { triggers = { ami_id = var.ami_id } provisioner "local-exec" { command = "./notify-deploy.sh ${var.ami_id}" }}The triggers map is the key mechanic: Terraform only re-runs the resource's provisioners when a value in triggers changes between applies, giving you explicit control over when a side-effecting action (a notification, a cache invalidation, a webhook) should fire, rather than it running on literally every apply. In modern Terraform, terraform_data is the newer built-in replacement that does the same job without depending on the separate null provider.
Further reading: Configure resources (terraform_data)
The standard pattern: terraform fmt -check and terraform validate run on every push as a fast first gate, then terraform plan runs automatically on any pull request and posts its output as a PR comment, so reviewers see exactly what would change before approving. terraform apply only runs after merge to the main branch — never from a developer's laptop against shared state, and never triggered by an unreviewed branch.
PR opened → fmt/validate → plan → plan output posted to PRPR approved & merged → apply (using the exact plan, not a fresh one)Two details separate a solid pipeline from a fragile one. First, save the plan with terraform plan -out=tfplan and apply that exact file (terraform apply tfplan) rather than re-running plan right before apply — otherwise something could change infrastructure in the gap between review and execution, and what gets applied isn't what was actually reviewed. Second, restrict who or what can run apply against production state — typically only the CI service account, never a human's personal credentials — with state-locking on the backend so two pipeline runs can't race. Tools like Atlantis or Terraform Cloud's VCS integration implement this whole flow out of the box rather than requiring you to hand-build it in a generic CI runner.
Further reading: Terraform CLI workflow
Policy as code lets you write rules that a plan must pass before apply is allowed to run — not "is this valid HCL" (that's validate) but "is this change actually allowed here." Sentinel is HashiCorp's own policy engine, built into Terraform Cloud/HCP; Open Policy Agent (OPA) with Rego is the open-source equivalent, usable with the open-source CLI via conftest or similar tooling against the plan's JSON output.
A typical policy: block any plan that would create an S3 bucket without encryption enabled, or that would launch an EC2 instance type outside an approved list, or that touches a prod workspace without an explicit change-ticket tag. Without policy as code, that kind of rule lives only in a human reviewer's head and gets missed under time pressure; with it, the check runs automatically on every plan and fails the pipeline with a specific, named violation before anything gets applied — the same shift that moved manual code-style nitpicks into an automated linter.
Further reading: Sentinel for Terraform
With a self-managed S3 (or similar) backend, Terraform still runs wherever you invoke it — your laptop or a CI runner — and S3 is only responsible for storing and locking the state file. You're on your own for orchestrating who can apply, secret injection, policy checks, and run history.
HCP Terraform (formerly Terraform Cloud) is a managed service that runs the actual plan/apply execution remotely, in HashiCorp's environment (or self-hosted, via Terraform Enterprise), and adds a UI with run history, a built-in state backend with locking already handled, team-based access controls, variable sets shared across workspaces, and native Sentinel policy checks — the pieces a team would otherwise assemble themselves out of CI scripts, an S3 bucket, a DynamoDB table, and a secrets manager. The trade-off is mostly about control versus convenience: HCP centralizes a lot of operational concerns for you, at the cost of depending on HashiCorp's platform (or running your own Enterprise instance) instead of infrastructure you fully own.
Further reading: HCP Terraform docs
OpenTofu is an open-source fork of Terraform, created after HashiCorp changed Terraform's license from MPL 2.0 to the Business Source License (BSL) in 2023. It's maintained under the Linux Foundation, with the CLI, providers, and module ecosystem staying largely compatible with Terraform — most existing .tf configurations work against OpenTofu with little or no change, and tofu mirrors Terraform's command structure (tofu init, tofu plan, tofu apply).
For an interview, the relevant point is less "which one is better" and more understanding why the fork happened and what stayed compatible versus diverged — OpenTofu has since shipped some features ahead of Terraform (like early state-encryption support) while Terraform has continued its own separate development under the BSL. Since this space moves fast, it's worth checking current docs for the two projects' latest feature parity rather than assuming anything is permanently true.
Further reading: OpenTofu
Both end up tearing down infrastructure through the same underlying mechanism — a plan that proposes destroying resources — but they differ in scope and intent. terraform destroy tears down everything currently tracked in that configuration's state, regardless of what your .tf files currently say; it's meant for decommissioning an entire environment or stack.
terraform destroy # tears down every resource in this stateterraform destroy -target=aws_instance.web # tears down just one (use sparingly, see -target question)Deleting a resource block from your .tf file and running terraform apply has a narrower, and arguably more common, real use: Terraform sees that resource is gone from configuration but still in state, and proposes destroying just that one resource, leaving everything else in the configuration untouched. That's the normal way to decommission one piece of a larger stack — removing an old S3 bucket while everything else keeps running — without touching the rest of what that state manages. The trap worth naming: terraform destroy run against the wrong workspace or the wrong directory doesn't ask "are you sure, only these three resources?" — it proposes destroying the entire state, which is exactly why it's rarely wired into CI/CD the way apply is, and why some teams require an extra manual confirmation step or a separate, more locked-down pipeline specifically for destroy operations.
Further reading: terraform destroy reference
Terraform CLI workflow
The Terraform Registry labels every provider with a tier so you know who's actually responsible for it. Official providers are written and maintained by HashiCorp itself (aws, azurerm, google, kubernetes, and so on). Partner (and the higher Partner Premier) providers are built and maintained by a third-party technology company that's gone through HashiCorp's partner program and maintains a direct relationship with HashiCorp — think a cloud vendor's own provider for their product. Community providers are published and maintained by individual contributors, with no HashiCorp relationship or guaranteed support behind them.
terraform { required_providers { aws = { source = "hashicorp/aws" # Official version = "~> 5.0" } }}The tier matters because it's a real signal about support and longevity, not just a badge. A Community provider might be the only option for a smaller or niche SaaS product, and plenty of them are well-maintained — but there's no obligation on anyone to keep it working, patch security issues promptly, or track breaking changes in the underlying API the way there is for an Official or Partner provider. For anything load-bearing in production, checking a Community provider's recent commit history, open issue count, and release cadence before depending on it is a reasonable step that an Official provider doesn't really require.
Further reading: Providers overview
Browse providers
The wrong answer is "Terraform updates it" — Terraform has no idea it exists. State is Terraform's only memory of what's real; if a resource isn't in state, Terraform treats your configuration for it as brand new and tries to create it. Depending on the resource type, that either fails outright with a naming or uniqueness conflict from the provider (a bucket name that's already taken, for instance), or — worse — succeeds and gives you a second, duplicate object you now have to notice and clean up by hand.
Recovery is a two-step process, and skipping the second step is the mistake people make under pressure:
terraform import aws_s3_bucket.my_bucket my-existing-bucket-nameimport only creates the state entry — it binds the real object to a resource address in state. It does not generate or check any HCL for you. If the aws_s3_bucket.my_bucket block in your .tf file doesn't already describe the bucket's actual configuration (versioning, encryption, tags — whatever's really set), the very next plan will show Terraform proposing to "fix" all of those mismatches, silently reverting real settings to whatever your (incomplete) config says. So the required next step is writing HCL that accurately matches the real resource, then running plan and confirming the diff is empty before ever running apply.
This exact situation is also the classic result of someone provisioning something through the console instead of Terraform — being able to recover cleanly from it, rather than just knowing the import command exists, is what separates a strong answer here.
Further reading: terraform import command reference
As of Terraform 1.10 (late 2024), the S3 backend supports native state locking via S3's conditional writes, using the use_lockfile argument — no DynamoDB table required. When enabled, Terraform writes a small .tflock object alongside the state file during any operation that would write state; a concurrent operation's write gets rejected by S3 with a precondition-failed error instead of silently racing:
terraform { backend "s3" { bucket = "my-tf-state" key = "app/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true }}This genuinely simplifies the standard AWS backend setup — one fewer resource to provision, manage IAM permissions for, and pay for, since S3 conditional writes don't add cost the way an on-demand DynamoDB table does. The trade-off worth naming: this requires Terraform 1.9+ (practically, 1.10+ for a stable release using it), and teams migrating an existing setup need to do it deliberately — running with both dynamodb_table and use_lockfile set for a transition period, confirming a full plan/apply cycle works, before removing the DynamoDB table. HashiCorp has signaled that DynamoDB-based locking arguments are being deprecated in favor of this, but the exact deprecation timeline is worth checking against current docs rather than assuming, since this is an area that's still actively evolving.
Further reading: S3 native state locking explained
A resource's identity in state is its address (aws_instance.web), not its real-world ID. If you just rename the resource block in your .tf file from aws_instance.web to aws_instance.app_server, Terraform sees the old address disappear and a new one appear — its default assumption is "destroy the old one, create the new one," even though nothing about the actual EC2 instance needs to change.
The fix is telling Terraform explicitly that this is the same object under a new address, either with terraform state mv directly against the state file:
terraform state mv aws_instance.web aws_instance.app_serveror, since Terraform 1.1, declaratively with a moved block committed alongside the rename, so it goes through normal code review and shows up correctly in plan output instead of requiring someone to remember to run a manual state command:
moved { from = aws_instance.web to = aws_instance.app_server}The same mechanism handles moving a resource into a module (from = aws_instance.web, to = module.compute.aws_instance.web). The thing to get right in either case: run terraform plan immediately after and confirm it shows zero changes to the real resource — if it shows a replace, the moved block or state mv command has the wrong address on one side.
Further reading: moved block reference
Refactoring modules
The instinct to avoid is one giant state file covering every account, region, and layer of infrastructure. A few concrete reasons that breaks down in practice: plan gets slower as state grows, because every resource gets refreshed against the provider on every run; the blast radius of any mistake — a bad state rm, an accidental -target, a corrupted state — covers everything at once instead of one slice; and a single state lock means only one person on the entire team can run any apply, anywhere, at a time.
A layout that scales better splits state along two axes. By account/region — separate state per AWS account (or per region within an account), each with its own backend key and its own provider credentials, so a mistake in staging can't touch prod's state at all. And by layer — networking (VPCs, subnets, transit gateways) as its own state, shared platform services (IAM, DNS, shared clusters) as another, and application-specific infrastructure as a third, with the application layer reading the networking layer's outputs via terraform_remote_state rather than all three being applied together.
accounts/ prod/us-east-1/networking/ prod/us-east-1/platform/ prod/us-east-1/app-foo/ staging/us-east-1/networking/ ...The trade-off is real: more state files means more terraform_remote_state wiring and more moving pieces to keep track of. The payoff is that networking changes so rarely it can have a slow, careful review process, while application infrastructure that changes daily gets its own fast, independent apply cycle — and nobody's routine app deploy can accidentally touch the VPC.
Further reading: Refactor Terraform state
Tainting (or -replace) doesn't just replace the one resource you targeted — it forces that resource's dependents to react too, because Terraform's plan is computed off the whole dependency graph, not the one resource in isolation. If the security group is referenced by an ALB listener, and that listener references an ACM certificate, replacing the security group can cascade into the listener needing to be touched as well, depending on exactly how the resources reference each other and whether any of them need create_before_destroy to avoid a gap.
The failure mode this question is really testing for: someone runs -replace on the security group, skims the plan output, sees "1 to replace," and applies — without noticing the plan actually includes the listener or the load balancer's association changing too, because the cascade wasn't obvious from the resource name alone. In production, that's turned into unplanned downtime more than once (a real, commonly-cited version of this involves an ACM certificate attached to a load balancer listener getting caught in a cascade from an unrelated security-group replace).
The way to avoid it: always run terraform plan -replace=<address> first — never apply directly off a bare taint — and actually read the full resource list in the plan, not just the summary count, specifically checking for any resource you didn't expect to see. If the blast radius looks wider than intended, that's the signal to investigate the dependency chain (terraform graph or just reading the reference chain in the HCL) before applying, not after.
Further reading: Recreate resources with -replace
Real interview scenario write-up
This is the direct consequence of what sensitive = true does and doesn't do (see the sensitive-variable question): it only redacts CLI and UI output. State itself is a JSON file, and every attribute of every resource Terraform manages — including a random_password result, or a database's master password argument — is written into that file in plaintext, regardless of any sensitive flag anywhere in the configuration. Anyone who can read the raw state file can read the secret directly, no matter how carefully the variable was flagged in HCL.
Real protection has to happen at a few different layers, because no single flag fixes this:
The interview-worthy point is recognizing that sensitive = true is a UI/output control, not an access control, and that the actual fix lives at the state-storage and secrets-architecture layer, not in a per-variable flag.
Further reading: References to values (sensitive handling)
-target restricts an apply to a specific resource (and its dependencies), skipping everything else in the configuration:
terraform apply -target=aws_instance.webIt's genuinely useful in a narrow emergency case — production is down, you need one specific fix applied right now, and you don't have time to review a full plan touching unrelated resources. It's also sometimes used to work around a circular-dependency situation by applying pieces in a deliberate sequence.
The reason it's discouraged as a routine habit: -target only applies the resources in that dependency chain, which means the rest of your configuration is now, by definition, out of sync with what a full plan would produce. The next normal, untargeted apply — run by you or a teammate, possibly days later — can surprise whoever runs it with a much larger diff than expected, because changes that were queued up in the configuration but skipped by the targeted apply are still pending. It also completely bypasses whatever review process exists around a full plan, since a -target apply is often run directly against production during an incident, outside the normal PR-reviewed pipeline. HashiCorp's own guidance is that if you find yourself reaching for -target regularly rather than as a rare emergency exception, that's usually a sign the state or module is scoped too broadly and would benefit from being split, per the multi-account state-layout question.
Further reading: terraform plan reference (-target)
Start by reading exactly what the plan says would change — not just the resource name, but the specific attribute and the before/after values. A plan showing ~ update in-place on an attribute that genuinely changed in the console is real drift; the fix there is either to accept it (update your .tf to match, if the manual change should stick) or apply to revert it (if it shouldn't have been changed outside Terraform in the first place).
If the diff looks like it's flapping — the same attribute shows a change on every plan even right after an apply, with no real value actually different — that's usually one of a few specific causes: a provider version bug in how it normalizes a value (a classic example is JSON-in-a-string attributes where key ordering differs between what you wrote and what the API echoes back, even though semantically it's the same JSON); an attribute with a server-side default that the provider doesn't fully account for; or a computed attribute that genuinely changes on the provider's side between refreshes (like a "last modified" timestamp) and shouldn't be treated as configuration drift at all — that's a candidate for ignore_changes in the resource's lifecycle block, not something to keep re-applying.
The practical debugging sequence: check terraform plan output carefully for the exact attribute and value diff (not just the summary), check the provider's changelog/GitHub issues for that resource type and attribute if the diff looks spurious rather than real, and confirm whether the same drift appears from a completely fresh plan -refresh-only run — which isolates whether this is state-vs-reality drift specifically, separate from any config-driven change. Jumping straight to apply without doing this is how a provider bug turns into an actual unwanted change to real infrastructure.
Further reading: terraform plan reference
Workspaces share the same configuration and backend, differing only in which state file is active — lightweight, minimal duplication, and convenient when environments are genuinely near-identical. Separate directories duplicate some backend/variable boilerplate but give each environment fully independent state, credentials, and .tfvars.
For a team of 30 engineers with a prod environment in the mix, separate directories are the safer default, for reasons that get more serious as team size grows. Workspace selection is invisible in the code itself — terraform workspace show is the only way to know which one is currently active, and it's a per-shell, per-session setting that's trivially easy to have set to the wrong thing without realizing it. On a large team, that turns into "someone thought they were in staging and applied to prod" as a realistic failure mode, not a hypothetical one. Separate directories make the target environment visible in the file path and, typically, in the CI pipeline's job configuration, and let you use genuinely different IAM credentials per environment (something a shared workspace setup can't cleanly express, since the provider block and its credentials are the same across all workspaces in a configuration).
The honest trade-off to name: separate directories mean more files to keep in sync when a module's inputs change, and some teams solve that by keeping environment-specific differences in thin root modules that all call the same shared child module, getting the DRY benefit of workspaces' shared code without the state-isolation risk. At 30 engineers, the cost of that extra file-keeping is worth paying for the reduced blast radius of a wrong-environment apply.
Further reading: Workspaces (config)
Workspaces (CLI)
Some resource attributes force a full destroy-and-recreate rather than an in-place update (AWS calls this "requires replacement," and Terraform's plan output flags it explicitly with -/+). If Terraform's default order applies — destroy the old object, then create the new one — anything depending on that resource has a gap with nothing there, which for something serving live traffic means an outage, however brief.
The standard fix is create_before_destroy in the resource's lifecycle block, which flips that order so the replacement exists before the old one is torn down:
resource "aws_launch_template" "web" { name_prefix = "web-" image_id = var.ami_id # ... lifecycle { create_before_destroy = true }}For a launch template feeding an autoscaling group specifically, create_before_destroy alone isn't the whole story, because the ASG's existing instances were launched from the old template and won't automatically pick up the new one just because the template resource was replaced — the ASG has to actually cycle its instances. That typically means pairing the launch template change with an aws_autoscaling_group instance_refresh block (which triggers a rolling replacement of instances using the new template, respecting a minimum healthy percentage so capacity never drops below a safe threshold) rather than assuming the resource replacement alone achieves zero downtime. The name_prefix (instead of a fixed name) is also necessary here — with a fixed name, Terraform can't create the new launch template before destroying the old one, because AWS won't allow two objects with the same name to exist simultaneously, which silently defeats create_before_destroy even though it's configured correctly.
Further reading: Meta-arguments overview (lifecycle)
The dominant cost in a slow plan is almost always the refresh step: Terraform calls the provider's API once per resource in state to check its current real-world status, so a state with a thousand resources means at least a thousand API calls before Terraform even starts computing a diff. A state file over roughly 10MB, or more than 500–1000 resources, is generally the point where this becomes painfully noticeable.
The fixes, roughly in order of how quickly they help versus how much they restructure things:
-refresh=false skips the refresh step entirely for a given run. Fast, but it means Terraform is comparing your configuration against what it last knew, not against current reality — fine for a quick local iteration loop, risky to rely on before an apply that actually matters, since real drift won't be caught.-target narrows a plan to a specific resource and its dependencies, useful for iterating on one piece without paying the refresh cost for the whole state — with the same caveats about leaving the rest of the config out of sync that make it a poor daily habit (see the -target question).-parallelism (default 10) lets more of those refresh API calls happen concurrently, though this hits diminishing returns fast once you start getting rate-limited by the provider's API.Backend latency is a secondary factor worth ruling out too — the entire state file has to be downloaded from the backend before anything else happens, so a backend that's geographically distant from your CI runners, or a very large state object, adds fixed overhead on top of the refresh cost regardless of how you tune the rest.
Further reading: Refactor Terraform state
terraform import (or the declarative import block, since Terraform 1.5) binds exactly one real-world object to exactly one resource address — it assumes a clean 1:1 mapping. That assumption breaks down for anything the provider models as several linked resources under the hood, or for a resource type whose provider doesn't support import at all for certain sub-configuration.
A concrete example: an AWS security group with its rules. Depending on how your provider version and configuration model rules — inline ingress/egress blocks on the aws_security_group resource itself, versus separate aws_security_group_rule (or the newer aws_vpc_security_group_ingress_rule) resources per rule — importing just the aws_security_group doesn't necessarily pull in every rule the way your .tf file expects it to be structured. The practical approach: import the primary resource first, run plan, and read the diff carefully rather than assuming it's clean — where the diff shows missing or mismatched nested data, you write additional import blocks (or run additional terraform import commands) for each of the separately-modeled pieces, checking the provider's own documentation for how it actually splits that object across resource types. For resources the provider doesn't support importing at all, the fallback is writing the resource block to describe the desired end state and letting the next apply create a replacement — acceptable for something non-critical, riskier for anything stateful (data, DNS records with live traffic) where a forced replace has a real consequence, in which case reaching out to the provider maintainer or checking recent GitHub issues for that resource type's import support is worth doing before assuming there's no path forward.
The broader point worth naming out loud: import is a state-file operation with no undo. Running it against a stray or wrong resource ID doesn't touch real infrastructure, but it can leave state in a confusing half-imported condition that's worth cleaning up with terraform state rm rather than working around.
Further reading: import block reference
terraform import command reference