Skip to content
Software Atlas

Infrastructure as Code

Why teams stopped clicking servers into existence; how Terraform and OpenTofu turn HCL files into real resources with plan and apply; what the state file is and why it needs a lock; drift, import, modules and secrets; and how infrastructure changes get reviewed like code — with every command run for real on a laptop.

⏱ ~55 min read15 sections8 interactive widgetsHCL · Shell · YAML
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceInfrastructure as code means you write down the servers, networks and databases you want in text files, and a tool makes the real world match those files.

In Cloud Basics you built a network by typing one command after another. That works once. But next month somebody has to build the same thing for a second environment, or remember why a firewall port is open, or rebuild it all after a mistake. A list of commands in someone's shell history doesn't help much then.

The loop you'll repeat all day

step 1
Write
main.tf: what should exist
step 2
Plan
diff: files vs. reality
step 3
Review
a human reads the plan
step 4
Apply
API calls, then save state
↺ change the files and go round again · state (§05) remembers what was built
  1. Write what you want in files (main.tf), in a small language called HCL.
  2. Plan: the tool compares the files with what really exists and prints what it would do. Nothing changes yet.
  3. Review: you (and a teammate, in a pull request) read the plan.
  4. Apply: the tool makes the API calls, then writes down what it created in a file called the state (§05).

Change the files, and the loop starts again. You never say "create a server"; you say "there should be a server like this", and the tool works out whether that means create, change, or nothing.

What it looks like

Here's a complete, working configuration. It asks for one random name (our stand-in for a server name) and one config file that uses it:

main.tf
resource "random_pet" "server" {      # ← "I want a random name"
  length = 2
}

resource "local_file" "app_config" {  # ← "I want a file with this content"
  filename = "${path.module}/out/app.env"
  content  = "SERVER=${random_pet.server.id}\nPORT=8080\n"
}

And here's the end of a real terraform apply of it:

terminal
$ terraform apply
  …(the plan, then "Enter a value: yes")…
random_pet.server: Creating...
random_pet.server: Creation complete after 0s [id=distinct-serval]
local_file.app_config: Creating...
local_file.app_config: Creation complete after 0s [id=09cfa2c36f384aae9a08003bb73aeafa799a8aba]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

$ cat out/app.env
SERVER=distinct-serval
PORT=8080

$ terraform apply          # again, without changing anything
  …
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

The second run created nothing: the world already matched the file. Swap random_pet for aws_instance and local_file for aws_s3_bucket, and the same loop builds real cloud servers. §03 explains every line.

Why teams bother

1

Reviewed

A change to production is a diff in a pull request. A teammate reads the plan before anything happens.
2

Repeatable

The same files build dev, staging and prod the same way. Rebuilding after a disaster is a command, not an archaeology project.
3

Recorded

Git history says who changed what, when, and why. The files are the documentation.

Terraform, OpenTofu and friends

Terraform (by HashiCorp) is the most widely used tool. OpenTofu is an open-source fork of it with the same language and commands (tofu plan instead of terraform plan); everything on this page works in both. Other tools follow the same idea with different syntax: Pulumi and AWS CDK let you write the description in TypeScript, Go or Python, and CloudFormation is AWS's own YAML/JSON format.

Following along

If you want to type along, install Terraform (or OpenTofu) and make an empty folder. The examples only use two small, official plugins that run entirely on your machine, random (makes random names and passwords) and local (writes files), plus terraform_data, which is built into Terraform. A file standing in for a server is a little silly, but it means you can break things freely and nothing costs money.

Foundations§02

Why not just click (or script)?

In one sentenceClicking is fastest the first time. A script is faster the second time. Describing the end result is the only one that still works the hundredth time, when you don't know what's already there.

Three ways to get a server

Imagine your team needs three web servers and a firewall rule. There are three ways to make that happen.

1. Click it. You log into the cloud's web console, click "Launch instance" three times and tick some boxes. This is called ClickOps. It takes ten minutes and works fine. The problems come later:

  • Nobody reviewed it. A typo in the firewall rule (0.0.0.0/0 instead of the office address) is now live.
  • Nothing records it. Six months on, nobody knows why port 8443 is open, and nobody dares close it.
  • Nobody can repeat it. Staging was clicked by a different person, so it's "almost" the same as production, and bugs show up only in production.

Servers that have been tweaked by hand for years become snowflake servers: each one unique, so nobody can rebuild it if it dies.

2. Script it. You write the steps down in a shell script. Now it's reviewed and repeatable. The script is imperative: it says what to do, in order:

make-servers.sh (illustrative, not run)
for i in 1 2 3; do
  aws ec2 run-instances --image-id ami-0abc123 --instance-type t3.micro   # ← "create one more"
done

Run it once: three servers. Run it again, because you weren't sure the first run worked: six servers. The script doesn't know what already exists. To make it safe, you'd add "first list what exists, then only create what's missing, and also handle the ones that are configured wrong…", and soon the script is mostly checking code.

3. Declare it. You write down the result you want, and let a tool work out the steps. This is declarative:

servers.tf (illustrative AWS example, not applied)
resource "aws_instance" "web" {
  count         = 3              # ← "there should be three", not "make three"
  ami           = "ami-0abc123"
  instance_type = "t3.micro"
}

Run it once: three servers. Run it again: the tool sees three already exist and does nothing. Somebody deletes one by hand: the next run creates exactly one. Change 3 to 5: it creates two. The tool always compares what you want with what exists, and does only the difference.

Try it: script vs declaration

Press each "run" button a few times, then delete a server by hand or change the wanted count. Watch which side ends up with what you asked for.

Run it again… and again
Left: a script that says “create N servers”. Right: a declaration that says “there should be N servers”.
wanted servers
imperative · make-servers.sh
for i in $(seq 3); do
  create-server
done
no servers
0 running · wanted 3 ✗
declarative · servers.tf
resource "server" "web" {
  count = 3
}
no servers
0 running · wanted 3 ✗
Nothing exists yet. Press both “run” buttons.

"Idempotent" is the word for this

Running a declarative apply twice has the same effect as running it once. That property is called idempotent, and it's what makes declarative tools safe to re-run after a timeout or a crash: you never have to wonder "did the last run finish?" (The Idempotency topic covers the idea in depth for APIs.)

ClickOpsScript (imperative)Declarative IaC
You write downnothingthe stepsthe end result
Reviewed before it happensnoyes, the scriptyes, the files and a plan of the exact changes
Safe to run twicen/aonly if you wrote the checksyes, by design
Fixes hand-made changesnonoyes, on the next apply
Deletes things you removedyou have to rememberyou have to write ityes, it plans the delete

The catch: the tool has to remember

To "do only the difference", the tool has to know which real servers belong to which lines of your files. If there are 40 servers in the account, which three are yours? Terraform solves that by writing down everything it creates in a state file. State is the most important (and most fragile) part of the whole system; §05 is all about it.

Foundations§03

Your first configuration

In one sentenceA Terraform configuration is a folder of .tf files full of blocks. resource blocks say what should exist; when one block mentions another, Terraform learns which one to build first.

The whole file

This is the running example for the next few sections. Put it in an empty folder as main.tf:

main.tf
terraform {
  required_version = ">= 1.10"
  required_providers {
    local  = { source = "hashicorp/local", version = "~> 2.5" }
    random = { source = "hashicorp/random", version = "~> 3.7" }
  }
}

variable "port" {
  type    = number
  default = 8080
}

resource "random_pet" "server" {
  length = 2
}

resource "local_file" "app_config" {
  filename        = "${path.module}/out/app.env"
  content         = "SERVER=${random_pet.server.id}\nPORT=${var.port}\n"   # ← uses the pet's name
  file_permission = "0644"
}

resource "terraform_data" "release" {
  input = "v1"
}

output "server_name" {
  value = random_pet.server.id
}

