Real Terraform interview questions with detailed answers on state, modules, workspaces, drift and IaC best practices, grouped by difficulty.
100questions with answers
Terraform is an open-source Infrastructure as Code (IaC) tool from HashiCorp. You use it to define and provision infrastructure (servers, networks, databases, DNS records) in a declarative language called HCL. You describe the end state you want, and Terraform works out what needs to be created, changed or destroyed to get there.
The distinction that 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 is already running: install packages, write config files, restart services. Terraform is also declarative, meaning you state the result and it computes the difference. Ansible is closer to an ordered list of tasks run top to bottom.
In practice teams use both. Terraform provisions the VM, then hands off to Ansible, a cloud-init script or a Packer-built image to set up the software on it.
Infrastructure as Code (IaC) means defining your servers, networks and other infrastructure in version-controlled 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. Clicking through a console leaves no audit trail, can't 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 build a near-identical staging environment. It also removes a whole class of "it worked before and nobody knows what changed" incidents, because every infrastructure change goes through the same reviewed, versioned path as application code.
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 real 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) before anything can be planned or applied. Without the right provider installed, Terraform has no idea how to create an aws_instance. The core binary itself knows nothing about any specific cloud.
The everyday loop is four commands, usually 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 needed once per working directory, and again whenever you add a provider or module or change the backend. plan is a dry run. It compares your configuration with the current state and shows what would be added, changed or destroyed, without making any change. apply carries out that plan.
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.
The state file (terraform.tfstate) is a JSON file that maps each resource in your configuration to the real object it created. For example, it links aws_instance.web in your HCL to the actual EC2 instance ID i-0a1b2c3d. It also caches attributes that the provider generated and that aren't written anywhere in your config.
Terraform needs it because HCL only describes what you want, not what currently exists. Without state, Terraform couldn't know that aws_instance.web was already created on a previous apply, so it would try to create it again every run. It also wouldn't know what to delete when you remove a resource block. State turns "here's my desired configuration" into "here's the exact difference between what is real and what I want".
A resource block tells Terraform to create, manage and eventually destroy something, so Terraform owns its whole lifecycle. A data block does the opposite. It reads information about something that already exists, without managing it 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 your configuration, such as a shared VPC, an AMI ID that changes weekly, or an existing IAM policy. You can then use its attributes (like data.aws_vpc.default.id) in resources you do manage, without importing that thing into your state.
Variables parameterize a configuration so the same code can be reused with different inputs, such as a different instance size for staging versus prod. 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, a .tfvars file, -var on the command line, or an environment variable prefixed TF_VAR_. Declaring a type is optional but worth doing. Terraform then rejects a value of the wrong type at plan time instead of failing halfway through an apply.
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 the 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. You compute a value once, such as a naming convention or a merged tag map, and reuse it across many resources without exposing it as something a caller could override. If you repeat the same expression in three resource blocks, it probably belongs in locals.
terraform fmt rewrites .tf files into Terraform's canonical style, with consistent indentation, aligned = signs and spacing, without changing what the configuration does.
On a solo project it's a nice-to-have. On a team it removes a whole category of noisy pull request diffs and style arguments, the same way gofmt or prettier does for code. Many teams run terraform fmt -check in CI so the build fails if someone forgot to format. Some add terraform fmt -recursive in a pre-commit hook so it never reaches review.
terraform validate checks that your configuration is valid HCL and internally consistent: correct argument names, correct types, no broken references. It doesn't contact any provider or look at real infrastructure, so it runs fast and needs no credentials.
terraform plan goes much further. It calls 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 as the first CI step. It won't catch things like "this AMI ID doesn't exist" or "you lack permission to create this". Those only show up during plan or apply, once Terraform is talking to the provider.
A module is a directory of .tf files that Terraform treats as one reusable unit. Technically every configuration is a module, and the directory you run terraform apply in is the "root module". The useful pattern is to write a module once (for example "a standard VPC" or "an S3 bucket with our tags and encryption") and call it from many places with different inputs.
module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" environment = "staging"}Modules exist so you don't paste 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, the way a function hides its internals behind a signature.
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 picks the newest provider release. That can silently change resource schemas, default behavior or even argument names. Someone running init three months later can get a different provider than CI used, and see plan diffs that have nothing to do with any code change. Pinning to a range, together with a committed .terraform.lock.hcl file, makes provider upgrades a deliberate, reviewed decision instead of an accident.
.terraform.lock.hcl is generated by terraform init. It records the exact provider versions, and their checksums, that were chosen for your configuration. It is separate from, and much smaller than, terraform.tfstate. Your required_providers block sets a version constraint (like ~> 5.0), and the lock file records the specific version that was actually selected.
# .terraform.lock.hcl: generated, not hand-editedprovider "registry.terraform.io/hashicorp/aws" { version = "5.47.0" constraints = "~> 5.0" hashes = [ "h1:...", ]}HashiCorp recommends committing it for the same reason you commit package-lock.json. Without it, every fresh terraform init re-resolves the constraint to the newest matching release at that moment, so a teammate running init next month can get a different provider than CI tested against, with no diff or review anywhere. With it committed, provider upgrades happen only when someone deliberately runs terraform init -upgrade, and the bump shows up as an ordinary reviewable change in a pull request.
One operational gotcha: the lock file records hashes per platform. A team mixing macOS and Linux needs to run terraform providers lock -platform=<platform> for every platform in use, or CI can fail with a missing-hash error.
Declarative means you describe the end state you want, not the steps to reach it. You write "there should be one S3 bucket with versioning on". Terraform compares that with what exists and works out the steps itself: create it, change it, or leave it alone. An imperative script, by contrast, says "run this command, then that one", and running it twice can do the wrong thing twice.
HCL (HashiCorp Configuration Language) is the language you write this in. It is built from blocks (resource, variable, module), arguments (name = value) and expressions (references, functions, conditionals).
resource "aws_s3_bucket" "logs" { bucket = "my-company-logs"}A practical benefit of the declarative style is that the order of blocks in your files doesn't matter. Terraform builds a dependency graph from the references between them and decides the order itself.
An output block exposes a value from a configuration. After an apply, root-module outputs are printed in the terminal and can be read later with terraform output. Inside a module, outputs are how the module hands values back to whoever called it.
output "bucket_arn" { description = "ARN of the logs bucket" value = aws_s3_bucket.logs.arn}Outputs have three common uses: showing a useful value to the person running Terraform (a load balancer URL), passing values from a child module to its parent (module.network.vpc_id), and sharing values between separate configurations through terraform_remote_state. Mark an output sensitive = true if it holds a secret, so it isn't printed. Remember that this hides it only in the terminal, not in state.
A backend defines where state is stored and how Terraform operations run. The default is the local backend, which writes terraform.tfstate to your working directory. For a team you use a remote backend such as S3, Azure Blob Storage, Google Cloud Storage or HCP Terraform.
terraform { backend "s3" { bucket = "my-tf-state" key = "app/terraform.tfstate" region = "us-east-1" }}A remote backend gives you three things a local file can't: one shared copy of state for the whole team, locking so two applies can't collide, and storage that is encrypted, versioned and access-controlled. Don't confuse a backend with a provider. The backend says where Terraform keeps its own records, and a provider says which platform's infrastructure it manages.
Terraform has three groups of types.
string, number and bool.list(...) for an ordered sequence, set(...) for unique unordered values, and map(...) for key-value pairs.object({...}) for a fixed set of named attributes and tuple([...]) for a fixed sequence of mixed types.There is also any, which lets Terraform infer the type.
variable "servers" { type = map(object({ instance_type = string disk_gb = number }))}Being specific with types is worth the effort. Terraform then rejects a wrong value at plan time with a clear message, and the type itself documents what shape of data a module expects.
The Terraform Registry (registry.terraform.io) is the public catalog of providers and modules. When you write source = "hashicorp/aws", terraform init downloads that provider from the Registry. You can also pull ready-made modules from it, for example a community VPC module, by pinning a version.
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"}Organizations can also run a private registry (HCP Terraform includes one) to share internal modules with versioning. When using a public module, read its inputs, check how active it is, and pin a version. A module from the Registry is code that creates real infrastructure in your account, so treat it like any other dependency.
Commit: your .tf files, .tfvars files that contain no secrets, and .terraform.lock.hcl (it pins provider versions).
Ignore (put in .gitignore):
.terraform/*.tfstate*.tfstate.*crash.log*.tfvars # only if they can contain secrets.terraform/ is a local cache of downloaded providers and modules that terraform init recreates. State files must never be committed, because they can hold secrets in plaintext and committing them also invites conflicts. Saved plan files (tfplan) should stay out of git too, since a plan can contain sensitive values.
required_version is a constraint on the Terraform CLI itself, set inside the terraform block. If someone runs a Terraform binary outside the allowed range, it stops with a clear error.
terraform { required_version = ">= 1.9.0, < 2.0.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }}Provider constraints control which version of each plugin is used. required_version controls the core binary. You need both, because newer Terraform versions add language features (like moved and import blocks) and can also change state format, so a teammate on an old binary could fail confusingly, or an upgraded binary could write state that older ones can't read. The constraint makes the minimum version explicit.
All three provision infrastructure from code, but they differ in scope and language.
| Terraform | CloudFormation | Pulumi | |
|---|---|---|---|
| Language | HCL | JSON or YAML | General-purpose (Python, TypeScript, Go and others) |
| Clouds | Many, through providers | AWS only | Many |
| State | You manage it (backend) | Managed by AWS in stacks | Managed by Pulumi service or self-hosted |
| Strength | Huge provider ecosystem, a common standard | Deep, same-day AWS support, no state to manage | Real programming constructs (loops, classes, tests) |
Terraform's advantages are the multi-cloud reach and the size of its ecosystem. CloudFormation is attractive if you are all-in on AWS and want AWS to own the state and rollback behavior. Pulumi suits teams that want to write infrastructure in a language they already use. A good interview answer says "it depends on your cloud footprint and your team's skills", not that one tool is always right.
terraform console opens an interactive prompt where you can evaluate Terraform expressions against your current configuration and state. It is a quick way to test functions and look at values without running a plan.
terraform console> cidrsubnet("10.0.0.0/16", 8, 3)"10.0.3.0/24"> length(var.subnet_ids)3> aws_instance.web.public_ip"203.0.113.10"It's handy for testing a tricky for expression, working out the result of cidrsubnet or merge, and checking what a resource attribute really contains. Because it reads state, it can also show what Terraform currently believes about an existing resource. It doesn't change anything, so it's safe to explore.
terraform init prepares a working directory so other commands can run. It does four things:
.terraform directory and writes or checks .terraform.lock.hcl.source.It is safe to run repeatedly, and it doesn't change infrastructure. You need to run it again whenever you add or upgrade a provider, add a module, or change the backend configuration. Useful flags are -upgrade (allow newer provider versions within your constraints), -reconfigure (ignore existing backend setup) and -migrate-state (move state to a new backend). A common first debugging step for "provider not found" or "backend not initialized" errors is simply to re-run init.
A .tfvars file is a plain list of variable values, kept separate from the code that declares the variables. It lets you run the same configuration with different inputs.
# prod.tfvarsenvironment = "prod"instance_type = "m5.large"min_size = 3terraform apply -var-file=prod.tfvarsTerraform loads terraform.tfvars and any *.auto.tfvars files automatically, and you pass others with -var-file. A common layout is one file per environment (dev.tfvars, staging.tfvars, prod.tfvars). Keep secrets out of committed .tfvars files. Pass them through environment variables (TF_VAR_...), your CI system's secret store, or a secrets manager, because a .tfvars file in git is a leak waiting to happen.
terraform show prints a human-readable view of either the current state or a saved plan file.
terraform plan -out=tfplanterraform show tfplan # readable view of the saved planterraform show -json tfplan # machine-readable JSONterraform show # the current stateThe JSON output of a plan is what makes automation possible. Policy tools like OPA and conftest read it to check rules, and scripts can read it to detect changes to a sensitive resource and require extra approval. Saving the plan also guarantees that the plan you review is the plan you apply. Treat plan files as sensitive, because they can contain secret values, and don't commit them.
Normally terraform apply shows the plan and waits for you to type yes. -auto-approve skips that prompt and applies immediately.
terraform apply -auto-approveterraform apply tfplan # applying a saved plan never promptsIt's appropriate in automation, but only when a human review has already happened earlier in the pipeline, for example a pull request that showed the plan and was approved. It's dangerous when run by hand, because nothing stops you from applying to the wrong directory, workspace or account.
A safer pattern is to apply a saved plan file (terraform apply tfplan). A saved plan can't prompt, and it can only do what was planned, so there is no surprise change between review and apply.
Nothing changes, and Terraform says No changes. Your infrastructure matches the configuration. This property is called idempotency: running the same operation repeatedly gives the same end result. It follows from the declarative model. Terraform compares the desired configuration with real state each time, finds no difference, and does nothing.
It's an important reason to trust Terraform in automation, because re-running a failed pipeline is safe. If the first apply failed halfway, a second one continues from where it stopped and creates only what is still missing.
The thing to watch is a plan that is not empty on the second run. That signals a problem, such as a provider quirk that reports a spurious diff, an attribute changed outside Terraform, or a value that changes on every run (like a timestamp). Investigate it instead of ignoring it.
There are two separate problems, and both are serious. First, state is not just bookkeeping. It routinely contains sensitive data in plaintext, such as database passwords or private keys that ended up as resource attributes, even when the variable was marked sensitive. Committing it to a git repo puts that data in history forever, even if you delete the file in a later commit.
Second, a local-only state file means only one person's laptop knows what infrastructure exists. If a teammate applies from their own copy, or the laptop holding the "real" state dies, you get a stale or missing source of truth, and Terraform tries to recreate resources that already exist.
The standard fix is a remote backend (S3, Azure Blob Storage, GCS or HCP Terraform). State then lives in one shared, access-controlled, versioned place that every teammate and CI job reads and writes consistently.
State locking stops two apply (or plan) operations from writing to the same state at the same time. If the backend supports it, Terraform automatically takes a lock before any operation that could write state and releases it afterwards.
Without locking, two people or two CI jobs can race. Both read the same "before" state, both compute a diff, both write their result, and whichever 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 state and reality is the kind of corruption that becomes a multi-hour incident. Locking simply serializes writes, so only one operation holds the lock and everyone else waits or is told the state is locked.
Both create multiple instances from one resource block, but they address the instances differently. count uses a numeric index (aws_instance.web[0], [1], [2]). for_each uses a stable key from a map or a set of strings (aws_instance.web["dev"], ["prod"]).
That difference matters when the list changes. With count, removing the first item of a three-item list shifts every later index down by one. Terraform sees [1] and [2] as new addresses and destroys and recreates resources that never needed to change. With for_each, removing one key affects only that key, because the addressing isn't positional.
# count: indexes shift 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]}Rule of thumb: use for_each by default for anything that might grow, shrink or reorder. count is fine for a genuinely fixed quantity, or for the on/off pattern count = var.enabled ? 1 : 0 that conditionally creates one resource.
plan compares three things, not two. First it refreshes: for every resource in state it calls the provider's API to read the object's current real attributes. Then it compares the state file (what Terraform last recorded), the real infrastructure (what the refresh just found) and your configuration (what you say you want), and produces a plan that reconciles all three.
This is why a 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 picks that up as drift, and the plan will show Terraform changing it back to match your configuration, because your configuration is the declared source of truth. You can also run terraform plan -refresh-only to see just the drift, without any config-driven change alongside it.
terraform_remote_state reads another configuration's state file (not its code) and exposes that configuration's outputs as data. It's used when two configurations are deliberately separate, with different repos, teams or apply schedules, but one needs a value the other created, like a VPC ID from a networking stack feeding 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 a value 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 layers (see the multi-account state layout question), terraform_remote_state or an equivalent lookup, such as an SSM Parameter Store value the other stack wrote, bridges them without merging state or coupling apply schedules.
A workspace is a named, isolated state 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 can use terraform.workspace in your config to vary behavior.
resource "aws_instance" "web" { instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.micro"}The alternative is a directory per environment (environments/dev, environments/staging, environments/prod), each with its own backend config and .tfvars. It duplicates some boilerplate but keeps environments fully independent: different provider credentials, different variable files, and no risk of applying to prod because you forgot which workspace was selected.
Workspaces are lighter and fine for genuinely similar environments. Most teams running prod next to lower environments prefer separate directories, because the selected workspace is easy to get wrong and the blast radius of applying to the wrong one is high.
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 option, not as a default tool.
resource "aws_instance" "web" { # ... provisioner "remote-exec" { inline = ["sudo apt-get update", "sudo apt-get install -y nginx"] }}HashiCorp describes provisioners as a last resort because they break Terraform's core model. The result of a provisioner isn't tracked in state like a resource attribute, so Terraform can't tell on a later run whether it succeeded, can't detect drift in what the script did, and can't cleanly roll it back.
The preferred approach is to push that work upstream. Bake configuration into the machine image with Packer, use cloud-init or user-data, or hand off to a configuration-management tool like Ansible after Terraform provisions the resource. Provisioners are more defensible for a genuinely one-off bootstrap action than for ongoing configuration.
local-exec runs a command on the machine running Terraform itself (your laptop or the CI runner). It's useful for things like calling 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, so it 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 a target that is up and accepting connections at the exact moment Terraform tries to connect. That is a common cause of flaky applies in CI.
lifecycle is a meta-argument available on every resource. It changes how Terraform handles create, update and destroy for that resource.
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 creates the replacement first and destroys the old one only once the new one exists. This matters whenever something else depends on the resource staying available, such as a launch template used by an autoscaling group.prevent_destroy makes any plan that would destroy or replace the resource fail, as a guardrail against deleting something like a production database.ignore_changes tells Terraform to stop flagging drift on specific attributes. It's useful when something outside Terraform (an autoscaler, another team's automation, a compliance scanner adding a tag) legitimately changes that attribute and you don't want every plan to revert it.Most of the time you don't need depends_on. If one resource's argument references another resource's attribute (subnet_id = aws_subnet.main.id), Terraform knows the subnet must exist first. That's an implicit dependency, inferred from the reference graph.
depends_on is for cases where a real dependency exists but nothing in the configuration references it directly. The classic case is IAM. An EC2 instance needs a role's policy to be fully attached before it starts, but the instance never mentions the policy attachment by name.
resource "aws_instance" "web" { # no direct reference to the policy attachment... depends_on = [aws_iam_role_policy_attachment.web]}Use it only when you can't express the dependency through a real attribute reference. It's a blunt tool: it forces ordering instead of letting Terraform parallelize independent resources, and it's easy to misuse as a band-aid for a plan failing for an unrelated reason.
Terraform builds a dependency graph from every reference between resources, plus any explicit depends_on. It then walks that graph. Resources with no dependency on each other run in parallel, and anything that depends on another resource waits until that one finishes.
You can inspect the graph with terraform graph, which outputs it in DOT format. This is one reason Terraform is fast on large configurations. It doesn't run top to bottom through your files. It runs through a graph, so the physical order of blocks in your files has no effect on execution order.
sensitive = true on a variable or output tells Terraform to hide that value in CLI output. plan and apply print (sensitive value) instead of the real string, and the value is hidden in the HCP Terraform UI too.
What it does not do is remove the value from the state file. State stores real, unredacted attributes in plaintext JSON, whatever the sensitive flag says. The flag only controls what is printed to a terminal or shown in a UI. This is a common interview trap because it sounds like encryption, but it isn't.
Actually protecting a secret means encrypting state at rest, tightly restricting who can read the state file, and ideally keeping long-lived secrets out of Terraform-managed attributes. A common pattern is to generate the secret at apply time and push it straight into a secrets manager (Vault, AWS Secrets Manager) that the application reads directly.
Both mark a resource to be destroyed and recreated on the next apply, with no change to its configuration. That's useful when a resource is degraded or corrupted and you want a clean rebuild. taint does this as a separate step. It writes a "tainted" marker into state immediately, and the replacement happens on whatever apply runs next, possibly run by someone else who didn't ask for it.
-replace folds it into a single plan and apply:
terraform apply -replace="aws_instance.web"HashiCorp deprecated taint (from Terraform 0.15.2) in favor of -replace because with -replace the intended replacement appears in the plan for review before anything happens. There's also no window where state is silently marked tainted and someone else's unrelated apply picks it up. taint still exists but is deprecated, and the guidance is to use -replace.
All three work on the state file directly and don't touch real infrastructure. They fix mismatches between what state says and what you want it to say.
terraform state list prints every resource address tracked in state. It's usually the first command you run to find the exact address before using mv or rm.terraform state mv renames a resource's address in state, for example after renaming a block or moving it into a module, without destroying and recreating the real object. It tells Terraform "this is the same instance, just addressed differently".terraform state rm removes a resource from state without destroying it in the real world. A common use is splitting one configuration into two, where a resource must stop being managed here so it can be imported into the other.terraform state listterraform state mv aws_s3_bucket.old aws_s3_bucket.newterraform state rm aws_s3_bucket.legacySince Terraform 1.1, the moved block handles the rename case declaratively and shows up in a normal plan, so many teams now prefer it over running state mv by hand (see the rename question).
A dynamic block generates nested blocks from a list or map. You need it when the number of nested blocks, not just a value inside one, has to vary. The most common case is repeated ingress or 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: dynamic blocks are more powerful than most cases need and can make configuration harder to read. If the number of nested blocks is fixed and known, write them out plainly. Use dynamic only when the set of blocks is a real input that varies by caller.
A splat expression (aws_instance.web[*].id) is shorthand for pulling one attribute from every instance of a resource created with count or for_each, giving a simple list. A for expression is the general 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 "this one attribute from all of them" case. Reach for for as soon as you need filtering, a computed key, or a map instead of a list. Forcing that through a splat usually ends up less readable than a plain for expression.
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 *.auto.tfvars files in alphabetical order, and finally -var and -var-file options on the command line, applied in the order given. Command-line options win over everything else.
export TF_VAR_instance_type=t3.smallterraform apply -var="instance_type=m5.large"# m5.large wins: the CLI -var beats the environment variableThe practical reason to know this: CI pipelines often set environment variables as defaults but pass an explicit -var-file per environment to override them. Anyone debugging "why did it apply with the wrong instance type" has to check the whole chain, not just the .tfvars file that looks most obvious.
A validation block inside a variable 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 is where and how it fails. Without validation, a typo like "produciton" might slide into some conditional logic and produce a confusing plan diff three resources later, or even apply cleanly with the wrong behavior. With validation it fails at plan with a message naming the exact input and why it's wrong. That matters most for a module other teams consume, because they get a useful error instead of having to read your module's internals.
null_resource does nothing on its own. It exists as a place to attach provisioners or to take part in the dependency graph, when you need Terraform-managed behavior that isn't tied to a 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 re-runs the provisioners only when a value in triggers changes between applies. That gives you explicit control over when a side effect (a notification, a cache invalidation, a webhook) fires, instead of it running on every apply. In modern Terraform, the built-in terraform_data resource does the same job without needing the separate null provider.
The standard pattern: terraform fmt -check and terraform validate run on every push as a fast first gate. Then terraform plan runs automatically on every pull request and posts its output as a PR comment, so reviewers see exactly what would change before approving. terraform apply runs only after merge to the main branch, never from a laptop against shared state and never from an unreviewed branch.
PR opened -> fmt and validate -> plan -> plan output posted to the PRPR approved/merged -> apply (using the saved 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. Re-running plan right before apply leaves a gap where something else could change, so what gets applied isn't what was reviewed. Second, restrict who or what can apply to production. Typically only the CI service account can, never a person's own credentials, with state locking so two runs can't race.
Tools like Atlantis or HCP Terraform's VCS integration implement this flow out of the box, so you don't have to hand-build it in a generic CI runner.
Policy as code lets you write rules that a plan must pass before apply is allowed to run. That is different from validate, which only checks that the HCL is valid. Policy checks whether the change is actually allowed. Sentinel is HashiCorp's policy engine, built into HCP Terraform. Open Policy Agent (OPA) with the Rego language is the open-source equivalent. It works with the open-source CLI through tools such as conftest, run against the plan's JSON output.
A typical policy blocks any plan that would create an S3 bucket without encryption, launch an instance type outside an approved list, or touch production without a change-ticket tag. Without policy as code, that rule lives in a reviewer's head and gets missed under time pressure. With it, the check runs on every plan and fails the pipeline with a specific, named violation before anything is applied. It's the same shift that moved style nitpicks out of code review and into an automated linter.
With a self-managed S3 backend, Terraform still runs wherever you invoke it, on your laptop or a CI runner. S3 only stores and locks the state. You are on your own for who can apply, secret injection, policy checks and run history.
HCP Terraform (formerly Terraform Cloud) is a managed service that runs plan and apply remotely. It adds a UI with run history, a built-in state backend with locking, team access controls, shared variable sets and native Sentinel policy checks. These are the pieces a team would otherwise assemble from CI scripts, a state bucket and a secrets manager. The self-hosted version is Terraform Enterprise.
The trade-off is control versus convenience. HCP centralizes many 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.
OpenTofu is an open-source fork of Terraform. It was created after HashiCorp changed Terraform's license from MPL 2.0 to the Business Source License (BSL) in 2023. It is maintained under the Linux Foundation, and the CLI, providers and module ecosystem stay largely compatible with Terraform. Most existing .tf configurations work with little or no change, and tofu mirrors Terraform's commands (tofu init, tofu plan, tofu apply).
For an interview, the useful point is less "which is better" and more understanding why the fork happened and what stayed compatible versus what diverged. OpenTofu has shipped some features of its own, such as built-in state encryption, while Terraform continues separate development under the BSL. This space moves fast, so check current docs for each project's feature set instead of assuming anything is permanent.
Both end up destroying infrastructure through the same mechanism, a plan that proposes destroying resources, but they differ in scope. terraform destroy tears down everything tracked in that configuration's state, whatever your .tf files currently say. It's meant for decommissioning a whole environment or stack.
terraform destroy # tears down every resource in this stateterraform destroy -target=aws_instance.web # just one (use sparingly)Deleting a resource block and running terraform apply is narrower and more common. Terraform sees the resource is gone from configuration but still in state, and proposes destroying just that one resource, leaving the rest untouched. That's the normal way to decommission one piece of a larger stack, such as an old S3 bucket, while everything else keeps running.
The trap: terraform destroy run in the wrong directory or workspace doesn't ask "only these three resources?". It proposes destroying the entire state. That is why it's rarely wired into CI/CD the way apply is, and why some teams use a separate, more locked-down pipeline for destroy operations.
The Terraform Registry labels each provider with a tier, so you know who is responsible for it. Official providers are written and maintained by HashiCorp (aws, azurerm, google, kubernetes and others). Partner providers are built and maintained by a technology company that has joined HashiCorp's partner program, such as a vendor's own provider for its product. Community providers are published by individual contributors, with no HashiCorp relationship or guaranteed support.
terraform { required_providers { aws = { source = "hashicorp/aws" # Official version = "~> 5.0" } }}The tier is a real signal about support and longevity. A Community provider may be the only option for a niche product, and plenty are well maintained, but nobody is obliged to keep it working, patch security issues quickly or track breaking API changes. For anything load-bearing in production, check a Community provider's recent commits, open issues and release cadence first. An Official provider rarely needs that step.
The source argument supports several locations: a local path (./modules/vpc), the public or a private Terraform Registry, a Git repository, an S3 or GCS bucket, and others.
# Local path: no versioning, always the code in your repomodule "a" { source = "./modules/vpc" } # Registry: use the version argumentmodule "b" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"} # Git: pin a tag or commit with refmodule "c" { source = "git::https://github.com/acme/tf-modules.git//vpc?ref=v2.3.1"}Versioning matters because an unpinned module can change under you. For Registry modules use the version argument. For Git sources pin a tag or commit hash, never a moving branch like main. Publish your own modules with semantic versioning (major for breaking changes), so consumers can upgrade deliberately and read what changed first.
A good module has a small, clear purpose, a minimal and well-documented interface, and sensible defaults. Its variables are the inputs, its outputs are the return values, and callers shouldn't need to read the internals.
Good habits:
terraform-docs tool can generate the docs).Common mistakes:
By default each provider has one configuration. A provider alias lets you configure the same provider more than once, for example to work in two AWS regions or two accounts from a single configuration.
provider "aws" { region = "us-east-1"} provider "aws" { alias = "dr" region = "eu-west-1"} resource "aws_s3_bucket" "replica" { provider = aws.dr bucket = "my-company-replica"}Typical uses are multi-region setups (a primary and a disaster-recovery region), cross-account setups (a resource in a shared account), and AWS cases that force a particular region, such as a CloudFront certificate that must be in us-east-1.
To use an aliased provider inside a module, pass it explicitly with the providers argument in the module block. Don't define provider blocks inside the reusable module itself.
The import block (Terraform 1.5 and later) lets you declare an import in code instead of running a one-off CLI command.
import { to = aws_s3_bucket.logs id = "my-existing-bucket"}Run terraform plan and Terraform shows the import as part of the plan. It happens during apply. The advantages over the terraform import command are real. The import is reviewable in a pull request, it goes through the normal plan and apply pipeline, it can run in CI, and it is easy to repeat across environments.
Terraform can even draft the configuration for you:
terraform plan -generate-config-out=generated.tfThe generated HCL is a starting point. Read it, tidy it (remove computed or default attributes) and move it into your real files. Afterwards, run a plan and confirm it shows no unintended changes before you apply.
Use a removed block (Terraform 1.7 and later) with destroy = false. It tells Terraform to forget the resource in state while leaving the real object running.
removed { from = aws_s3_bucket.legacy lifecycle { destroy = false }}Before this feature, the usual way was terraform state rm aws_s3_bucket.legacy, a manual command that isn't visible in code review. The removed block is declarative, appears in the plan, and works in CI.
The usual reasons are handing a resource to another Terraform configuration (which then imports it), or deliberately leaving something behind while cleaning up. Be careful with the order. If you simply delete the resource block without a removed block, Terraform plans to destroy it.
They let you write assertions about your infrastructure so mistakes are caught with a clear message instead of a vague failure later.
precondition (in a resource's lifecycle) is checked before Terraform creates or updates the resource. It validates an assumption about the inputs.postcondition is checked after, and validates the result.check blocks (Terraform 1.5 and later) run assertions at the end of a plan or apply, but they only produce warnings and never block the run. They suit ongoing health checks, such as "this endpoint responds".data "aws_ami" "app" { # ... lifecycle { postcondition { condition = self.architecture == "x86_64" error_message = "The AMI must be x86_64." } }} check "site_is_up" { data "http" "site" { url = "https://example.com/health" } assert { condition = data.http.site.status_code == 200 error_message = "Health endpoint did not return 200." }}Use preconditions and postconditions for rules that must stop a bad change, and check for monitoring-style warnings.
There are several layers, from cheap and fast to slow and realistic.
terraform fmt -check and terraform validate, plus linters such as TFLint. Fast, no credentials needed.terraform test runs .tftest.hcl files. A test can run plan (cheap) or apply (real resources, which are destroyed afterwards), and make assertions.# tests/bucket.tftest.hclrun "creates_encrypted_bucket" { command = plan assert { condition = aws_s3_bucket.logs.bucket == "my-company-logs" error_message = "Unexpected bucket name." }}A sensible approach is to run static checks and plan-based tests on every pull request, and run the slower real-infrastructure tests on a schedule or before releasing a shared module.
Static tools read your code (or the plan) and flag problems before anything is deployed.
tflinttrivy config .checkov -d .They belong early in the pipeline, on every pull request, right after fmt and validate, and before plan is approved. Failing the build on high-severity findings turns security review from "someone remembered to look" into an automatic gate.
Expect some false positives. Teams usually keep a documented list of accepted exceptions instead of disabling a whole rule.
Terraform has many functions. These show up constantly:
| Function | What it does |
|---|---|
merge(a, b) |
Combines maps; later values win. Common for tags. |
lookup(map, key, default) |
Reads a map key with a fallback. |
try(expr, fallback) |
Returns the fallback if the expression errors. |
coalesce(a, b) |
First non-null, non-empty value. |
concat, flatten |
Join lists, flatten nested lists. |
toset, tolist |
Convert between collection types (needed for for_each). |
cidrsubnet |
Carves subnets out of a network range. |
templatefile |
Renders a file with variables. |
jsonencode |
Turns HCL data into a JSON string (IAM policies). |
format, join, split |
String building. |
tags = merge(var.common_tags, { Name = "${var.env}-web" })You can try any function safely in terraform console. One piece of advice: when a nested expression is getting hard to read, move part of it into a local with a clear name.
The standard pattern uses count with a conditional expression (a ternary: condition ? a : b) that gives 1 or 0.
variable "enable_monitoring" { type = bool default = false} resource "aws_cloudwatch_dashboard" "main" { count = var.enable_monitoring ? 1 : 0 dashboard_name = "main" dashboard_body = jsonencode({})}Because count makes the resource a list, referencing it needs care. Use one(aws_cloudwatch_dashboard.main[*].id) or aws_cloudwatch_dashboard.main[0].id only when you know it exists. Alternatively use for_each = var.enable_monitoring ? { main = true } : {}, which gives a stable key.
For optional arguments (not whole resources), set a value to null. A null argument is treated as if it weren't written. Conditionals are handy, but too many of them make a module hard to follow, so keep each toggle simple and documented.
templatefile(path, vars) reads a file and replaces ${...} placeholders with the values you pass in. It is the standard way to build user-data scripts, config files or policies from a template.
# user_data.sh.tpl#!/bin/bashecho "Environment: ${environment}" > /etc/app.env%{ for host in hosts ~}echo "${host}" >> /etc/hosts.allow%{ endfor ~}resource "aws_instance" "web" { ami = var.ami_id instance_type = "t3.micro" user_data = templatefile("${path.module}/user_data.sh.tpl", { environment = var.environment hosts = var.allowed_hosts })}Use ${path.module} so the path works wherever the module is called from. Templates can include %{ for } and %{ if } directives. For anything beyond a short bootstrap script, prefer baking configuration into an image (for example with Packer), since large user-data scripts are slow and hard to debug.
Terraform needs to read the backend configuration before it can load anything else, including variables and state, so a backend block can't use var. references or other expressions. Everything in it must be a literal.
The solution is partial configuration. Leave out the values that differ and supply them at init time:
terraform { backend "s3" { # bucket, key and region are provided at init time }}terraform init -backend-config=backends/prod.hclterraform init -backend-config="key=app/prod.tfstate"A prod.hcl file would hold plain bucket = "..." and key = "..." lines. This keeps one set of code usable for many environments, with each environment pointing at its own state. It also keeps account-specific details out of the shared code. Wrapper tools like Terragrunt automate this pattern.
Change the backend block to the new location and run terraform init -migrate-state. Terraform detects the change and offers to copy the existing state to the new backend.
# 1. back up the current state firstterraform state pull > backup.tfstate # 2. edit the backend block (e.g. local -> s3), then:terraform init -migrate-state # 3. confirm nothing changedterraform planA careful migration looks like this:
terraform state pull.init -migrate-state and answer the prompt.terraform plan and expect no changes. Anything else means something went wrong.Don't use -reconfigure for this. It switches backends without copying state, so you would appear to have empty state and Terraform would plan to create everything again.
When a run is interrupted (a CI job is cancelled, a laptop loses power), the lock may not be released. The next run fails with a message showing the lock ID, who holds it and when it started.
First, make sure nobody is really still running Terraform. Check the CI system and ask the person named in the lock. Releasing a lock that is actually in use can let two applies write at once, which is exactly the corruption locking exists to prevent.
If you're sure it's stale, release it with the ID from the error:
terraform force-unlock <LOCK_ID>Then run terraform plan to check state is sane. If the interrupted apply was halfway through, the plan will show what's left to do, and a normal apply continues from there.
With a DynamoDB lock table you can inspect the lock item directly, and with S3-native locking the lock is a small .tflock object next to the state. Use force-unlock as the normal tool, not hand-editing those. And if this happens often, fix the cause, for example CI jobs getting cancelled mid-apply.
default_tags is a setting on the AWS provider that automatically adds a set of tags to every taggable resource it creates, so you don't repeat tags in each resource block.
provider "aws" { region = "us-east-1" default_tags { tags = { Environment = var.environment Team = "platform" ManagedBy = "terraform" } }}Tags matter for cost allocation, ownership, security rules and finding things. Putting them on the provider enforces a baseline without relying on every author to remember. A resource can still set its own tags, and its values are merged with the defaults. If both set the same key, the resource's own tag wins.
Know the one quirk: the merged result is exposed in the tags_all attribute. Some resources or tags (for example those in autoscaling groups) don't inherit default tags, so check the provider documentation for those cases.
Use short-lived credentials from workload identity federation (OIDC) instead of storing static access keys as CI secrets. Static keys can leak, are rarely rotated, and give an attacker long-term access.
With OIDC, the CI platform (GitHub Actions, GitLab CI) proves its identity with a signed token, and the cloud exchanges it for temporary credentials by letting the job assume a role. Nothing long-lived is stored.
# AWS trust policy condition (conceptually): only this repo and branch may assume the role"token.actions.githubusercontent.com:sub" = "repo:acme/infra:ref:refs/heads/main"Best practices that go with it:
This pattern also gives you an audit trail, since every assumed session is logged against a specific pipeline run.
Immutable infrastructure means you never modify a running server in place. To change something, you build a new image or resource with the change, deploy it, and throw away the old one. The opposite, mutable infrastructure, patches and edits servers over time, which leads to configuration drift: servers that were supposed to be identical slowly become different.
Terraform leans toward the immutable model. Many resource changes can't be done in place, so Terraform plans a replacement (-/+). Combined with a pre-built image (for example from Packer) and an autoscaling group that rolls instances, you get a workflow like:
ami_id in Terraform.The benefits are consistency, easy rollback (point back at the old image) and fewer "snowflake" servers. The cost is slower change for small tweaks and a need to separate state (data) from compute. Not everything can be immutable, such as databases, so the data layer needs careful handling.
Terragrunt is a thin wrapper around Terraform (and OpenTofu), from Gruntwork, that reduces repetition when you manage many environments and many state files.
It solves practical annoyances of large Terraform setups:
# terragrunt.hcl in a stack directoryinclude "root" { path = find_in_parent_folders()} terraform { source = "../../../modules//vpc"} inputs = { cidr_block = "10.1.0.0/16"}It's worth it when you have dozens of stacks and copy-pasted backend files. It's overkill for a small setup, and it adds a tool and a concept the team must learn. Plain Terraform with a clean module structure covers many teams well, so adopt Terragrunt for a concrete pain, not by default.
Drift is a difference between real infrastructure and what Terraform expects, often caused by someone changing something in the console. To catch it early, run a scheduled, read-only plan and alert when it isn't empty.
terraform plan -detailed-exitcode -refresh-only# exit code 0 = no changes, 2 = changes found, 1 = error-detailed-exitcode makes the exit code meaningful for scripts. A scheduled CI job (nightly, say) can run this per stack and notify a channel when it returns 2. HCP Terraform also offers built-in health assessments that check for drift automatically.
Detecting drift is half the job. Decide in advance how to resolve it: either revert the change by applying, or update the code to match if the manual change was valid. The deeper fix is prevention. Restrict console write access with IAM, so most changes can only happen through the pipeline.
Turn on detailed logging with the TF_LOG environment variable, and send it to a file if it is long.
export TF_LOG=DEBUG # TRACE, DEBUG, INFO, WARN or ERRORexport TF_LOG_PATH=./tf.logterraform planTRACE is the most verbose and shows every API call between Terraform and the provider, including the cloud's raw error response. That response is often the real answer, for example an AccessDenied that names the exact missing permission.
A useful order of attack:
terraform validate to rule out syntax problems.TF_LOG=DEBUG and look at the API error.-target on that one resource (for debugging only).Be careful sharing logs. At DEBUG and TRACE they can contain tokens and sensitive values, so clean them before posting anywhere.
Terraform must know how many instances a resource has, and their keys, at plan time, so it can build the graph. If your for_each or count depends on something only known after another resource is created (like a generated ID), Terraform can't plan.
# Problem: keys come from IDs that don't exist yetresource "aws_route53_record" "r" { for_each = toset(aws_instance.web[*].id) # IDs unknown until apply # ...}Fixes, best first:
resource "aws_route53_record" "r" { for_each = var.servers # keys are static names records = [aws_instance.web[each.key].private_ip] # ...}-target to create the dependency first. It works, but it's a workaround, and a signal that the design should change.The rule to remember: the keys must be known at plan time, while the values can be unknown.
replace_triggered_by (Terraform 1.2 and later) is a lifecycle setting that forces a resource to be replaced whenever another resource or attribute changes.
resource "aws_instance" "app" { # ... lifecycle { replace_triggered_by = [ aws_launch_template.app.latest_version ] }}Use it when there is a real dependency that Terraform can't see on its own. A common case is a resource that reads a value only at creation time. If the source of that value changes, the dependent resource must be rebuilt to pick it up, but nothing in its own configuration changed, so a normal plan would show no diff.
It replaces the old trick of using null_resource triggers for this. The plan shows exactly why each replacement is happening, which makes it reviewable. Use it sparingly, since automatic replacements of important resources can be costly, and always read the plan.
When a variable is an object type, you can mark attributes as optional(...), and optionally give a default. Callers can then leave them out.
variable "buckets" { type = map(object({ versioning = optional(bool, true) region = optional(string) tags = optional(map(string), {}) }))}buckets = { logs = {} # all defaults data = { versioning = false }}Without optional, every attribute of an object must be provided, so callers would repeat boilerplate or the module would need many separate variables. With it, you get one tidy structured input with sensible defaults. Optional attributes became stable in Terraform 1.3.
Combine this with for_each to create many similar resources from a single map, and add validation blocks to protect the values. Note that an optional attribute without a default is null when omitted, so handle that in your logic.
The long-standing weakness of Terraform is that every value a resource manages ends up in state, including secrets. Newer versions add features that keep a value out of state.
ephemeral "random_password" "db" { length = 24} resource "aws_db_instance" "main" { # ... password_wo = ephemeral.random_password.db.result password_wo_version = 1}Support depends on the provider and the specific resource, so check the documentation for what's available. These features are the real answer to "how do I keep a secret out of state?", where older answers could only say "encrypt the state and restrict access". Both protections are still worth having.
Both update state to match real infrastructure, but the standalone terraform refresh command is deprecated, and -refresh-only is its safer replacement.
The old terraform refresh rewrote state immediately, with no preview. If the refresh pulled in something unexpected, such as a wrong credential pointing at an empty account, it could record that damage in state with no chance to review.
terraform plan -refresh-only # show what drift would be recorded, change nothingterraform apply -refresh-only # review, confirm, then update stateWith -refresh-only you see a plan of the state changes first, and you approve them. Nothing in real infrastructure changes in either mode. Only the state file's record of reality is updated.
Use it when you've confirmed that a manual change should become the new truth, and you want state to reflect it without applying anything, or to inspect drift without proposing config-driven changes. Note that a normal plan and apply already refresh by default.
The wrong answer is "Terraform updates it". Terraform has no idea it exists. State is Terraform's only memory of what is real, and 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 with a naming or uniqueness conflict from the provider (a bucket name already taken, for instance) or succeeds and gives you a second, duplicate object to find and clean up.
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. It does not write any HCL for you. If the aws_s3_bucket.my_bucket block in your .tf file doesn't describe the bucket's real configuration (versioning, encryption, tags, whatever is set), the next plan will show Terraform "fixing" all of those mismatches and quietly reverting real settings to your incomplete config.
So the required next step is to write HCL that accurately matches the real resource, run plan, and confirm the diff is empty before you ever run apply. This situation is also the classic result of someone creating something in the console instead of Terraform. Recovering from it cleanly, instead of only knowing that import exists, is what separates a strong answer.
The S3 backend can now lock state using S3's own conditional writes, through the use_lockfile argument, with no DynamoDB table. When enabled, Terraform writes a small .tflock object next to the state file during any operation that could write state. A concurrent operation's write is rejected by S3 with a precondition failure 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 simplifies the standard AWS setup. It's one less resource to provision, secure with IAM and pay for. The feature arrived as experimental in Terraform 1.10 and was declared stable in 1.11, and the dynamodb_table argument is now deprecated.
The trade-off is migration care. For an existing setup, run with both dynamodb_table and use_lockfile set for a transition period, confirm a full plan and apply cycle works, and only then remove the DynamoDB table. Also check the current docs for the exact deprecation timeline, since this area is still evolving.
A resource's identity in state is its address (aws_instance.web), not its real-world ID. If you simply rename the block 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 instance needs to change.
The fix is to tell Terraform that this is the same object under a new address. You can do it directly against state:
terraform state mv aws_instance.web aws_instance.app_serveror, since Terraform 1.1, declaratively with a moved block committed alongside the rename. That way it goes through normal code review and appears correctly in plan, instead of relying on someone remembering to run a manual command:
moved { from = aws_instance.web to = aws_instance.app_server}The same mechanism handles moving a resource into a module (to = module.compute.aws_instance.web). Whichever you use, run terraform plan straight afterwards and confirm it shows zero changes to the real resource. If it shows a replace, one side of the address is wrong.
Avoid one giant state file covering every account, region and layer. It breaks down in several ways. plan gets slower as state grows, because every resource is refreshed on every run. The blast radius of any mistake (a bad state rm, an accidental -target, a corrupted state) covers everything instead of one slice. And a single state lock means only one person on the whole team can apply anything at a time.
A layout that scales splits state along two axes. By account and region: separate state per AWS account (or per region), each with its own backend key and provider credentials, so a mistake in staging can't touch prod's state. And by layer: networking (VPCs, subnets, transit gateways) as one state, shared platform services (IAM, DNS, shared clusters) as another, and application infrastructure as a third. The application layer reads the networking layer's outputs through terraform_remote_state.
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 remote-state wiring to track. The payoff is that networking, which changes rarely, can have a slow, careful review process, while application infrastructure that changes daily gets a fast, independent apply cycle. Nobody's routine app deploy can accidentally touch the VPC.
Replacing a resource gives it a new real-world ID, and Terraform plans around the whole dependency graph, not the one resource in isolation. Everything that references the security group's ID has to be updated, and depending on the attribute, that can mean an in-place update or another replacement.
The classic failure is ordering. With the default destroy-then-create order, Terraform tries to delete the old security group while the load balancer or instances still use it, and AWS refuses with a DependencyViolation error. The apply fails halfway, leaving you with a partly changed environment. A second variant is a fixed name on the group: with create_before_destroy set, AWS still refuses to create a second group with the same name, so the plan fails.
The fix is create_before_destroy together with name_prefix, so the new group exists and the dependents move over before the old one is deleted:
resource "aws_security_group" "web" { name_prefix = "web-" vpc_id = var.vpc_id lifecycle { create_before_destroy = true }}To avoid a surprise, always run terraform plan -replace=<address> first and read the full resource list, not just the summary count. If the plan touches something you didn't expect, trace the reference chain (terraform graph or the HCL) before you apply, not after.
This is the direct consequence of what sensitive = true does and doesn't do (see the sensitive variable question). It only hides values in CLI and UI output. State is a JSON file, and every attribute of every resource Terraform manages, including a random_password result or a database's master password, is written into it in plaintext, whatever sensitive flags exist in the configuration. Anyone who can read the raw state can read the secret.
Real protection happens at several layers, because no single flag fixes this:
The point to make in an interview is that sensitive = true is a display control, not an access control. The real fix sits in state storage and secrets architecture.
-target limits an apply to a specific resource and its dependencies, skipping the rest of the configuration:
terraform apply -target=aws_instance.webIt's genuinely useful in a narrow emergency. Production is down, you need one specific fix applied now, and you don't have time to review a full plan touching unrelated resources. It's also sometimes used to break a circular-dependency situation by applying pieces in a deliberate order.
It's discouraged as a habit because only that dependency chain is applied, so the rest of the configuration is now out of sync with what a full plan would produce. The next normal apply, possibly days later and run by someone else, can surprise them with a much larger diff than expected, since changes that were queued up were skipped. It also bypasses whatever review exists around a full plan, because targeted applies are often run directly against production during an incident.
HashiCorp's own guidance is that if you reach for -target regularly instead of as a rare exception, your state or module is probably scoped too broadly and should be split (see the state layout question).
Start by reading exactly what the plan says would change: the specific attribute and the before and after values, not just the resource name. An ~ update in-place on an attribute that really was changed in the console is real drift. You either accept it (update your .tf to match, if the manual change should stay) or apply to revert it (if it shouldn't have been changed outside Terraform).
If the diff looks like it's flapping, with the same attribute shown as changing on every plan even right after an apply, it's usually one of three things:
ignore_changes.The debugging sequence: read the exact attribute diff, check the provider's changelog and GitHub issues for that resource and attribute if the diff looks spurious, and run terraform plan -refresh-only to isolate whether this is state-versus-reality drift apart from any config-driven change. Jumping straight to apply without this is how a provider bug turns into an unwanted change to real infrastructure.
Workspaces share one configuration and backend and differ only in which state is active. That is lightweight, with minimal duplication, and convenient when environments are nearly identical. Separate directories duplicate some backend and variable boilerplate but give each environment fully independent state, credentials and .tfvars.
For a team of 30 with prod in the mix, separate directories are the safer default, and the reasons get stronger with team size. Workspace selection is invisible in the code. terraform workspace show is the only way to know which is active, and it's a per-shell setting that's easy to get wrong. At that size, "someone thought they were in staging and applied to prod" is a realistic failure, not a hypothetical. Separate directories make the target environment visible in the file path and usually in the CI job configuration. They also allow genuinely different IAM credentials per environment, which a shared workspace setup can't express cleanly, since the provider block is the same across all workspaces in a configuration.
The honest trade-off is that separate directories mean more files to keep in sync. Many teams solve that with thin root modules per environment that all call the same shared child module. That keeps the code DRY, as workspaces do, without the isolation risk. At 30 engineers, the cost of that extra bookkeeping is worth the smaller blast radius of a wrong-environment apply.
Some attribute changes force a destroy-and-recreate (the plan marks it -/+, "requires replacement"). With the default order, Terraform destroys the old object and then creates the new one, so anything depending on it has a gap. For something serving live traffic, that means an outage, however brief.
The standard fix is create_before_destroy in the resource's lifecycle block, which flips the order so the replacement exists before the old one is removed:
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, that alone isn't the whole story. The group's existing instances were launched from the old template and won't pick up the new one just because the template resource was replaced. The group has to cycle its instances. That means pairing the template change with an instance_refresh block on aws_autoscaling_group, which triggers a rolling replacement and respects a minimum healthy percentage, so capacity never falls below a safe level.
Using name_prefix instead of a fixed name is also required. With a fixed name, AWS won't let two templates with the same name exist at once, so Terraform can't create the new one first, and create_before_destroy silently fails to help even though it's configured.
The main 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 status. A state with a thousand resources means at least a thousand API calls before Terraform even starts computing a diff.
The fixes, roughly in order from quick to structural:
-refresh=false skips the refresh for that run. It's fast, but Terraform is then comparing your config with what it last knew, not with current reality. Fine for quick local iteration, risky before an apply that matters, since real drift won't be caught.-target narrows the plan to one resource and its dependencies, avoiding the refresh cost for the rest. The same caveats apply as in the -target question.-parallelism (default 10) lets more refresh calls run at once. It hits diminishing returns quickly once the provider's API starts rate limiting you.Also rule out backend latency. The whole state file is downloaded before anything else happens, so a backend far from your CI runners, or a very large state object, adds fixed overhead whatever else you tune.
terraform import, and the declarative import block available since Terraform 1.5, binds one real object to one resource address. It assumes a clean one-to-one mapping. That assumption breaks when the provider models something as several linked resources, or when a resource type doesn't support import for part of its configuration.
A typical example is an AWS security group and its rules. Depending on your provider version and style, rules may be inline ingress and egress blocks, or separate resources such as aws_vpc_security_group_ingress_rule. Importing only the aws_security_group doesn't necessarily bring in every rule the way your .tf file expects.
The practical approach:
plan and read the diff carefully instead of assuming it's clean.import blocks (or terraform import commands) for each separately modeled piece. Check the provider documentation for how it splits that object across resource types.If a resource type can't be imported at all, you can write the block to describe the desired end state and let a later apply create a replacement. That's acceptable for something non-critical. For anything stateful, such as data or DNS records with live traffic, a forced replace has real consequences, so check the provider's GitHub issues for import support before assuming there's no path.
Import only edits state, so a wrong import doesn't touch real infrastructure. If you import the wrong object, fix it with terraform state rm for that address before trying again.
First, stop all applies. Running apply against empty or damaged state is the worst move. Terraform would think nothing exists and try to create everything again, hitting conflicts or making duplicates.
Then recover in order of preference:
terraform state pull and terraform state push.import blocks. terraform plan -generate-config-out can draft the missing configuration. After each batch, run plan and aim for zero changes.terraform state pull > snapshot.tfstate # always snapshot before any surgeryPrevention is better than recovery: enable versioning and access logging on the state bucket, restrict who can delete or write state, enable MFA delete if you can, keep locking on, and split state into smaller layers so one failure has a small blast radius.
The goal is to move resources between states without destroying or recreating anything. The safest modern approach combines removed blocks and import blocks, with a freeze on applies during the move.
terraform state pull.import blocks, and run plan until it shows no changes for those resources.removed blocks and destroy = false, so Terraform forgets them but leaves the real objects running.plan on the old stack and confirm it proposes no destruction.terraform_remote_state (or a parameter store).# old stackremoved { from = aws_subnet.private lifecycle { destroy = false }} # new stackimport { to = aws_subnet.private id = "subnet-0abc123"}The older way is terraform state mv with -state and -state-out against local state files. It still works, but it's manual and harder to review.
Do the work in small batches, one layer at a time, and always confirm clean plans on both sides before unfreezing. The dangerous moment is when both states claim the same resource, so don't apply either stack in that window.
A major provider version (say AWS 4 to 5) can change defaults, rename arguments and split resources. Upgrading carelessly produces surprising diffs or even replacements. Treat it as a controlled migration.
terraform init -upgrade, and read the plan.~> 4.0) will block consumers. Module authors should declare minimum versions (>= 4.0), and let the root configuration pick the exact version..terraform.lock.hcl each time.Automate the boring part with a dependency bot that opens pull requests for new provider versions, so upgrades stay small and frequent instead of piling up into one scary jump.
A cycle means resource A depends on B and B depends on A, directly or through a longer chain. Terraform needs a dependency order to build and destroy things, so it can't proceed.
The textbook cause is two AWS security groups that reference each other inline:
# Cycle: each group's rule points at the other groupresource "aws_security_group" "app" { ingress { security_groups = [aws_security_group.db.id] }}resource "aws_security_group" "db" { ingress { security_groups = [aws_security_group.app.id] }}The fix is to break the loop by creating the groups first and the rules as separate resources:
resource "aws_security_group" "app" { name = "app" }resource "aws_security_group" "db" { name = "db" } resource "aws_vpc_security_group_ingress_rule" "db_from_app" { security_group_id = aws_security_group.db.id referenced_security_group_id = aws_security_group.app.id ip_protocol = "tcp" from_port = 5432 to_port = 5432}To find a cycle in a bigger configuration, read the error, which lists the resources involved, and use terraform graph to see the edges. Common other causes are an unnecessary depends_on, a module output feeding back into one of its own inputs, and a local referencing something that depends on it. The general cure is to introduce a separate resource that holds the connection between the two.
The principle is that a plan should be able to read but not change anything, and an apply to production should only be possible from a protected, reviewed path.
A solid design:
plan role and a write-enabled apply role.AdministratorAccess. Start from what the code actually creates, tighten with permission boundaries, and review it as code.The honest trade-off is that a tightly scoped apply role has to be updated whenever Terraform starts managing a new service. That friction is worth it, because a compromised pipeline then can't do everything.
No single setting is enough. Use several layers, because each one fails in a different way.
lifecycle { prevent_destroy = true } makes any plan that would destroy the resource fail. But know its limit: it protects only while the block exists in your code. If someone deletes the whole resource block, the guard goes with it and Terraform proposes destroying the resource.deletion_protection = true on an RDS instance. The cloud itself refuses to delete it, even if Terraform is told to. This is the layer that really stops mistakes.skip_final_snapshot = false, automated backups and tested restores.-/+ (replace) on a database. Read plans for any replace on a stateful resource, and consider policy-as-code that blocks them.resource "aws_db_instance" "main" { # ... deletion_protection = true skip_final_snapshot = false final_snapshot_identifier = "main-final" lifecycle { prevent_destroy = true }}Treat modules as internal products with owners, versions and a stable interface.
moved blocks when you rename internal resources, so an upgrade doesn't recreate anything.terraform test or Terratest in CI for every module, plus static and security scans, before releasing a version.terraform-docs), and keep examples that actually run.Watch for the trade-off between flexibility and consistency. A module that exposes every option becomes unmaintainable, while one that exposes too few gets forked. Review how it is actually used, and adjust the interface based on real requests.
Terraform records each resource in state as soon as it succeeds, so after a partial failure, the resources that completed are tracked, and the failed one and its dependents were not created. Terraform doesn't roll back. It just stops and reports the error.
Recovery steps:
terraform plan. It will show only what's left to do. Because Terraform is idempotent, a normal re-apply continues from where it stopped.import.terraform force-unlock.What not to do: don't run terraform state rm or hand-edit state to "make the error go away". Don't destroy and start over unless you have to. The partial-failure case is exactly why remote state, locking and saved plans matter.
Do it incrementally and safely. Trying to import everything at once usually stalls.
import blocks. Use plan -generate-config-out or a tool such as Terraformer or the cloud vendor's export tool to draft HCL, then treat the output as a first draft, since it's verbose and not idiomatic.for_each.The danger to name is the first apply after an import. If generated HCL doesn't match reality, the plan may change or replace live resources. That's why the "no changes" check comes first, and why you do production last, after you've practiced on a lower environment.
Terraform needs a provider configured before it can plan any of that provider's resources. If the kubernetes provider's settings come from attributes of a cluster resource created in the same configuration (its endpoint and credentials), those values don't exist until the cluster is created.
# Fragile: provider settings depend on a resource in the same stateprovider "kubernetes" { host = aws_eks_cluster.main.endpoint cluster_ca_certificate = base64decode(aws_eks_cluster.main.certificate_authority[0].data) # ...}This sometimes works on the very first apply, but it breaks in common situations:
The better design is layering: one configuration creates the cluster, and a separate configuration deploys workloads into it, reading the cluster details through a data source or remote state. Each stack has its own state and apply, and the second one always starts with a cluster that exists.
Many teams go further and manage in-cluster workloads with a GitOps tool such as Argo CD or Flux, and keep Terraform for the cloud infrastructure underneath it. A good answer explains why, and doesn't just say "split it".
Aim for isolation, review and repeatability.
Repository structure (one pattern among several):
modules/ # reusable, versioned building blocks vpc/ service/live/ dev/ networking/ app-foo/ staging/ prod/ networking/ app-foo/ # each folder is its own root module and its own statelive/ is a separate root module with its own state key, so a change in one place can't touch another..tfvars), not copy-pasted code.Pipeline:
Controls that make it safe:
Close by naming the trade-offs: more stacks mean more wiring, strict pipelines slow urgent fixes (so define an emergency path), and the structure should grow with the team. Start simple, and split when a real pain appears.