This page is for anyone facing a Terraform round, whether it's part of a DevOps, cloud or platform interview. Most rounds start with the basics of providers, resources and the plan and apply cycle, then spend real time on state: remote backends, locking, drift and moving resources safely. After that come variables, modules, count versus for_each and lifecycle rules, with senior rounds adding pipeline design, secrets and a story about a change that went wrong. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Repeatable: the same code builds the same setup in dev, test and production.
Reviewable: changes go through pull requests and a plan shows the effect before anything happens.
Recoverable: the code is the record, so you can rebuild or audit what exists.
"When people click things together in a console, nobody really knows later what exists or why. Environments slowly drift apart, and rebuilding one after a mistake means guessing. With Terraform the setup is written down as code in Git. That gives me three things. First, repeatability: I can build the same network and servers in dev and production from one config with different inputs. Second, review: a change is a pull request, and terraform plan shows exactly what will be created, changed or destroyed before anyone applies it. Third, history and recovery: Git tells me who changed a firewall rule and when, and if a region or account is lost I can recreate it from code. Terraform in particular works across many providers with one workflow, so the same habits apply to cloud resources, DNS and monitoring."
Only saying it is faster, without mentioning review, repeatability or the plan step.
Provider: a plugin that talks to one API, such as a cloud, and knows its resource types.
Resource: something Terraform creates, updates and destroys, and tracks in state.
Data source: a read-only lookup of something that already exists, used as input.
"A provider is the plugin that knows how to talk to one platform's API. The AWS provider, the Azure provider and the Kubernetes provider are examples, and terraform init downloads them. A resource is an object Terraform owns: I declare a bucket or a virtual machine, Terraform creates it, remembers it in state, and will change or delete it to match my code. A data source is a read-only lookup. It fetches information about something that already exists, like the latest image matching a filter or a network another team manages, so I can use its ID without owning it. The key difference is ownership: if I delete a resource block, Terraform plans to destroy the real object. If I delete a data source block, nothing in the cloud changes."
data "aws_vpc" "shared" {
tags = { Name = "shared-network" }
}
resource "aws_subnet" "app" {
vpc_id = data.aws_vpc.shared.id
cidr_block = "10.0.1.0/24"
}
Thinking a data source creates the thing it looks up, or mixing up providers with modules.
Implicit: referencing another resource's attribute creates an edge in the graph.
Parallel: resources with no path between them are created at the same time.
Explicit: depends_on is for dependencies Terraform can't see from references.
"Terraform builds a dependency graph from the code. If my subnet uses the VPC's id, that reference tells Terraform the VPC must exist first, so I rarely write the order myself. Anything with no link between them runs in parallel, which is why a large apply is quicker than doing things one by one. On destroy it walks the graph in reverse. depends_on is for hidden dependencies, where one thing needs another but doesn't reference any of its attributes. A common example is an app server that needs an access policy attached before it starts, even though nothing in the server block points at that policy. I use it sparingly, because it makes the graph more conservative, and it can make data sources wait until apply, which means less is known at plan time."
Adding depends_on everywhere to be safe, or believing Terraform runs resources top to bottom in file order.
Constraint: required_providers says which versions are allowed, for example a pessimistic range.
Lock file: records the exact version picked and its checksums; commit it.
Upgrade: change versions on purpose with init -upgrade and review the plan.
"In the terraform block I declare required_providers with a source and a version constraint, usually something like the pessimistic operator so I accept patch or minor updates but not a new major version. That constraint says what's allowed. The lock file, .terraform.lock.hcl, records which exact version init actually chose, plus checksums of the package. I commit it to Git, so every teammate and the CI runner install the same provider build, and a surprise release can't change behaviour between the plan someone reviewed and the apply. When I want to move up, I run terraform init -upgrade, look at the provider changelog, and check the plan shows no unexpected changes. One thing to note is that the lock file covers providers, not modules, so module versions are pinned separately in the module block."
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Not committing the lock file, or leaving providers unpinned so any new major version can be pulled in.
init: sets up the backend and downloads providers and modules.
plan: refreshes state, compares it with the code and shows the proposed changes.
apply and destroy: apply carries the changes out; destroy removes everything the state tracks.
"terraform init prepares the working folder. It configures the backend where state lives and downloads the providers and modules the code needs, and it writes a lock file for provider versions. terraform plan reads the current state, checks the real infrastructure for changes, and compares that with my code. It prints what it would create, update in place, replace or destroy, and it doesn't change anything. terraform apply works out the same plan, asks me to confirm unless I pass a saved plan, and then makes the API calls and updates the state. terraform destroy is really a plan to delete everything this state manages, so it's dangerous in shared environments. In a team I usually run plan with -out to save it, have it reviewed, and apply that exact file."
Saying plan makes changes, or not knowing that destroy removes everything in that state.
On pull request: fmt check, validate, lint and policy checks, then plan posted to the PR.
On merge: plan saved to a file, approval gate for production, apply that exact plan.
Safety: one runner per state at a time, locked versions, no applies from laptops.
"On every pull request the pipeline runs terraform fmt -check and validate, then a linter and any policy checks, like no public buckets. Then it runs plan against each affected environment and posts the summary to the pull request, so reviewers read the plan, not just the code. On merge to main, it runs plan again with -out to save the plan file, and for production there's a manual approval step where someone reads that plan. Then it applies the saved file, so what gets applied is exactly what was approved. If state changed in between, Terraform refuses the stale plan, which is what I want. I'd make sure only one job runs per state at a time, pin Terraform and provider versions, store plan files as protected artifacts because they can hold secrets, and remove apply rights from people's laptops for production."
A pipeline that runs apply with auto-approve on every push, with no saved plan or review of the plan output.
Mapping: links each resource address in code to a real object ID.
Metadata: stores attributes and dependencies used to plan and to destroy in order.
Care: it can hold secrets, so it needs a secure, shared, backed-up home.
"The state file is Terraform's record of what it manages. For each resource address in my code, like aws_instance.web, it stores the real object's ID and its attributes as they were after the last run. Terraform needs it because the cloud doesn't know which objects belong to which config. Without state, Terraform would have no idea that the server in my code is that particular server, so it would try to create a new one. State also remembers dependencies, which helps it delete things in the right order after the code for them is gone, and it saves time on large setups. Because it can contain sensitive values in plain text, I treat it like a secret: stored in a remote backend with encryption and access control, never committed to Git, and never edited by hand."
Committing state to Git, or saying Terraform could simply look up everything in the cloud without it.
Shared: one copy of state everyone and CI use, not files on laptops.
Locking: plan, apply and other state commands hold a lock, so two runs can't overlap.
Safety: versioning, encryption and tight access on the backend.
"Local state works for one person, but in a team it breaks quickly. If two people each have their own copy, they overwrite each other's changes or create duplicates. A remote backend puts one copy in a shared place, like an object storage bucket or a managed Terraform service, and every run reads from it. Locking is the second half. When someone runs plan or apply, Terraform takes a lock on that state, and anyone else trying to run against it waits or fails with a lock error. Without it, two applies at once could each read the old state and write conflicting results, which can corrupt it. Each backend locks its own way: some use a lease on the blob, some a separate lock table or lock file. I also turn on versioning for the bucket, so I can recover an older state if something goes wrong."
Keeping state on a laptop or in Git for a team, or not knowing what locking prevents.
Meaning: the real infrastructure no longer matches what state and code say.
Find it: a normal plan shows it; plan -refresh-only shows only the outside changes.
Decide: revert with apply, or copy the change into code if it should stay.
"Drift is when the real infrastructure differs from what Terraform expects, usually because someone changed something in the console or another tool touched it. A normal plan refreshes state first, so drift shows up as changes Terraform wants to make to put things back. If I only want to see what changed outside Terraform, I run plan with -refresh-only, which shows the difference between state and reality without proposing code changes. Then I decide. If the manual change was a mistake, I apply and Terraform reverts it. If it was a real fix, say an emergency firewall rule during an incident, I write it into the code, so the plan comes back clean. If I only want state to record the outside change, apply -refresh-only does that without touching anything. Some teams run a scheduled plan in CI and alert when it isn't empty, so drift is caught in days, not months."
Blindly applying to overwrite a manual change without asking why it was made.
Why: a new address looks like a new resource, and the old one looks deleted.
moved block: declare the old and new address in code; the plan shows a move, not a replace.
Older way: terraform state mv, run by hand, with a plan afterwards to confirm.
"Terraform tracks resources by their address, so if I rename aws_instance.web to aws_instance.api, it sees one resource gone and a new one to create. For a database that would be a disaster. The clean fix today is a moved block: I write from the old address and to the new one, and the plan shows it as a move with no changes to the real object. It's reviewed in the pull request like any other change and works for everyone's state, including other workspaces. The older approach is terraform state mv, which edits state directly from the command line. It works, but it's a manual step outside review, so I'd take a state backup first. Either way, the proof is a plan that shows zero adds and zero destroys. The same idea applies when moving a resource into a module or switching from count to for_each."
moved {
from = aws_instance.web
to = aws_instance.api
}
moved {
from = aws_s3_bucket.logs
to = module.logging.aws_s3_bucket.this
}
Accepting the destroy and recreate on a stateful resource, or hand-editing the state JSON.
Write or generate code: a resource block that describes the object.
Import: an import block, or the terraform import command, links the address to the real ID.
Reconcile: adjust the code until plan shows no changes.
"Import tells Terraform that an existing object belongs to a resource address in my code. The newer way is an import block: I write the target address and the real ID, run plan, and Terraform shows it will import rather than create. With -generate-config-out it can even draft the resource block for me, which I then clean up. The older way is the terraform import command, which writes straight to state and needs the resource block to exist already. Either way, importing only fills in state. The real work is making the code match reality, so I keep running plan and adjusting arguments until it shows nothing to change. I'd import in small batches, starting with things that are low risk, and I'd never apply while the plan still shows changes to a production object I don't understand."
import {
to = aws_s3_bucket.reports
id = "company-reports-bucket"
}
resource "aws_s3_bucket" "reports" {
bucket = "company-reports-bucket"
}
Thinking import writes the code for you in all cases, or applying straight after import without a clean plan.
Don't revert: an apply now would undo the fix and restart the incident.
Codify: add the rule to the code through a normal pull request so the plan is clean.
Follow up: ask if the fix was right long term, and agree how emergency changes get recorded.
"The worst thing I could do is let the next routine apply remove that port and take the service down again. So first I'd tell the team, so nobody applies that config for now. Then I'd talk to whoever made the change and understand why it was needed. If the rule should stay, I add it to the code in a pull request, maybe tighter than the emergency version, and confirm the plan shows no change to the live rule, or only the tightening we agreed. If it was a workaround, we fix the real cause and then remove the rule through Terraform. Afterwards I'd suggest a simple rule for incidents: console changes are allowed, but they get a ticket to codify within a day, and a scheduled drift plan checks that they did."
Applying immediately to put things back the way the code says, or scolding the engineer who kept the service up.
Boundaries: split by how often things change and who owns them, such as network, data and apps.
Move: import into the new state, remove from the old one without destroying.
Prove: both plans show no changes before and after; one piece at a time.
"First I'd pick boundaries that match how things change: the network rarely changes, databases change carefully, and application pieces change daily, so those make natural separate states. Different owners are another good boundary. Then I'd move one piece at a time. In the new configuration I add the resource code plus import blocks, and in the old one I use removed blocks with destroy set to false, or terraform state rm, so the old state forgets the objects without deleting them. The rule is that both plans must show zero adds and zero destroys before anything is applied. Values the pieces share, like subnet IDs, become outputs or data source lookups instead of direct references. I'd back up the state first, freeze other changes to that state during the move, and do the low-risk parts first to build confidence."
Deleting resources from the old config and letting them be recreated in the new one, or doing the whole split in one big change.
Check: read the lock info: who holds it, when, from which operation.
Confirm: make sure that run or pipeline is truly dead, not just slow.
Release: terraform force-unlock with the lock ID, then plan again.
"The lock error prints the lock ID, who took it, and when. First I check whether that run is really gone. Often a CI job was cancelled or a laptop lost its connection mid-apply, so the lock was never released. I'd look at the pipeline or message the person named. If someone is actually applying right now, I just wait, because forcing the lock while they write state is how you corrupt it. Once I'm sure nothing is running, I use terraform force-unlock with that lock ID, then run a plan to see what state things are in. If the failed run was an apply, the plan might show some resources half-created, so I read it carefully before applying again. Afterwards I'd look at why the lock leaked, for example pipelines that kill jobs without a grace period."
Force-unlocking immediately without checking whether another run is in progress, or deleting the lock record by hand.
No rollback: what succeeded stays created and is saved in state.
Tainted: a resource that was created but failed during setup may be marked for replacement.
Recover: read the error, fix the cause, plan again, apply the rest.
"Terraform doesn't roll back. Everything that completed before the error is real and is recorded in state, and the rest simply wasn't done. If a resource was created but failed partway through, for example a provisioner errored, Terraform marks it tainted, so the next plan will replace it. So my steps are: read the actual error first, because it's often quotas, permissions, a name already in use or a timeout. Fix that cause. Then run plan, which shows only the remaining work plus any tainted replacements, and check it makes sense before applying. What I don't do is delete things by hand in the console to get a clean start, because that creates drift. If the failure was a timeout, I check whether the object actually finished creating before retrying, so I don't end up with a duplicate outside state."
Expecting Terraform to roll back automatically, or deleting resources by hand to start over.
Meaning: the object exists in the cloud but not in this state.
Causes: built by hand, lost state, another config owns it, or a failed earlier run.
Fix: import it if this config should own it; otherwise rename or use a data source.
"It means Terraform is trying to create something with a name or ID that's already taken, and this state has no record of it. So the question is why. Maybe someone created it by hand, or a previous apply timed out after the object was actually created, or state was lost, or a different Terraform config already owns it. I'd look at the object's tags and creation details to find out. If this config should own it and nothing else manages it, I import it, then make the code match until the plan is clean. If another config already manages it, I must not import it here, because two states managing one object will fight each other. In that case I'd read it with a data source or pick a different name. Renaming blindly just leaves an orphaned object behind."
Importing the object without checking whether another configuration already manages it.
Stop: don't approve; the plan is the truth, not the description.
Find the cause: the plan marks which attribute forces replacement.
Fix safely: revert or change that attribute, move the address, or plan a proper migration.
"I'd block the merge straight away, because the plan is what will happen, whatever the pull request says. Then I'd read the plan for the line marked forces replacement. It's often not the tag at all. Common causes are an attribute that can't be changed in place, like the instance identifier or the storage encryption setting, a provider upgrade that changed a default, a resource renamed or moved into a module without a moved block, or a count index that shifted. Once I know the attribute, the fix follows: revert that change, add a moved block, or pin the provider back. If the change really is needed and truly forces replacement, that's a planned migration with a snapshot, a maintenance window and a restore test, not a routine merge. Afterwards I'd put prevent_destroy on the database and add a pipeline check that flags any destroy of stateful resources."
Approving because the description says it's harmless, or adding ignore_changes to hide the diff without understanding it.
Declare: type, description, optional default, validation, sensitive.
Sources: default, TF_VAR_ environment variables, tfvars files, command-line flags.
Order: later sources override earlier ones; command-line flags win.
"I declare a variable with a type, a description, and a default only if a sensible one exists. Then the value can come from several places. From lowest to highest: the default, environment variables named TF_VAR_ plus the name, the terraform.tfvars file, then terraform.tfvars.json, then any .auto.tfvars files in name order, and finally -var and -var-file flags on the command line, where the last one given wins. If nothing sets a required variable, Terraform prompts for it, which is why I always run with -input=false in CI so it fails fast instead of hanging. I also like validation blocks, so a bad value like a wrong environment name fails at plan with a clear message, not halfway through an apply. For per-environment values I keep a separate tfvars file per environment and pass it with -var-file."
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
Not knowing that command-line flags override tfvars, or putting secrets as defaults in the variable block.
Variables: inputs set by the caller; part of the module's interface.
Locals: names for values computed inside, not settable from outside.
Use: locals to avoid repeating expressions, like common tags or name prefixes.
"An input variable is something the caller of the configuration or module decides, like the environment name or the instance size. It's part of the public interface. A local is a named expression inside the module that nobody outside can override. I use locals to avoid repeating myself and to give a clear name to something computed. A common example is a name prefix built from the project and environment, or a map of standard tags merged with extra tags passed in. If I made those variables instead, callers could set them to inconsistent values, and the interface gets noisy. My rule of thumb is: if a user genuinely needs to choose it, it's a variable. If it's derived from other values, it's a local. Too many locals chained together can hurt readability, so I keep them simple."
Making every value a variable, or saying locals and variables are interchangeable.
Outputs: expose values from a module or a root config, like an ID or endpoint.
Between modules: the parent reads module.name.output_name.
Between states: terraform_remote_state, or a data source lookup, or a shared parameter store.
"Outputs are how a configuration or module exposes values. Inside one config, a module's outputs are how the parent gets things like a subnet ID or a load balancer address, using module dot name dot output. At the root, outputs are printed after apply and can be read with terraform output, which is handy for scripts. Between separate states, say the network team's state and my application state, I have a few options. terraform_remote_state reads the other state's root outputs directly, but it means my config needs read access to their whole state, which can include secrets. Often a cleaner option is a normal data source that looks the network up by tags, or having the network config publish values to a parameter store. Outputs can be marked sensitive, which hides them in the CLI but not in state."
Thinking a sensitive output is encrypted or removed from state.
count: makes N copies addressed by number; good for identical copies or on/off switches.
The trap: remove an item from the middle and every later index shifts, so resources get replaced.
for_each: keys each instance by a stable string, so only the removed one is destroyed.
"count creates copies addressed by position, like aws_iam_user.dev[0] and [1]. That's fine for identical things, or the common trick of count equals one or zero to switch a resource on or off. The problem comes when I use count over a list of named things. If I remove the first name, every later name moves up one index, and Terraform sees each index now has a different name. So it updates or even replaces the resources after it, which for users or buckets is a real outage. for_each takes a map or a set of strings, and each instance is keyed by that string, like aws_iam_user.dev with the key alice. Removing one entry only destroys that one. One limit: the keys must be known at plan time, so I can't key on an ID that only exists after apply."
variable "developers" {
type = set(string)
}
variable "enable_debug" {
type = bool
}
# keyed by name: removing one user touches only that user
resource "aws_iam_user" "dev" {
for_each = var.developers
name = each.key
}
# count as an on/off switch
resource "aws_cloudwatch_log_group" "debug" {
count = var.enable_debug ? 1 : 0
name = "/app/debug"
}
Using count over a list of names and not seeing the index shift problem.
dynamic: loops over a collection to produce repeated nested blocks.
content: the body of each block; the iterator is named after the block.
Restraint: use it for real variable input, not to hide simple static config.
"A whole resource can be repeated with for_each, but nested blocks inside a resource, like ingress rules in a security group, need a dynamic block. I write dynamic followed by the block name, give it a for_each over a list or map, and put the block's body inside content. Inside content, the iterator takes the block's name by default, so I read ingress.value.port for each rule, or I can rename the iterator if it's clearer. This keeps a module flexible: callers pass a list of rules and the module builds them. That said, I don't use dynamic blocks just to be clever. If the config is fixed, writing the blocks out plainly is easier to review. And for security group rules specifically, some providers now offer separate rule resources, which can be easier to manage one by one."
variable "ingress_rules" {
type = list(object({
port = number
cidrs = list(string)
}))
}
resource "aws_security_group" "app" {
name = "app"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidrs
}
}
}
Wrapping everything in dynamic blocks so the code can't be read without running a plan.
create_before_destroy: build the replacement first to avoid downtime; watch unique names.
prevent_destroy: any plan that would destroy the resource errors out.
ignore_changes: stop Terraform fighting attributes changed by something else.
"These three change how Terraform handles a resource's life. create_before_destroy flips the default order on replacement: the new object is built first, then the old one is removed. I use it for things like a TLS certificate attached to a load balancer, where a gap would cause downtime. The catch is names: if the name must be unique, both can't exist at once, so I use a name prefix. prevent_destroy makes any plan that would delete the resource fail, which I put on production databases and state buckets. But it only works while the block is in the code; if someone deletes the whole resource block, the protection goes with it. ignore_changes tells Terraform not to revert certain attributes. A typical case is an autoscaling group's desired count, which the autoscaler changes, or tags added by another system."
Saying prevent_destroy stops deletion in every case, or using ignore_changes on everything to silence a noisy plan.
Definition: any folder of .tf files; the root module calls child modules.
Interface: variables in, outputs out, the details hidden inside.
When: a pattern repeated across environments or teams, with a clear purpose.
"Every folder of Terraform files is a module, and the one I run commands in is the root module. When I call another folder or a registry module with a module block, that's a child module. I pass it inputs as arguments, and it gives back outputs. The point is packaging. If every service needs the same pattern, say a bucket with encryption, versioning and a lifecycle policy, I write it once as a module and every team gets the safe defaults. It's worth writing one when the pattern repeats and has a clear job. It's not worth wrapping a single resource just to rename its arguments, because that adds a layer to maintain and debug with no real gain. For shared modules I pin a version, so an update to the module doesn't change everyone's infrastructure on their next plan."
Creating a module for every single resource, or calling shared modules without pinning a version.
Interface: few required inputs, typed and validated; safe defaults for backups and encryption.
Guard rails: hard-code what must never vary, expose what teams really need to choose.
Lifecycle: semantic versions, a changelog, moved blocks, tests and examples.
"I'd start by asking what teams actually need to choose: engine version, size, maybe the network. Those become typed variables with validation. Things that must never vary, like encryption on, public access off, backups kept, go in the module without a switch, because each switch is a way to get it wrong. I'd keep outputs to what callers need, like the endpoint and a secret reference, and turn on the database's own deletion protection by default. I'd avoid prevent_destroy inside the module, because lifecycle settings can't come from a variable, so no caller could ever switch it off. For evolution, the module lives in its own repo or registry with semantic versions and a changelog. Callers pin a version and upgrade on purpose. If I rename things inside, I ship moved blocks so nobody's database gets replaced. Before release, I'd run terraform test against an example config in a test account, and ship a README with a working example."
Exposing every argument of the underlying resource as a variable, which makes the module a thin wrapper with no guard rails.
Workspaces: same code and backend, one state per workspace; easy to apply to the wrong one.
Separate roots: a folder or config per environment calling shared modules; separate backends and credentials.
Choice: workspaces for short-lived copies; separate roots for real environment isolation.
"CLI workspaces give one configuration several states in the same backend, and you switch with terraform workspace select. They're handy for identical, short-lived copies, like a test stack per feature branch. For dev, staging and production I prefer separate root configurations, one folder per environment, each calling the same versioned modules with its own tfvars and its own backend. The reasons are practical. With workspaces, the only thing between me and production is which workspace is selected, and it's easy to apply to the wrong one. They also share the same backend and usually the same credentials, so I can't give dev access without production access. Environments also tend to differ over time, and separate folders make that visible instead of burying it in conditionals on terraform.workspace. The cost is a bit of repetition, which modules keep small."
Saying workspaces give full isolation between environments, or not knowing they share one backend.
Never in code: no secrets in .tf or committed tfvars files.
Fetch or generate: read from a secrets manager, or let the cloud generate and store it.
Protect state: encrypted backend, tight access, and remember sensitive only redacts output.
"First, secrets never go in the code or in committed tfvars files. I either pass them in from the pipeline's secret store as environment variables, read them with a data source from a secrets manager, or, better, let the platform create and store them, like a database that manages its own master password in a secret service, so Terraform only handles a reference. The part people miss is state. Any secret Terraform reads or sets is usually written into the state file in plain text, and it can appear in saved plan files too. Marking a variable or output as sensitive only hides it from the terminal and logs; it's still in state. So the backend must be encrypted, access limited to the people and pipelines that need it, and plan files treated as secrets. Newer Terraform versions add ephemeral values and write-only arguments, which keep a secret out of state entirely where the provider supports them."
Believing sensitive = true encrypts the value or keeps it out of state.
What happened: the change, what broke, and who noticed.
Root cause: why the plan or review didn't catch it.
Fix and lesson: the recovery and the lasting guard rail you added.
"At my last company I changed our storage module to use for_each instead of count, because the index shifting kept bothering me. Staging looked fine, but the production plan showed a few buckets being replaced, and in a long plan that got missed in review. The apply replaced a log bucket, and we lost some recent logs that hadn't been copied elsewhere. Nothing customer facing broke, but it was a real loss. The root cause was that I hadn't written moved blocks for every key, and our review didn't look at the plan summary, only the code. I restored what we could from a replica, then added moved blocks for the rest. After that we posted plan summaries into every pull request, added a check that fails on any destroy of storage or databases unless someone approves it, and put prevent_destroy on buckets holding data."
A story where the cause was bad luck or someone else, with no change to the process afterwards.
Starting point: what existed, why it needed to change.
Approach: order of work, import method, how you proved nothing changed.
Lesson: what surprised you and what you'd do again.
"In my last role our main application's networking and load balancers had all been set up by hand over a few years. Nobody fully knew the rules, and we couldn't build a second region with any confidence. I started with an inventory using the cloud's own listing tools, then grouped things by risk. I brought the low-risk parts in first, like log groups and DNS records, using import blocks and generated code that I cleaned up by hand. For every batch, the rule was a plan with zero changes before we merged. The surprise was how many settings differed from what we assumed, like old default values and rules nobody could explain. We documented each one before deciding to keep or remove it. The payoff came a few months later, when we built the second region from the same modules in days instead of weeks."
Describing a big-bang import with no plan checks, or not being able to name anything that went wrong.
Problem: what hurt: slow plans, risky applies, messy repo, unclear ownership.
Change: what you proposed and how you got the team to agree.
Result: what got better, in plain terms.
"At my last company everyone ran applies from their own laptop with slightly different Terraform versions, and twice we had a state lock left behind and a confusing mess afterwards. I wrote a short proposal: pin the Terraform version, run all plans and applies in CI, post the plan to every pull request, and require approval for production. Some people worried it would slow them down, so I started with one low-risk environment and asked two sceptical teammates to try it for a couple of weeks. Plans in pull requests turned out to be the part everyone liked, because reviewers could see the real effect instead of guessing from code. After that we rolled it out to production. We stopped seeing version mismatches entirely, and changes to production became routine instead of something people avoided on Fridays."
A story about tooling alone, with no mention of how the team was brought along or what actually improved.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.