What each block does

  • terraform { required_providers … } lists the providers this folder needs. A provider is a plugin that knows one API: hashicorp/aws talks to AWS, hashicorp/local talks to your disk. "~> 2.5" means "version 2.5 or any newer 2.x, but not 3.0", so a breaking new major version can't sneak in (it's semantic versioning).
  • resource blocks are the heart of it. Each resource is one real thing: here a random name, a file, and a terraform_data (a built-in resource that just stores a value; we'll use it to show an in-place change in §04).
  • variable and output are the configuration's inputs and results. §06 covers them.

Every resource type comes from a provider, and its name starts with the provider's name: random_pet from random, local_file from local, aws_s3_bucket from aws. Each provider's documentation on the Terraform Registry lists its resource types and their arguments.

terraform init: fetch the plugins

Before anything else, run terraform init in the folder. It reads required_providers, downloads matching provider versions, and records the exact choice:

terminal
$ terraform init
Initializing the backend...

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/local versions matching "~> 2.5"...
- Finding hashicorp/random versions matching "~> 3.7"...
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (unauthenticated)
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (unauthenticated)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
…
Terraform has been successfully initialized!

Our sandbox had no internet access to the public registry, so we installed the providers from a local copy, which is why it says (unauthenticated). From the real registry you'd see (signed by HashiCorp) instead.

init creates two things:

  • .terraform/ holds the downloaded plugins. It's a cache: add it to .gitignore.
  • .terraform.lock.hcl records the exact versions picked and their checksums. Commit it. It is to providers what package-lock.json or go.sum is to libraries (a lockfile), so your teammates and CI get the same builds.

References decide the order

Notice that local_file.app_config mentions random_pet.server.id. The file's content can't be known until the name exists, so Terraform must create the pet first. It finds these references in every block and draws a dependency graph: a directed acyclic graph (DAG) where an arrow means "needs".

You never write the order yourself. terraform graph prints the graph Terraform worked out. Here's the real output for our file:

terminal
$ terraform graph
digraph G {
  rankdir = "RL";
  node [shape = rect, fontname = "sans-serif"];
  "local_file.app_config" [label="local_file.app_config"];
  "random_pet.server" [label="random_pet.server"];
  "terraform_data.release" [label="terraform_data.release"];
  "local_file.app_config" -> "random_pet.server";
}

The output is in DOT, the text format of a drawing tool called Graphviz; terraform graph | dot -Tpng > graph.png turns it into a picture. The first lines only set up the drawing and list the three resources. The line that matters is the one with ->: local_file.app_config -> random_pet.server means "the file depends on the pet". terraform_data.release has no arrow, because it needs nothing.

Things that don't depend on each other are built at the same time. In our real apply, Terraform started on terraform_data.release and random_pet.server straight away (neither needs anything), and the file waited until the pet existed:

terminal (from a real terraform apply)
terraform_data.release: Creating...
terraform_data.release: Creation complete after 0s [id=0d974f96-455f-5fc5-8467-895229e1392e]
random_pet.server: Creating...
random_pet.server: Creation complete after 0s [id=natural-moccasin]
local_file.app_config: Creating...
local_file.app_config: Creation complete after 0s [id=61c13a4fb4941c66f6031b6bab0af649c6b2a13c]

With a real cloud this matters a lot: a network, a database and a bucket might each take minutes, and building them side by side saves most of that time. Terraform runs up to 10 operations at once by default (the -parallelism flag changes it). Deleting runs the graph backwards: the server goes before the network it lives in.

Here's a bigger, AWS-shaped graph. Step through an apply, then a destroy:

The dependency graph, wave by wave
Each box’s “needs” come from references in its block, like aws_vpc.main.id. Terraform starts everything whose needs are met, in parallel.
depth 0
aws_vpc.main
needs nothing
depth 1
aws_subnet.app
needs vpc.main
aws_subnet.data
needs vpc.main
aws_security_group.app
needs vpc.main
depth 2
aws_db_instance.main
needs subnet.data, security_group.app
depth 3
aws_instance.web
needs subnet.app, security_group.app, db_instance.main
depth 4
aws_route53_record.www
needs instance.web
vpc = your private network · subnet = a slice of it · security_group = firewall rules · db_instance = a managed database · instance = a virtual machine · route53_record = a DNS name (see Cloud Basics)
Nothing exists. Each box lists what it needs. Press “Next wave”.

The same blocks scale to real clouds. Cloud Basics §11 shows a VPC, a subnet and a security-group rule written exactly this way, with aws_vpc.main.id references linking them.

Practitioner§04

Plan, apply, destroy

In one sentenceterraform plan prints a diff between what you asked for and what exists. terraform apply carries it out. Reading the plan is the job; typing yes is the easy part.

The first plan

After terraform init, run terraform plan on the file from §03. Nothing exists yet, so everything gets a +. Here's the real output (we cut the six content_… checksum lines the local provider adds, to save space):

terminal
$ terraform plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.app_config will be created
  + resource "local_file" "app_config" {
      + content              = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0644"
      + filename             = "./out/app.env"
      + id                   = (known after apply)
    }

  # random_pet.server will be created
  + resource "random_pet" "server" {
      + id        = (known after apply)
      + length    = 2
      + separator = "-"
    }

  # terraform_data.release will be created
  + resource "terraform_data" "release" {
      + id     = (known after apply)
      + input  = "v1"
      + output = (known after apply)
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + server_name = (known after apply)

A plan lists every resource it will touch, with each setting. (known after apply) means "this value doesn't exist until the thing is created". The pet's random name is only picked during apply, so the file content that uses it isn't known yet either. Settings you didn't write, like directory_permission = "0777", are the provider's defaults.

The last line is the one to read first. 3 to add, 0 to change, 0 to destroy is what you expected for a brand-new setup. If it ever says destroy when you only meant to add something, stop.

Apply, then plan again

terraform apply makes a fresh plan, shows it, and waits for you to type yes. Then it makes the calls (the output is in §03) and saves the result. Run plan again straight away:

terminal
$ terraform plan
terraform_data.release: Refreshing state... [id=0d974f96-455f-5fc5-8467-895229e1392e]
random_pet.server: Refreshing state... [id=natural-moccasin]
local_file.app_config: Refreshing state... [id=61c13a4fb4941c66f6031b6bab0af649c6b2a13c]

No changes. Your infrastructure matches the configuration.

The Refreshing state... lines are Terraform checking each real resource still looks the way it left it (§05 explains how). Everything matches, so there's nothing to do.

The four symbols

SymbolMeansExample
+createa new resource in the file
~update in placechange a server's tags; the server keeps running
-/+replace: destroy the old one, then create a new onea setting that can't be changed on an existing thing
-destroyyou deleted the block from the file

There's a fifth, +/-, which is a replace done in the other order (create the new one first). §10 shows when you'd want that.

An in-place update (~)

Change input = "v1" to "v2" in the terraform_data block:

terminal
$ terraform plan
  …
  # terraform_data.release will be updated in-place
  ~ resource "terraform_data" "release" {
        id     = "0d974f96-455f-5fc5-8467-895229e1392e"
      ~ input  = "v1" -> "v2"
      ~ output = "v1" -> (known after apply)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

~ marks the resource and each changed setting, with old -> new. The id line has no symbol: it stays the same, because it's the same object, changed.

A replacement (-/+)

Now change the variable's default from 8080 to 9090. That changes the file's content (checksum lines cut again):

terminal
$ terraform plan
  …
  # local_file.app_config must be replaced
-/+ resource "local_file" "app_config" {
      ~ content              = <<-EOT # forces replacement
            SERVER=natural-moccasin
          - PORT=8080
          + PORT=9090
        EOT
      ~ id                   = "61c13a4fb4941c66f6031b6bab0af649c6b2a13c" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

<<-EOT … EOT is HCL's way of writing a multi-line string (a heredoc). Inside it, the - and + lines are a line-by-line diff of the file's content. # forces replacement names the setting that can't be changed on the existing object. The local provider is written that way for every setting: any change deletes the file and writes a new one. Real clouds have the same rule for some settings and not others. On AWS, for example, you can change a server's size (its instance_type) in place, but changing its ami, the machine image it boots from, or the zone it lives in, means a new server. The widget at the end of this section uses exactly those two settings.

Saving the plan you reviewed

A plain terraform apply makes a new plan, which might differ from the one you read a minute ago. To apply exactly what you reviewed, save it with -out and apply the file:

terminal
$ terraform plan -out=tfplan
  …(same plan as above)…
Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

$ terraform apply tfplan
local_file.app_config: Destroying... [id=61c13a4fb4941c66f6031b6bab0af649c6b2a13c]
local_file.app_config: Destruction complete after 0s
local_file.app_config: Creating...
local_file.app_config: Creation complete after 0s [id=461ee33c25086279fcd9abd3641b6d55154577c4]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Applying a saved plan doesn't ask for yes: you already said yes by reviewing it. That's how CI pipelines apply changes (§12). Note the order in the output: destroy first, then create.

Destroy

terraform destroy plans a - for everything in the state and, after a yes, deletes it all in reverse dependency order:

terminal
$ terraform destroy
  …
Plan: 0 to add, 0 to change, 3 to destroy.
  …
local_file.app_config: Destroying... [id=461ee33c25086279fcd9abd3641b6d55154577c4]
local_file.app_config: Destruction complete after 0s
random_pet.server: Destroying... [id=natural-moccasin]
random_pet.server: Destruction complete after 0s

Destroy complete! Resources: 3 destroyed.

(We trimmed the terraform_data lines.) Great for throwaway test environments. In production you'll almost never run it; deleting a block from the file and applying removes just that one thing.

Try it: be the reviewer

Change the wanted settings in the code panel. The plan panel updates like terraform plan would. (The short legend under the code panel says what each setting means.) Read it, then apply and watch the "real world" catch up.

Be the reviewer
Edit the code, read the plan Terraform would print, then apply it.
code · main.tf
A web server, a managed database and a storage bucket on AWS (illustrative).
web.instance_type
web.ami
db.allocated_storage
bucket logs
  • instance_type: the server's size (CPU and memory)
  • ami: the machine image the server boots from; a new image means a new server
  • allocated_storage: the database's disk size in GB
  • logs: an S3 bucket (cloud file storage)
$ terraform plan
No changes. Your infrastructure matches the configuration.
real world
🖥 web · t3.micro · ami-v1
🗄 db · 20 GB
🪣 acme-logs
Everything is applied and matches. Change a setting in the code panel.
Practitioner§05

State: Terraform's memory

In one sentenceThe state file is Terraform's notebook: for every resource in your code, it records which real object it created and what that object looked like.

What's inside

After the first apply, a file called terraform.tfstate appears next to main.tf. It's plain JSON. Here's the real one, cut down to the interesting parts:

terraform.tfstate (trimmed)
{
  "version": 4,
  "terraform_version": "1.17.1",
  "serial": 4,
  "lineage": "79956eff-d6b5-0682-022e-81c036d9ffc6",
  "outputs": { "server_name": { "value": "natural-moccasin", "type": "string" } },
  "resources": [
    {
      "mode": "managed",
      "type": "local_file",
      "name": "app_config",
      "instances": [{
        "attributes": {
          "content": "SERVER=natural-moccasin\nPORT=8080\n",
          "file_permission": "0644",
          "filename": "./out/app.env",
          "id": "61c13a4fb4941c66f6031b6bab0af649c6b2a13c"
        },
        "dependencies": ["random_pet.server"]
      }]
    },
    {
      "mode": "managed",
      "type": "random_pet",
      "name": "server",
      "instances": [{ "attributes": { "id": "natural-moccasin", "length": 2, "separator": "-" } }]
    }
  ]
}

Each entry joins an address from your code (type + name, here local_file.app_config) to a real ID (61c13a4f…) and the last-known value of every setting. dependencies remembers the graph from §03, so Terraform can still delete things in the right order after you've removed them from the code. serial goes up by one on every write, and lineage is a random ID given to this state when it was first created. Commands that upload a whole state file (terraform state push, or moving state to a new backend) check them, and refuse to overwrite a newer state with an older one, or with a state from a different project. They don't stop two applies that run at the same time from overwriting each other; that's the lock's job (§09).

Why Terraform can't work without it

  1. Which objects are mine? An AWS account might have 40 servers. The state says which three belong to aws_instance.web. Without it, Terraform would have to guess, or touch everything.
  2. Some things can't be looked up. There's no API to ask "what random name did you pick last Tuesday?". The state is the only place natural-moccasin is written down.
  3. Deletes. When you remove a block from the code, the code no longer mentions that object at all. Only the state remembers that it exists and must be deleted.
  4. Speed. For thousands of resources, the state is a quick index of IDs to check, instead of listing everything in the account.

How a plan is made: three inputs

Every terraform plan compares three things:

  • The code: what you want.
  • The state: what Terraform did last time.
  • The real world: what's actually there now. Terraform asks the provider to read each object in the state again. That's the refresh step, the Refreshing state... lines you saw in §04.

Then it evaluates the code against the refreshed reality, diffs the two, and prints the result. Step through it, in the order Terraform does it:

Inside terraform plan
Three inputs (code, state, the real world), one diff. Step with → or press play.
Code
1variable "port" {
2 default = 9090
3}
4
5resource "local_file" "app_config" {
6 filename = "out/app.env"
7 content = "SERVER=${random_pet.server.id}\nPORT=${var.port}\n"
8}
1 · State (last time)
local_file.app_config
id = 61c13a4f…
PORT=8080
2 · Real world (refresh)
not checked yet
3 · Wanted (your code)
not evaluated yet
4 · Plan
nothing yet
You've just changed the default port in the code from 8080 to 9090 and run terraform plan. After parsing the files, Terraform opens the state (§05): last time it created local_file.app_config with ID 61c13a4f… and content PORT=8080. That's the ticket saying which real object is ours.
1 / 8

Looking at state safely

Don't open the JSON in an editor to change it. Use the commands, which read it for you:

terminal
$ terraform state list
local_file.app_config
random_pet.server
terraform_data.release

$ terraform state show random_pet.server
# random_pet.server:
resource "random_pet" "server" {
    id        = "natural-moccasin"
    length    = 2
    separator = "-"
}

Renaming or forgetting things has proper tools too: moved and removed blocks (§13), and import for adopting things (§07).

When state goes wrong

#1

Lost state

Delete terraform.tfstate and Terraform believes nothing exists. The next plan says 3 to add. For our files that's mostly harmless (it rewrites them). For a cloud, it tries to create a second copy of everything: duplicate servers you pay for, or errors like "bucket already exists". Recovery means importing every object back (§07). This is why state lives in safe, versioned storage (§09).

#2

State in git

It's tempting to commit terraform.tfstate so the team shares it. Don't: two people apply from different copies and overwrite each other's changes, and the file contains secrets in plain text (§11). Add *.tfstate and *.tfstate.backup to .gitignore and use a remote backend (§09).

#3

Hand edits

Editing the JSON by hand to "fix" something is how states get corrupted. Terraform keeps the previous version as terraform.tfstate.backup after each write, which can save you once. Remote backends with versioning (§09) are the real safety net.

Practitioner§06

Variables, outputs and loops

In one sentenceIf a configuration were a function, variables would be its parameters, outputs its return values, and for_each its loop.

Variables: the inputs

An input variable lets the same code build slightly different things: a small server in dev, a big one in prod. Declare it with a type, and optionally a default, a description and a rule:

main.tf
variable "port" {
  type        = number
  default     = 8080
  description = "Port the app listens on"

  validation {
    condition     = var.port >= 1024 && var.port <= 65535
    error_message = "Use an unprivileged port between 1024 and 65535."
  }
}

locals {
  listen = "0.0.0.0:${var.port}"   # ← a named value computed from others
}

output "listen" {
  value = local.listen
}

The validation runs during plan, so a bad value never reaches the cloud:

terminal
$ terraform plan -var port=80

Planning failed. Terraform encountered an error while generating this plan.

Error: Invalid value for variable

  on main.tf line 1:
   1: variable "port" {
    ├────────────────
    │ var.port is 80

Use an unprivileged port between 1024 and 65535.

Giving variables values

From weakest to strongest (a later one wins):

  1. the default in the block;
  2. an environment variable named TF_VAR_port (handy in CI);
  3. a file called terraform.tfvars, loaded automatically if it exists;
  4. any files ending in .auto.tfvars, also loaded automatically (in alphabetical order);
  5. -var-file=prod.tfvars or -var port=9000 on the command line.

A .tfvars file is just name = value lines:

terminal
$ cat dev.tfvars
port = 9000

$ terraform plan -var-file=dev.tfvars
  …
Changes to Outputs:
  + listen = "0.0.0.0:9000"

If a variable has no default and no value from anywhere, terraform plan stops and asks you to type one.

Outputs: the results

An output value prints a value after apply and makes it available to scripts, CI, or another configuration (a module, §08):

terminal
$ terraform output
server_name = "natural-moccasin"

$ terraform output -raw server_name     # ← no quotes, for scripts
natural-moccasin

terraform output -json gives all outputs as JSON, which is easy to read from a script.

Reading things you don't manage: data sources

Sometimes you need a value that exists already but isn't yours to create: the newest Ubuntu image ID, your account number, a network another team owns. A data block reads it without managing it; this is a data source:

illustrative AWS example (not applied)
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]   # Canonical, who publish Ubuntu
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id   # ← read, not created
  instance_type = "t3.micro"
}

Terraform never creates or deletes what a data block reads. It just looks it up on every plan.

Many copies: count and for_each

To make several similar resources, add count (a number) or for_each (a set or map). Both give you one resource per item, but they name the copies differently, and that matters a lot.

main.tf
variable "servers" {
  type    = list(string)
  default = ["web", "api", "worker"]
}

resource "local_file" "by_count" {
  count    = length(var.servers)                         # 3 copies: [0], [1], [2]
  filename = "${path.module}/out/count-${count.index}.txt"
  content  = var.servers[count.index]
}

resource "local_file" "by_each" {
  for_each = toset(var.servers)                          # 3 copies: ["web"], ["api"], ["worker"]
  filename = "${path.module}/out/each-${each.key}.txt"
  content  = each.key
}

Now remove "api" from the middle of the list and plan. Here's the real summary:

terminal
$ terraform plan -var 'servers=["web","worker"]'
  …
  # local_file.by_count[1] must be replaced
      ~ content              = "api" -> "worker" # forces replacement
  # local_file.by_count[2] will be destroyed
  # (because index [2] is out of range for count)
  # local_file.by_each["api"] will be destroyed
  # (because key ["api"] is not in for_each map)

Plan: 1 to add, 0 to change, 3 to destroy.

The count copies are numbered by position. Removing item 1 shifts "worker" from position 2 to position 1, so Terraform thinks copy [1] changed from api to worker (replace it) and copy [2] is no longer wanted (destroy it). Two changes for one removal. The for_each copies are named by key, so removing "api" removes exactly ["api"].

With files that's untidy. With servers, it means a perfectly healthy worker server gets changed because of an unrelated edit, or even destroyed and rebuilt if its name feeds a setting that forces replacement. Try it:

Remove one item, count the damage
Edit the list of servers, then compare the plans for count (by position) and for_each (by key).
var.servers ="web""api""worker"
count = length(var.servers)0 replaced · 0 touched
[0] "web"unchanged
[1] "api"unchanged
[2] "worker"unchanged
for_each = toset(var.servers)0 replaced · 0 touched
["web"]unchanged
["api"]unchanged
["worker"]unchanged
The plan is empty. Try removing "api" (the middle item).
Practitioner§07

Drift and import

In one sentenceDrift is when reality no longer matches the code, because someone changed it by hand. Terraform spots it on the next plan and, unless you act, undoes the hand change on the next apply.

How drift happens

It's 2 a.m., the site is slow, and someone raises a database's size in the console to stop the bleeding. It works. Nobody updates the code. A week later, a colleague applies an unrelated change, and the plan quietly includes ~ instance_class = "db.r6g.2xlarge" -> "db.r6g.large". If nobody reads it carefully, the hotfix is reverted and the slowdown comes back.

That gap between code and reality is configuration drift. It comes from console clicks, scripts, other tools, or the cloud itself (some services change settings during maintenance).

Drift for real

Starting from the applied example (port 9090), we edit the managed file by hand, the way someone might "just quickly" tweak a server:

terminal
$ echo 'DEBUG=true' >> out/app.env       # ← a change made outside Terraform
$ cat out/app.env
SERVER=natural-moccasin
PORT=9090
DEBUG=true

The next plan refreshes every resource (§05), notices, and plans to put things back (checksum lines cut):

terminal
$ terraform plan
  …
  # local_file.app_config will be created
  + resource "local_file" "app_config" {
      + content              = <<-EOT
            SERVER=natural-moccasin
            PORT=9090
        EOT
      + directory_permission = "0777"
      + file_permission      = "0644"
      + filename             = "./out/app.env"
      + id                   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

$ terraform apply      # …yes
$ cat out/app.env
SERVER=natural-moccasin
PORT=9090

The DEBUG=true line is gone. Two details are worth noticing:

  • The local provider identifies a file by a checksum of its content, so a changed file looks like "the file I made is gone" and the plan says create. Cloud providers usually report it as a changed setting instead: ~ update in-place with the setting going back to what the code says.
  • Drift is only visible in settings the provider reads back. We also tried chmod 600 out/app.env: the plan said No changes, because the local provider doesn't check permissions. Every provider has blind spots like this.

Just looking: refresh-only

To see what changed outside Terraform without planning to undo it, use -refresh-only:

terminal
$ terraform plan -refresh-only
  …
Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

  # local_file.app_config has been deleted
  - resource "local_file" "app_config" {
  …
This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.

terraform apply -refresh-only accepts the real world into the state and changes no real resource. That's rarely the full fix, though: the code still says the old thing, so the next normal plan would revert it anyway.

Fixing drift: decide which side is right

Every drift is a question: was the hand change right?

  • No (someone's experiment, a mistake): run a normal apply. The code wins, reality is put back.
  • Yes (the 2 a.m. hotfix): change the code to match reality, open a pull request, and apply. The plan should now say No changes. That's how you know code and reality agree again.

Play both sides. Make a hand change in the "real world" panel, see how the plan reacts, then either apply (code wins) or "update the code" (reality wins):

Drift: someone changed it by hand
Make a hand change in the real-world panel, read the plan, then decide which side wins.
code · main.tf
A web server, a managed database and a storage bucket on AWS (illustrative).
web.instance_type
web.ami
db.allocated_storage
bucket logs
  • instance_type: the server's size (CPU and memory)
  • ami: the machine image the server boots from; a new image means a new server
  • allocated_storage: the database's disk size in GB
  • logs: an S3 bucket (cloud file storage)
web.tags.owner
$ terraform plan
No changes. Your infrastructure matches the configuration.
real world
🖥 web · t3.micro · ami-v1
🗄 db · 20 GB
🪣 acme-logs
console clicks
Everything is applied and matches. Now make a change by hand in the real world.

Catching drift early

Drift is cheapest to deal with the day it happens. Many teams run a scheduled plan (nightly, say) in CI and alert when it isn't empty. -detailed-exitcode makes that easy, because the exit code tells you the answer:

terminal
$ terraform plan -detailed-exitcode > /dev/null; echo "exit=$?"
exit=0          # ← no changes
$ rm out/app.env
$ terraform plan -detailed-exitcode > /dev/null; echo "exit=$?"
exit=2          # ← changes pending: drift (or unapplied code)

Exit code 0 means no changes, 1 means an error, 2 means the plan has changes. ($? is the shell's variable for the last command's exit code; CI/CD §04 explains why exit codes drive pipelines.)

The best fix is to make hand changes rare: give people read-only console access to production, and keep the write permissions for the pipeline that runs Terraform.

Import: adopting things that already exist

Drift's cousin: resources that were never in Terraform. Maybe the team clicked a bucket together before adopting IaC. If you just write a resource block for it, Terraform will try to create a second one. Instead, you import it: tell Terraform the real ID, and it records the object in the state.

Since Terraform 1.5 you do this with an import block, which goes through the normal plan-and-review flow. Here we adopt an existing random string (standing in for something like a bucket) and let Terraform write the resource block for us:

import.tf
import {
  to = random_string.bucket_suffix   # ← the address it should have in your code
  id = "k3x9q2"                      # ← the real object's ID
}
terminal
$ terraform plan -generate-config-out=generated.tf
random_string.bucket_suffix: Preparing import... [id=k3x9q2]
random_string.bucket_suffix: Refreshing state... [id=k3x9q2]

Terraform will perform the following actions:

  # random_string.bucket_suffix will be imported
  # (config will be generated)
    resource "random_string" "bucket_suffix" {
        id          = "k3x9q2"
        length      = 6
        …
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

Warning: Config generation is experimental
  …
$ terraform apply
  …
random_string.bucket_suffix: Importing... [id=k3x9q2]
random_string.bucket_suffix: Import complete [id=k3x9q2]

Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

$ terraform plan
  …
No changes. Your infrastructure matches the configuration.

-generate-config-out writes a starting resource block into generated.tf by reading the real object. Tidy it up (it includes every default, like keepers = null) and move it into your main files. The goal is the last line: after import, plan says no changes. If it shows a diff, your block doesn't describe the real object yet; edit until it doesn't.

For a real cloud the import block looks the same; only the ID format differs per resource type (each type's docs say what to use):

illustrative AWS example (not applied)
import {
  to = aws_s3_bucket.photos
  id = "acme-photos-prod"     # for buckets, the ID is the bucket name
}

Before Terraform 1.5 there was only the terraform import ADDRESS ID command, which changes the state immediately, with no plan to review. You'll still see it in older guides. Some tools (for example Terraformer, or a cloud's own export feature) can generate code for a whole account at once, but expect a lot of cleanup.

Practitioner§08

Modules: functions for infrastructure

In one sentenceA module is a folder of .tf files you call like a function: pass it inputs, it creates resources, it hands back outputs.

The problem

Your app needs the same three things in dev and prod: a server name, a config file, maybe a firewall rule. You could copy the blocks and rename them dev_… and prod_…. Then someone fixes a bug in the prod copy and forgets dev. Same reason you don't copy-paste functions.

Writing one

A module is just a folder. Its variable blocks are the parameters, its output blocks the return values:

layout
.
├── main.tf                     # ← the root module: where you run terraform
└── modules/
    └── app_server/             # ← a child module
        ├── variables.tf
        ├── main.tf
        └── outputs.tf
variable "env" {
  type        = string
  description = "Environment name, e.g. dev or prod"
}

variable "port" {
  type    = number
  default = 8080
}

(path.root is the folder you ran terraform in, so both environments write under one out/ folder.)

Calling it

In the root main.tf, a module block calls it. source says where the folder is; the other arguments fill its variables:

main.tf
module "dev" {
  source = "./modules/app_server"
  env    = "dev"
}

module "prod" {
  source = "./modules/app_server"
  env    = "prod"
  port   = 443                  # ← override the default
}

output "servers" {
  value = [module.dev.server_name, module.prod.server_name]   # ← read a module's output
}

terraform init notices the modules (- dev in modules/app_server, - prod in modules/app_server), then apply creates two copies of everything:

terminal
$ terraform apply
  …
module.prod.random_pet.name: Creation complete after 0s [id=prod-above-arachnid]
module.dev.random_pet.name: Creation complete after 0s [id=dev-intent-eagle]
module.prod.local_file.config: Creation complete after 0s [id=2f454a16af14cd087530996eb9f0c975091a8efd]
module.dev.local_file.config: Creation complete after 0s [id=64015dd1cb71afeb5fa5370959bbe0dbfd44ecdf]

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

servers = [
  "dev-intent-eagle",
  "prod-above-arachnid",
]

$ cat out/prod/app.env
SERVER=prod-above-arachnid
PORT=443

$ terraform state list
module.dev.local_file.config
module.dev.random_pet.name
module.prod.local_file.config
module.prod.random_pet.name

Resources inside a module get the module's name in front of their address: module.prod.local_file.config. A module can only see what you pass in, and the caller can only see what the module outputs. That boundary is what makes it reusable.

A module call is a function call
env = "prod"
port = 443
→ variables →
modules/app_server
random_pet.name
local_file.config
→ outputs →
server_name =
"prod-above-arachnid"
Inputs flow in through variables, outputs flow back. Everything in between is private to the module.

Modules from elsewhere

source doesn't have to be a local folder. It can be a git repository or a module on the public Terraform Registry, where the community publishes ready-made modules (a whole VPC with subnets in every zone, say):

illustrative AWS example (not applied)
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"   # ← from the public registry
  version = "~> 6.0"                          # ← always pin

  name            = "main"
  cidr            = "10.0.0.0/16"
  azs             = ["eu-west-1a", "eu-west-1b"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}

# a git source pins a tag instead:
# source = "git::https://github.com/acme/tf-modules.git//app_server?ref=v1.4.0"

This saves weeks. The concepts behind those arguments (subnets, zones) are in Cloud Basics §04. But a module is someone else's code that creates real things in your account, so treat it like any dependency:

  • Pin the version. Without version (or ?ref= for git), the next terraform init -upgrade can pull a new major version that plans to replace half your network.
  • Read the plan after every version bump, as you would for your own changes.
  • Don't over-wrap. A module that just renames the arguments of one resource adds a layer without hiding anything. Good modules package a meaningful unit ("a service with its database, alarms and permissions") behind a few well-chosen inputs.
Practitioner§09

Remote state and locking

In one sentenceA team needs one copy of the state, kept somewhere everyone can reach, with a lock so only one person changes it at a time.

Why a local file stops working

So far the state was terraform.tfstate on your laptop. Now a teammate wants to apply. They don't have your file, so their Terraform thinks nothing exists and plans to create everything again. Copying the file around (or committing it to git) leads to the next problem: two people with two copies, each overwriting the other's changes.

Remote backends

A backend is where Terraform keeps the state. The default local backend is the file on disk. A remote backend keeps it in shared storage. The common ones are an S3 bucket (AWS), a Cloud Storage bucket (GCP), Azure Blob Storage, or HCP Terraform (HashiCorp's hosted service). You configure it once, inside the terraform block:

backend.tf
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "app/terraform.tfstate"   # ← one key per state file
    region       = "eu-west-1"
    use_lockfile = true                      # ← Terraform 1.11+: lock with a file in the bucket
  }
}

After terraform init there's no terraform.tfstate on your disk. Every plan and apply downloads the state from the bucket, and every apply uploads the new version.

We ran exactly this against a local S3 emulator (moto, the one Cloud Basics uses), with a few extra settings that point the backend at http://127.0.0.1 instead of AWS. terraform state pull prints the remote state:

terminal
$ terraform state pull | head -6
{
  "version": 4,
  "terraform_version": "1.17.1",
  "serial": 1,
  "lineage": "d61f992a-cc53-2764-716d-697f0d04e628",
  "outputs": {},

The bucket itself has to exist before init can use it: chicken and egg. Teams create it once by hand, or with a tiny configuration that starts with local state; once the bucket exists, they add the backend block and run terraform init -migrate-state to move that state into it.

Two applies at once

Remote state fixes "who has the file". It creates a new problem: two people can now apply at the same time. Both download state version 5, both make changes, both upload. The second upload overwrites the first, and the state forgets resources that really exist. It's the classic lost update race (Race Conditions §05 walks through it slowly).

The fix is a state lock: before changing anything, Terraform creates a lock; if a lock already exists, it stops. Toggle locking off and on, then start both applies:

Two applies, one state
Step through Alice's and Bob's runs. Then switch locking on and replay.
Alice's terraform apply
idle
Bob's terraform apply
idle
State bucket
state v5: vpc, app
Real cloud resources
vpc
app
Alice wants to add a database; Bob wants to add a cache. Both run terraform apply at almost the same moment. Locking is off.
1 / 7

Locking for real

The local backend locks too, using the operating system's file locks. We started a slow apply (a resource with an 8-second sleep in it), then ran a second apply two seconds later in the same folder:

terminal (second apply)
$ terraform apply -auto-approve

Error: Error acquiring the state lock

Error message: resource temporarily unavailable
Lock Info:
  ID:        3ea57e70-83be-483f-1867-b3d13c96154d
  Path:      terraform.tfstate
  Operation: OperationTypeApply
  Who:       root@vm
  Version:   1.17.1
  Created:   2026-10-30 22:53:10.076473026 +0000 UTC
  Info:

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. …

Then the same race against the S3 backend on the emulator. With use_lockfile = true, the first apply writes an object called app/terraform.tfstate.tflock next to the state. It uses a conditional write: "create this object only if it doesn't exist yet". The second apply's conditional write fails, and S3 answers 412 Precondition Failed:

terminal (second apply, S3 backend)
Error: Error acquiring the state lock

Error message: operation error S3: PutObject, https response error
StatusCode: 412, …
api error PreconditionFailed: At least one of the pre-conditions you
specified did not hold
Lock Info:
  ID:        7a0218c0-9436-ceb6-ebef-4ed902588d05
  Path:      tfstate-demo/app/terraform.tfstate
  Operation: OperationTypeApply
  Who:       root@vm
  …

While the first apply ran, listing the bucket showed app/terraform.tfstate.tflock. When it finished, only app/terraform.tfstate was left: the lock is deleted at the end of every run.

Who and Created are there so you can find the person (or CI job) holding the lock and ask them. Two flags help:

  • -lock-timeout=5m waits up to five minutes for the lock instead of failing at once. Good for CI, where jobs can queue.
  • terraform force-unlock LOCK_ID deletes a lock left behind by a run that crashed (a laptop that went to sleep mid-apply). Only use it when you're sure nothing is running: unlocking under a live apply brings the race right back.

Protecting the bucket

The state bucket is now one of the most important things you own. Treat it that way:

  • Turn on versioning. Every apply writes a new version, so a corrupted or wrongly overwritten state can be rolled back to yesterday's.
  • Encrypt it and restrict who can read it. State contains secrets (§11) and a full map of your infrastructure. Use encryption at rest and give read access only to the people and pipelines that run Terraform.
  • Block public access, and keep the bucket in a separate, locked-down account if you can.
  • Split it up. One key per environment and component (prod/network, prod/app, staging/app) means a lock on the app doesn't block network changes, and a mistake in one can't damage the others (§13).
Practitioner§10

Immutable infrastructure

In one sentenceDon't change a running server. Build a new one from a tested image, switch traffic to it, and delete the old one.

Two ways to update 20 servers

Mutable (patch in place). A tool logs into each running server and changes it: install the new app version, edit a config file, upgrade a library. Tools like Ansible, Chef and Puppet work this way. It's fast, but each server's history is different. Server 7 missed one run because it was rebooting, and now it's subtly different from the other 19. Over months, every server drifts in its own way.

Immutable (replace). You build a new image with the new version baked in, test that image, then start new servers from it and delete the old ones. No server is ever changed after it boots, so all 20 are identical, and identical to what you tested. That's immutable infrastructure.

Three releases, two styles
mutable: one server, patched again and again
server-7
v1
patch
v1 + patch
patch
v1 + 2 patches ?
immutable: a fresh server per release
img v1
v1
img v2
v2
img v3
v3
Mutable servers collect changes over time (each orange “patch” is a tool logging in and changing the running server); immutable ones are thrown away on each release. Rolling back the immutable way means starting servers from the previous image again.

What it looks like in practice

  1. Build an image. A golden image has the OS, your app and its settings already installed. For VMs that's a machine image built with a tool like Packer; for containers it's a container image built from a Dockerfile (Containers covers that).
  2. Point the code at it. The image ID is a variable. Changing it is a one-line pull request.
  3. Apply. Changing the image a server boots from forces replacement (§04): the plan shows -/+. Terraform starts new servers from the new image and deletes the old ones.

Containers made this the default. Nobody sshes into a container to upgrade it; you build a new image and the platform replaces the containers. For Kubernetes or a container platform, Terraform usually creates the cluster or service, and the deploy pipeline swaps the image (Deployment Strategies covers rolling, blue-green and canary releases).

Replace without a gap: create_before_destroy

By default a replace (-/+) destroys first, then creates. For a server behind a load balancer, that's a moment with no server at all. Add a lifecycle block to flip the order:

main.tf
resource "local_file" "app_config" {
  # …same as before…

  lifecycle {
    create_before_destroy = true   # ← build the new one, then delete the old one
  }
}

The plan symbol changes from -/+ to +/-, and the real apply does it in that order:

terminal
$ terraform apply -var port=7070
  …
+/- create replacement and then destroy
  …
local_file.app_config: Creating...
local_file.app_config: Creation complete after 0s [id=d2ccfa96271afe4613e02262aabfbade03733f17]
local_file.app_config (deposed object dae4195f): Destroying... [id=461ee33c25086279fcd9abd3641b6d55154577c4]
local_file.app_config: Destruction complete after 0s

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

$ ls out/
$                       # ← empty!

While both exist, Terraform calls the old one the deposed object. And then something went wrong: out/ is empty. Both the old and the new object were the same path, out/app.env. Terraform wrote the new file (overwriting the old one), then "deleted the old file", which was the same path, so it deleted the new one.

That's the classic create_before_destroy trap, and it's not just about files. Two things can't exist at once if they need the same unique name. On a real cloud you'd usually get an error instead ("a security group named app already exists"), and the apply stops halfway. The fix: let names vary. Many AWS resources have a name_prefix argument, so Terraform adds a random suffix and the old and new copies can live side by side.

Staff-level§11

Secrets in state

In one sentenceAny value a resource stores goes into the state in plain text. sensitive = true only hides it on screen. Protect the state like a password file, and keep secrets out of it where you can.

Seeing it happen

A common pattern: let Terraform generate a database password. Here's a password resource and an output for it, marked sensitive:

main.tf
resource "random_password" "db" {
  length  = 20
  special = false
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true          # ← hide it in terminal output
}

The apply output behaves:

terminal
$ terraform apply
  …
Changes to Outputs:
  + db_password = (sensitive value)
  …
Outputs:

db_password = <sensitive>

Now look in the state file and ask for the output by name:

terminal
$ grep '"result"' terraform.tfstate
            "result": "n60AvyzIG3tpuyvGjyct",

$ terraform output db_password
"n60AvyzIG3tpuyvGjyct"

There it is, in plain text. Terraform has to store it: the state is its memory (§05), and next time it needs to know whether the password changed. The same is true for a database's password argument, a private key from the tls provider, or an API token passed to any resource.

sensitive = true means "don't print this in plan or apply output", so it doesn't end up in CI logs. It doesn't encrypt anything, and anyone who can read the state can read the secret. (General advice on where secrets leak is in Secrets §03.)

Layer 1: protect the state

Whatever else you do, assume the state contains secrets:

  • Remote backend only, never git (§09).
  • Encryption at rest on the bucket, ideally with a key in a KMS (key management service) that only the Terraform role can use.
  • Read access only for the pipeline and a few admins, following least privilege. A developer who can read the state bucket can read every database password in it.
  • OpenTofu (1.7+) can also encrypt the state on the client before it's uploaded, so the storage only ever sees ciphertext. Terraform itself relies on the backend's encryption.

Layer 2: keep secrets out of state

Newer Terraform versions add ways to use a secret without storing it:

An ephemeral value is produced and used during one run and never saved. ephemeral blocks look like resources, but Terraform refuses to let their values reach anything that would be stored. We tried to write one into a file:

main.tf
ephemeral "random_password" "db" {
  length = 20
}

resource "local_file" "leak" {
  filename = "${path.module}/out/pw.txt"
  content  = ephemeral.random_password.db.result
}
terminal
$ terraform plan

Error: Invalid use of ephemeral value

  with local_file.leak,
  on main.tf line 7, in resource "local_file" "leak":
   7:   content  = ephemeral.random_password.db.result

Ephemeral values are not valid for "content", because it is not a write-only
attribute and must be persisted to state.

So where can it go? Into a write-only argument: a setting the provider sends to the API and Terraform never saves. Providers add these to arguments like database passwords, with a _wo suffix. Here's how the pieces fit for an AWS database:

illustrative AWS example (Terraform 1.11+, AWS provider v6; not applied)
ephemeral "random_password" "db" {
  length = 24
}

resource "aws_db_instance" "main" {
  identifier          = "app-db"
  engine              = "postgres"
  instance_class      = "db.t4g.micro"
  allocated_storage   = 20
  username            = "app"
  password_wo         = ephemeral.random_password.db.result   # ← sent, never stored
  password_wo_version = 1                                     # ← bump to send a new one
}

Because Terraform keeps no copy, it can't tell when the password "changed". You bump password_wo_version to say "send a new one now". The app still needs the password, so in practice you'd also write it to a secrets manager from the same ephemeral value.

Layer 3: don't touch the secret at all

The cleanest option is often to let the cloud create and keep the secret, so Terraform only ever handles a reference to it:

illustrative AWS example (not applied)
resource "aws_db_instance" "main" {
  # …
  manage_master_user_password = true   # ← AWS generates it and stores it in Secrets Manager
}

output "db_secret_arn" {
  value = aws_db_instance.main.master_user_secret[0].secret_arn   # a pointer, not the password
}

The app reads the password from Secrets Manager at startup with its own role, and AWS can rotate it without Terraform. The same idea applies elsewhere: give workloads roles instead of keys (Cloud Basics §06), and for anything else use a secrets manager (Secrets §06).

Staff-level§12

Plans in pull requests

In one sentenceThe code diff says what you meant; the plan says what will happen. Review both, and let only the pipeline apply.

Why the code diff isn't enough

A one-line change like version = "~> 5.0" → "~> 6.0" on a module looks harmless in a diff. Its plan might replace 14 resources. A rename from app_config to config looks like tidying; its plan destroys and recreates the resource (§13 shows how to avoid that). The plan is the real change. So the standard workflow puts the plan right in the pull request, next to the code.

The workflow

One change, from branch to production
The standard Terraform pull-request workflow.
You
git switch -c bigger-db
allocated_storage = 20 → 50
Pull request
—
CI pipeline
—
Cloud
unchanged
State bucket
v42
You make the change on a branch: the database needs more disk. You don't have write access to production, and that's on purpose.
1 / 8

The key rule is the last step: only the pipeline has permission to change production. People get read-only access. That makes the PR the only door, so every change is reviewed and recorded, and nobody "just quickly" causes drift (§07).

A pipeline, sketched

Here's that flow as a GitHub Actions workflow. It's illustrative (we didn't run it against AWS). CI/CD explains workflows, jobs and steps. There are three jobs: a plan on every pull request, and after merge a plan that's saved and an apply that runs exactly that saved plan once someone approves it.

.github/workflows/terraform.yml (illustrative)
name: terraform
on:
  pull_request:
    paths: ["infra/**"]
  push:
    branches: [main]
    paths: ["infra/**"]

defaults:
  run:
    shell: bash               # ← explicit bash also turns on pipefail (see below)
    working-directory: infra

jobs:
  pr-plan:                    # runs on every pull request
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions: { contents: read, id-token: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-plan
          aws-region: eu-west-1
      - run: terraform fmt -check -recursive    # formatting is consistent
      - run: terraform init -input=false
      - run: terraform validate                 # syntax and types are OK
      - run: terraform plan -input=false -no-color -lock-timeout=5m | tee plan.txt
      # …then post plan.txt as a PR comment (e.g. with actions/github-script)

  main-plan:                  # after merge: make the plan that will be applied
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions: { contents: read, id-token: write }
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-plan
          aws-region: eu-west-1
      - run: terraform init -input=false
      - run: terraform plan -input=false -lock-timeout=5m -out=tfplan
      - run: |                                   # show the plan on the run's summary page
          { echo '~~~'; terraform show -no-color tfplan; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v7
        with: { name: tfplan, path: infra/tfplan, retention-days: 1 }   # contains secrets (§11)

  apply:
    needs: main-plan
    runs-on: ubuntu-latest
    environment: production   # ← waits for a named reviewer, who reads the plan above
    permissions: { contents: read, id-token: write }
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-apply   # can change things
          aws-region: eu-west-1
      - run: terraform init -input=false
      - uses: actions/download-artifact@v8
        with: { name: tfplan, path: infra }
      - run: terraform apply -input=false -lock-timeout=5m tfplan   # ← exactly the approved plan

Who may do what

Two roles keep this safe, and the details matter:

  • terraform-plan can read the cloud (enough to refresh) and read the state. It also needs permission to write and delete the lock object (…/terraform.tfstate.tflock), because plan takes the state lock too (§09), and nothing else in the bucket. Remember that whoever can read the state can read every secret in it (§11), so this role is sensitive as well.
  • terraform-apply can change things. This is the one that must never reach a pull request.

What stops a pull request from simply editing role-to-assume to terraform-apply? On pull_request, GitHub runs the workflow file from the PR's branch, so the YAML protects nothing. The protection is the apply role's trust policy in AWS: it only accepts OIDC tokens whose sub (subject) claim names the production environment, which only jobs in this repo that pass the environment: production approval get:

trust policy of terraform-apply (illustrative, excerpt)
"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:acme/infra:environment:production"
  }
}

A job from a PR branch gets a token with sub = repo:acme/infra:pull_request, so AWS refuses to hand it the apply role. Protect the production environment in GitHub too (required reviewers, deployments only from main), so nobody can add environment: production to a branch and approve it themselves.

The saved plan earns its keep in the gap between main-plan and the approval, which might be hours. If anything changed the state in between, applying the old plan would be wrong, and Terraform refuses. We saved a plan, let a "teammate" apply a different change first, then tried to apply the saved one:

terminal
$ terraform apply tfplan2

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

Then you re-run the workflow, which makes and approves a fresh plan. A saved plan also has to be applied with the same Terraform version and the same commit's code, which is why the apply job checks out the same commit and runs init again.

Tools like Atlantis (open source) and HCP Terraform, Spacelift or env0 (hosted) package this whole flow: plan on PR, comment, lock the folder while a PR is open, apply on approval.

What the reviewer actually checks

1

The summary line

Does X to add, Y to change, Z to destroy match what the PR says it does? Any destroy you didn't expect is a stop.
2

Replacements

Every -/+: what forces it? Is the thing stateful (database, disk, bucket, DNS zone)? A new database is an empty database.
3

Security

New 0.0.0.0/0 rules, "*" in IAM policies, anything public. Also: changes nobody asked for, which usually means drift.

Let a robot check the boring rules: policy as code

Some rules should never depend on a tired reviewer: "no public buckets", "every resource has a team tag", "nothing in prod is destroyed without a second approval". Policy as code checks them automatically on every plan.

It starts with the plan as JSON. terraform show -json turns a saved plan into data a script can read. Here's our example's plan, after changing both the port and the release (real output, using jq to pick fields):

terminal
$ terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address)  \(.change.actions | join(","))"'
local_file.app_config  delete,create
random_pet.server  no-op
terraform_data.release  update

$ terraform show -json tfplan | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length'
1

A CI step can fail when that number isn't zero, unless the PR has a "destroy-approved" label. Real teams write such rules in a policy language: OPA/Rego (with the conftest tool), Sentinel in HCP Terraform, or tools like Checkov and Trivy that ship hundreds of ready-made security rules. Terraform itself can also check simple rules with validation, precondition and check blocks, and test modules with terraform test (1.6+).

Staff-level§13

Staff-level: blast radius, refactoring and scale

In one sentenceAt scale, Terraform's danger is the size of a mistake. Keep states small, refactor with moved blocks, and put guards on anything that holds data.

Split the state by blast radius

A single state for "all of production" feels simple until a plan takes 15 minutes, one person's lock blocks everyone, and a typo in an app change sits one yes away from the VPC. The blast radius of a state is everything one bad apply can touch, so keep each one small:

a common layout
infra/
├── modules/                 # shared building blocks, versioned
│   ├── app_service/
│   └── network/
├── prod/
│   ├── network/             # ← own state: changes rarely, very dangerous
│   ├── data/                # ← own state: databases, buckets
│   └── app/                 # ← own state: changes daily, safe to replace
└── staging/
    ├── network/
    ├── data/
    └── app/

Split along how often things change and who owns them. The network changes twice a year and belongs to the platform team; the app's services change daily. Each folder has its own backend key (§09), its own lock, its own plan.

The parts still need each other's values (the app needs the network's subnet IDs). Ways to pass them, from loosest to tightest coupling:

  • Look it up with a data source by name or tag (data "aws_vpc" "main" { tags = { Name = "main" } }). The app only depends on the VPC existing, not on how it was built.
  • Publish values to a parameter store from the network config and read them in the app config.
  • terraform_remote_state, a data source that reads another state's outputs directly. Simple, but it gives the reader access to that entire state, secrets included (§11), so many teams avoid it.

Environments: folders, not workspaces (mostly)

Terraform has a built-in workspace feature: terraform workspace new staging gives the same folder a second state. It's handy for short-lived copies (a test environment per pull request). For long-lived environments, most teams prefer one folder per environment, as above:

  • You can see in the file tree which environment you're in. With workspaces, the only clue is a command you may have forgotten to run, and apply in the wrong workspace is a classic incident.
  • Prod can use a different backend, different credentials and stricter approvals.
  • Environments can differ on purpose (prod has three zones, dev has one) without count = terraform.workspace == "prod" ? 3 : 1 everywhere.

Tools like Terragrunt exist mostly to reduce the repetition this creates.

Refactoring without destroying: moved

Renaming a resource in code changes its address, and Terraform sees "old one gone, new one wanted". Here's the real plan after renaming local_file.app_config to local_file.config:

terminal
$ terraform plan
  …
  # local_file.app_config will be destroyed
  # (because local_file.app_config is not in configuration)
  # local_file.config will be created
Plan: 1 to add, 0 to change, 1 to destroy.

For a database, that's a disaster dressed as tidying. A moved block (Terraform 1.1+) tells Terraform it's the same object with a new address:

main.tf
moved {
  from = local_file.app_config
  to   = local_file.config
}
terminal
$ terraform plan
  …
  # local_file.app_config has moved to local_file.config
    resource "local_file" "config" {
        id                   = "461ee33c25086279fcd9abd3641b6d55154577c4"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

The same works for moving a resource into a module (to = module.app.local_file.config) and for switching from count to for_each (from = x[0], to = x["web"]). Keep the moved blocks around until every state that uses this code has been applied. The opposite tool is a removed block (1.7+): "stop managing this, but don't delete it", for handing a resource to another state or team.

Guards on things that hold data

prevent_destroy makes any plan that would destroy the resource fail. We added it to random_pet.server and ran terraform destroy:

terminal
$ terraform destroy

Error: Instance cannot be destroyed

  on main.tf line 14:
  14: resource "random_pet" "server" {

Resource random_pet.server has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or reduce the scope
of the plan using the -target option.
main.tf
resource "random_pet" "server" {
  length = 2

  lifecycle {
    prevent_destroy = true
  }
}

It also blocks accidental -/+ replacements, which is where it earns its keep. It has one gap: if you delete the whole block, the guard goes with it. So pair it with the cloud's own protection (deletion_protection = true on databases, versioning on buckets), which lives outside your code.

The other lifecycle setting you'll meet is ignore_changes = [desired_count]: "don't revert this setting when it drifts". Use it for values something else legitimately manages, like an autoscaler changing the number of copies, and not as a way to hide drift you don't understand.

Upgrades are changes too

  • Pin Terraform itself (required_version), providers (version plus the committed .terraform.lock.hcl, the provider lockfile) and modules.
  • Upgrade on purpose: a pull request that runs terraform init -upgrade, with the plan reviewed like any other change. A bot like Renovate or Dependabot can open these PRs.
  • Major provider versions rename and remove arguments. Read the upgrade guide, upgrade one state at a time, staging first.

What doesn't belong in Terraform

Terraform is best for things that change at human speed: networks, clusters, databases, permissions, DNS. It's a poor fit for things that change on every commit, like which container image version is running. Let the deploy pipeline handle those (or GitOps for Kubernetes) and use ignore_changes on the image setting so the two don't fight.

Practitioner§14

Check yourself

Decide before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 8
Read the summary

A PR changes the database's engine setting. The plan shows -/+ aws_db_instance.main must be replaced with # forces replacement next to engine, and Plan: 1 to add, 0 to change, 1 to destroy. What happens on apply?

QUESTION 2 / 8
Run it twice

You ran terraform apply and it created 3 servers. Without changing any file, you run terraform apply again. What happens?

QUESTION 3 / 8
count shifts

count = length(var.servers) with ["web", "api", "worker"]. You remove "web" (the first item). How many resources does the plan touch?

QUESTION 4 / 8
Hidden, not safe

A database password comes from random_password and is output with sensitive = true. A contractor has read access to the S3 bucket holding the state. Can they see the password?

QUESTION 5 / 8
Somebody holds the lock

Your apply fails with Error acquiring the state lock. The lock info says Who: runner@ci, created 2 minutes ago. What should you do?

QUESTION 6 / 8
The 2 a.m. hotfix

During an incident someone raised a server's size in the console, and it fixed the problem. Today's plan for an unrelated change also shows ~ instance_type = "t3.large" -> "t3.micro". What's the right fix?

QUESTION 7 / 8
Tidying up

You rename resource "aws_db_instance" "db" to "main" to match your naming rules. Without anything else, what does the plan do?

QUESTION 8 / 8
Create first, then…

A security group has a fixed name = "app". You add create_before_destroy = true and change a setting that forces replacement. What's likely to happen on apply?

Answered 0 / 8
Foundations§15

Cheat sheet

📐
Describe the resultWrite what should exist; the tool works out create, change or nothing. Safe to run twice.
🔍
Read every planSummary line first, then every -/+ and -. A replaced database is an empty database.
📒
State is the memoryIt maps code addresses to real IDs. Remote, locked, versioned, encrypted. Never in git, never hand-edited.
🔒
One writer at a timeThe lock stops two applies racing. force-unlock only when you're sure nothing runs.
🔁
for_each over countName copies by key, so removing one item touches one resource.
🧭
Drift: pick a sideHand change wrong? Apply. Right? Put it in code until plan says no changes. Check nightly.
🤫
Secrets live in statesensitive only hides output. Protect the state; prefer ephemeral, write-only or cloud-managed secrets.
🐄
Replace, don't patchNew image, new servers, delete the old. Unique names for create_before_destroy.
CommandDoes
terraform initdownload providers and modules, set up the backend, write .terraform.lock.hcl
terraform fmt / validateformat files / check syntax and types (no API calls)
terraform plan -out=tfplanrefresh, diff, print the plan and save it
terraform apply tfplando exactly the saved plan (fails if the state changed since)
terraform plan -refresh-onlyshow drift without planning to undo it
terraform plan -detailed-exitcodeexit 0 = no changes, 1 = error, 2 = changes (for scheduled drift checks)
terraform destroydelete everything in this state
terraform state list / state show ADDRlook inside the state safely
terraform output -raw NAMEread an output from a script
terraform show -json tfplanthe plan as JSON, for policy checks
terraform force-unlock IDremove a lock left by a crashed run
Plan symbolMeaning
+create
~update in place
-/+replace: destroy, then create (look for # forces replacement)
+/-replace with create_before_destroy: create, then destroy
-destroy
I want to…Reach for
adopt something that already existsan import block, then plan until "No changes"
rename or move a resourcea moved block
stop managing without deletinga removed block (1.7+)
never lose this databaseprevent_destroy + the cloud's deletion protection
let something else own a settingignore_changes = [that_setting]
reuse a group of resourcesa module, version pinned
share state with the teama remote backend (S3 with use_lockfile, GCS, HCP Terraform)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DevOps & CloudKubernetes
Read →