🏗️ Terraform Guide

A how-to manual: how Terraform works day to day, reading plans, the state file, drift, importing what already exists, renaming without rebuilding, lifecycle rules, workspaces, remote state and locks, secrets, automation and OpenTofu, with every example run for real in a lab.

Video: Terraform guide (DevOps video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
terraform: describe infrastructure in files, then let a tool make the real thing match.
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform state list

Terraform compares the files you wrote with what exists and works out the changes. You review them, and then it makes them. The commands are few. What goes wrong in practice is everything around them: a plan that quietly says "forces replacement", a state file with a password in it, a resource renamed in the code and rebuilt in production, a hand edit nobody told Terraform about, two people applying at once.

This guide is about running Terraform after the first day: reading plans, the state, drift, variables, for_each against count, importing what somebody built by hand, renaming and forgetting without destroying, lifecycle rules, workspaces, remote state and locking, secrets, a safe pipeline, and OpenTofu. Two other pages on this site cover the first steps (Terraform Basics in the Cloud Tutorials tab) and modules (Terraform Modules, in this tab). The older cheat sheet that stood on this page is replaced by this guide, and its one-line commands are in the cheat sheet section.

Every example ran for real on Ubuntu 24.04 with Terraform 1.16.6 (HashiCorp's apt repository) and OpenTofu 1.13.1. To need no cloud account, the lab used the Docker provider (kreuzwerker/docker 3.9.0) against a Docker daemon inside the lab, so Terraform created and destroyed real containers, networks and volumes. A real cloud provider, cloud state backends and CI systems were not run, and the section "What was not run in the lab" lists everything else.

What Terraform is, and the lab used here

Terraform reads files that describe the infrastructure you want, compares them with what exists, and makes the difference. You write the end result ("a network, a container on port 8080"), not the steps. The same four commands work whether the thing is a cloud server, a DNS record, a Kubernetes object or a Docker container.

The words, in the order you meet them:

configuration   the .tf files in one directory (the "root module"). Written in HCL, HashiCorp Configuration Language.
provider        a plug-in that knows one service (AWS, Azure, Google Cloud, Kubernetes, Docker ...). Downloaded by init.
resource        something Terraform creates and manages: a server, a network, a container.
data source     something that already exists and is only read.
state           Terraform's record of what it made, and the real IDs. A file named terraform.tfstate unless you say otherwise.
plan            the list of changes Terraform would make. Changes nothing.
apply           makes the changes in a plan.
backend         where the state is kept (a local file, a database, a bucket).
workspace       a second copy of the state for the same configuration.

Two other pages on this site cover the first steps and modules: the Terraform Basics page (Cloud Tutorials tab) walks through init, plan and apply with local files, and the Terraform Modules page (this tab) covers modules. This guide is about running Terraform day to day: reading plans, state, drift, importing things that already exist, refactoring without destroying anything, workspaces, remote state and locking, secrets, automation and OpenTofu. The old cheat sheet that stood on this page is replaced by this guide, and its one-line commands are in the cheat sheet section at the end.

Every example below ran for real on Ubuntu 24.04 with Terraform 1.16.6, from HashiCorp's apt repository. To need no cloud account, the lab uses the Docker provider (kreuzwerker/docker 3.9.0) against a Docker daemon that lives inside the lab, so Terraform creates and destroys real containers and networks and nothing outside the lab is touched. Providers for a real cloud work the same way: the commands, the plan format, the state and the lock file are identical. What a cloud adds (credentials, quotas, slow creation, resources that take minutes) was not run, and the section "What was not run in the lab" lists it.

$ cat /etc/os-release | head -2
PRETTY_NAME="Ubuntu 24.04.5 LTS"
NAME="Ubuntu"
$ terraform version
Terraform v1.16.6
on linux_amd64
$ docker version --format 'Docker Engine {{.Server.Version}}'
Docker Engine 29.1.3
$ docker ps -a
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

The lab starts with no containers. Terraform is a single binary and everything it needs per project goes into the project directory, in a hidden folder named .terraform.

Installing Terraform

On Ubuntu, use HashiCorp's own apt repository. The Ubuntu archive does not carry Terraform, because the licence changed in 2023 to the Business Source License (source-available, not open source). The commands below are the ones HashiCorp documents, and they are what the lab ran when it was built:

$ sudo apt-get update && sudo apt-get install -y gnupg wget lsb-release
$ wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
$ sudo apt-get update && sudo apt-get install -y terraform

$ apt-cache policy terraform | head -3
terraform:
  Installed: 1.16.6-1
  Candidate: 1.16.6-1
$ which terraform
/usr/bin/terraform
$ terraform version
Terraform v1.16.6
on linux_amd64
$ terraform -help | head -12
Usage: terraform [global options] <subcommand> [args]

The available commands for execution are listed below.
The primary workflow commands are given first, followed by
less common or more advanced commands.

Main commands:
  init          Prepare your working directory for other commands
  validate      Check whether the configuration is valid
  plan          Show changes required by the current configuration
  apply         Create or update infrastructure
  destroy       Destroy previously-created infrastructure

Alternatives:

OpenTofu       the open-source (Linux Foundation) fork. The command is tofu, and the files are the same. A section near the end runs the same project with it.
a zip file     releases.hashicorp.com/terraform/VERSION/ has a plain binary for every platform. Unzip it into /usr/local/bin. Good for pinning one exact version in CI.
tfenv or mise  tools that install and switch between several versions (useful when old projects need old versions). Not run in the lab.

Pin the version a project needs in the configuration, with required_version (the next section), so that a colleague with an older Terraform gets a clear error instead of a surprise.

Turn on tab completion once, and add a shared plug-in cache so that every project does not download the same 64 MB provider again:

$ terraform -install-autocomplete
$ tail -1 ~/.bashrc
complete -C /usr/bin/terraform terraform
$ mkdir -p ~/.terraform.d/plugin-cache && echo 'plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"' > ~/.terraformrc && cat ~/.terraformrc
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

The first command prints nothing when it works. It adds the one line shown by tail to ~/.bashrc, and completion starts in the next shell you open. The completion itself was not exercised in the lab. Running the command a second time is an error ("already installed"), which is harmless.

A first configuration: init, fmt and validate

A configuration is a directory of .tf files. Terraform reads all of them together, in any order, so how you split them is for people only. This one has a network and a web server container, and it uses a provider, so the first command is init.

$ cat main.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

provider "docker" {}

resource "docker_network" "app" {
  name = "app-net"
}

resource "docker_container" "web" {
  name  = "web"
  image = "nginx:alpine"

  networks_advanced {
    name = docker_network.app.name
  }

  ports {
    internal = 80
    external = 8080
  }
}

The terraform block says which Terraform and which providers this needs (the version constraint ~> 3.9 means 3.9 or later 3.x, never 4). The provider block configures the provider, and this one needs nothing because it finds the Docker socket on its own. The container refers to docker_network.app.name, and that reference is how Terraform knows to create the network first.

$ terraform init
Initializing the backend...

Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.9"...
- Installing kreuzwerker/docker v3.9.0...
- Installed kreuzwerker/docker v3.9.0 (self-signed, key ID 0DCE698927DAF8EC)
Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://developer.hashicorp.com/terraform/cli/plugins/signing

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
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

init downloaded the provider into .terraform/ and wrote a lock file. Look at both:

$ ls -A
.terraform
.terraform.lock.hcl
main.tf
$ cat .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/kreuzwerker/docker" {
  version     = "3.9.0"
  constraints = "~> 3.9"
  hashes = [
    "h1:EAdNh5KgGPJT5jm848MRIfNfHUVJeTBdKKcFLax5g38=",
    "zh:0ead8281830e9b9496651282235d9a139ba1b1b6ff79e395eb8c78658dc446b9",
    "zh:0f17d37d8d3872df3fb75c68b5272e0c981343f53b506a9675b4405191edd3ef",
    "zh:11d50b37323874427c6d2a08b737d3c7707c8301fdd236c94485cf2828d0b14b",
    "zh:32f6f9b847446054e2db3d72886ef2f1d1aa51a6d0dac42340b07dad18e3f28f",
    "zh:5ea5c67668b5dcbda560dc6104b788a9bfc974d52f02f7886889b77cc0e5d248",
    "zh:5fb19a0b07edc344cd3ddeeb9cfb3d183089deb7a6a94a7b22a583aa1712596b",
    "zh:602a7ece444e2a142ec5245abb98e7a1a990a68afae2df63b6c85ec084f0c5d7",
    "zh:693dce278524ad8a6d6c9dd7a01bcd63bb85189639198f8d0b044ab0e5099401",
    "zh:72e9911568103576c6a78fa38841cfd45eeb88ad22a2c649eb140a377a5b3c26",
    "zh:956b62b6857cbb467b50158601f01b1203daa34cbd447dcc7f044c327e878b68",
    "zh:9d372bac0d4479868b34485fb4966ba7bb525938f818b6a625f4977004ea83f9",
    "zh:e06658a51427f9f53dbdb06263406fc1bc56d1a4fb5e7eb660d7cdfc22f596bd",
    "zh:eee38dadf672b946419af25160eae7c03fc2afbb14f39f2f1d2a7404d647e2f7",
  ]
}
$ du -sh .terraform
28K	.terraform

.terraform is only 28 KB here because the shared plug-in cache from the last section holds the provider and .terraform links to it. Without the cache it holds the whole provider, about 64 MB. The lock file records the exact provider version and its checksums. Commit it to git, so that everybody (and the CI) gets the same provider. Do not commit the .terraform directory. Now the two checks that cost nothing:

$ terraform fmt -check
$ terraform fmt
$ terraform validate
Success! The configuration is valid.

fmt rewrites files to the standard layout (with -check it only reports, and the exit status is 3 when something would change, which suits CI). validate checks syntax and references without contacting anything. Break the file to see what an error looks like:

$ terraform validate

Error: Unsupported attribute

  on main.tf line 22, in resource "docker_container" "web":
  22:     name = docker_network.app.nme

This object has no argument, nested block, or exported attribute named "nme".
Did you mean "name"?
(exit status 1)
$ terraform validate
Success! The configuration is valid.

The first message names the file and the line, and shows the line. A misspelled attribute is caught here, before any provider is contacted.

plan: read it before you apply

terraform plan compares three things: the configuration, the state, and (after refreshing) the real world. It prints what it would do and changes nothing. The symbols:

+  create
-  destroy
~  change in place
-/+  destroy and create again (a replacement)

$ 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:

  # docker_container.web will be created
  + resource "docker_container" "web" {
      + attach                                      = false
      + bridge                                      = (known after apply)
      + command                                     = (known after apply)
      + container_logs                              = (known after apply)
      + container_read_refresh_timeout_milliseconds = 15000
      + entrypoint                                  = (known after apply)
      + env                                         = (known after apply)
      + exit_code                                   = (known after apply)
      + hostname                                    = (known after apply)
      + id                                          = (known after apply)
      + image                                       = "nginx:alpine"
      + init                                        = (known after apply)
      + ipc_mode                                    = (known after apply)
      + log_driver                                  = (known after apply)
      + logs                                        = false
      + memory_reservation                          = 0
      + must_run                                    = true
      + name                                        = "web"
      + network_data                                = (known after apply)
      + network_mode                                = "bridge"
      + read_only                                   = false
      + remove_volumes                              = true
      + restart                                     = "no"
      + rm                                          = false
      + runtime                                     = (known after apply)
      + security_opts                               = (known after apply)
      + shm_size                                    = (known after apply)
      + start                                       = true
      + stdin_open                                  = false
      + stop_signal                                 = (known after apply)
      + stop_timeout                                = (known after apply)
      + tty                                         = false
      + wait                                        = false
      + wait_timeout                                = 60

      + healthcheck (known after apply)

      + labels (known after apply)

      + networks_advanced {
          + aliases      = []
          + name         = "app-net"
            # (3 unchanged attributes hidden)
        }

      + ports {
          + external = 8080
          + internal = 80
          + ip       = "0.0.0.0"
          + protocol = "tcp"
        }
    }

  # docker_network.app will be created
  + resource "docker_network" "app" {
      + driver      = (known after apply)
      + id          = (known after apply)
      + internal    = (known after apply)
      + ipam_driver = "default"
      + name        = "app-net"
      + options     = (known after apply)
      + scope       = (known after apply)

      + ipam_config (known after apply)
    }

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

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Two things to notice. Values that only exist after creation are shown as (known after apply). And the last line is the one to read first: "Plan: 2 to add, 0 to change, 0 to destroy."

For anything that matters, save the plan to a file and apply that file, so that what you reviewed is exactly what runs:

$ terraform plan -out=tfplan | tail -6
─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"
$ ls -l tfplan
-rw-r--r-- 1 ubuntu ubuntu 3058 Oct 19 19:38 tfplan
$ terraform show tfplan | tail -4
      + ipam_config (known after apply)
    }

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

The plan file is binary, and it can hold secrets, so treat it like state. It also goes stale: if the state changes between plan and apply, Terraform refuses to apply it. That is the safety, not a bug.

$ terraform apply tfplan
docker_network.app: Creating...
docker_network.app: Creation complete after 2s [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]
docker_container.web: Creating...
docker_container.web: Creation complete after 1s [id=d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7]

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

The container is running and the network exists. Terraform made both and wrote down their IDs. Check it from outside Terraform:

$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES     IMAGE          PORTS
web       nginx:alpine   0.0.0.0:8080->80/tcp
$ docker network ls --filter name=app-net --format '{{.Name}} {{.Driver}}'
app-net bridge
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8080
HTTP 200

The next plan should have nothing to do. A plan with no changes is how you know that the configuration and reality agree:

$ terraform plan
docker_network.app: Refreshing state... [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]
docker_container.web: Refreshing state... [id=d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ terraform plan -detailed-exitcode; echo "exit status: $?"
docker_network.app: Refreshing state... [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]
docker_container.web: Refreshing state... [id=d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
exit status: 0

With -detailed-exitcode the exit status is 0 for no changes, 1 for an error, and 2 for changes waiting. That is the basis of drift detection in a cron job or a CI pipeline, and a later section uses it.

Without a saved plan, terraform apply makes a new plan, prints it and asks for "yes". -auto-approve skips the question. Keep that for CI, where the plan was reviewed in an earlier step.

State: what Terraform remembers

The state is how Terraform knows that docker_container.web in the configuration is the container with a particular ID. Without it, Terraform could not tell its own containers from anybody else's. It is a JSON file, terraform.tfstate, in the project directory unless a backend says otherwise.

$ ls -l terraform.tfstate*
-rw-r--r-- 1 ubuntu ubuntu 4995 Oct 19 19:38 terraform.tfstate
$ terraform state list
docker_container.web
docker_network.app
$ terraform state show docker_container.web | head -20
# docker_container.web:
resource "docker_container" "web" {
    attach                                      = false
    bridge                                      = null
    command                                     = [
        "nginx",
        "-g",
        "daemon off;",
    ]
    container_read_refresh_timeout_milliseconds = 15000
    cpu_set                                     = null
    cpu_shares                                  = 0
    domainname                                  = null
    entrypoint                                  = [
        "/docker-entrypoint.sh",
    ]
    env                                         = []
    hostname                                    = "d9a4bcfaf230"
    id                                          = "d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7"
    image                                       = "nginx:alpine"

The commands to read it are state list and state show. Do not read the file with an editor and change it: use the state commands (a later section). The file itself is plain JSON, and a few keys are worth knowing (jq is a separate package, sudo apt install jq):

$ jq '{version, terraform_version, serial, lineage}' terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.16.6",
  "serial": 3,
  "lineage": "2abd46d7-7698-d112-8d14-453246807c4b"
}
$ jq -r '.resources[] | "\(.mode) \(.type).\(.name) -> \(.instances[0].attributes.id[0:12])"' terraform.tfstate
managed docker_container.web -> d9a4bcfaf230
managed docker_network.app -> 14ad4adbe9e7

serial goes up with every change. lineage identifies this state, and Terraform refuses to mix two states with different lineages (it is how a state from a different project is caught). The IDs in the file are the real Docker IDs:

$ docker ps --format '{{.ID}} {{.Names}}'
d9a4bcfaf230 web

State holds everything the provider knows about each resource, including secrets such as generated passwords, in plain text. Three rules follow from that:

never commit terraform.tfstate to git (put it in .gitignore, section "Safe habits")
for a team, keep the state in a remote backend with locking, not on someone's laptop (section on remote state)
restrict who can read the state bucket or database as strictly as you restrict the secrets themselves

Outputs are values printed at the end of an apply, and they are stored in the state, so other tools can read them:

$ cat outputs.tf
output "web_url" {
  value = "http://localhost:${docker_container.web.ports[0].external}"
}

output "web_ip" {
  value = docker_container.web.network_data[0].ip_address
}
$ terraform apply -auto-approve
docker_network.app: Refreshing state... [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]
docker_container.web: Refreshing state... [id=d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7]

Changes to Outputs:
  + web_ip  = "172.19.0.2"
  + web_url = "http://localhost:8080"

You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.

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

Outputs:

web_ip = "172.19.0.2"
web_url = "http://localhost:8080"
$ terraform output
web_ip = "172.19.0.2"
web_url = "http://localhost:8080"
$ terraform output -raw web_url
http://localhost:8080
$ terraform output -json | jq -c 'map_values(.value)'
{"web_ip":"172.19.0.2","web_url":"http://localhost:8080"}

Adding an output changed only the state, not the container, so the apply said "0 added, 0 changed, 0 destroyed". Use -raw in scripts: it prints the value with no quotes and no newline.

Changing things: in place or replace

When the configuration changes, plan says which of two things will happen to each resource. Some arguments can be changed on the real thing while it runs (shown with ~). Others cannot, so the resource is destroyed and created again (shown with -/+ and the words "forces replacement"). Which is which is decided by the provider, and the plan tells you.

First an in-place change, where a label is added to the container:

$ sed -i 's/  image = "nginx:alpine"/  image = "nginx:alpine"\n  labels {\n    label = "team"\n    value = "web"\n  }/' main.tf && terraform fmt && sed -n 15,22p main.tf
}

resource "docker_container" "web" {
  name  = "web"
  image = "nginx:alpine"
  labels {
    label = "team"
    value = "web"
$ terraform plan | grep -E '^  # |forces replacement|^Plan:'
  # docker_container.web must be replaced
      + labels { # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

A label cannot be changed on a running Docker container, and the plan says so: -/+ with "forces replacement". The plan listing is long because a Docker container has dozens of attributes, so the examples from here on filter it with grep to the lines that name each resource, each "forces replacement" and the summary. That is only for reading: the full plan is what apply runs.

$ terraform apply -auto-approve | grep -E 'Destroying|Creating|Creation|Destruction|Apply complete'
docker_container.web: Destroying... [id=d9a4bcfaf23095e9516eddbc84746902ae85339b1c263a14c288d4ab0acd4dc7]
docker_container.web: Destruction complete after 0s
docker_container.web: Creating...
docker_container.web: Creation complete after 1s [id=846f379ce613caab4a27188ede443ff6c77906729a08dc347b245399484795ee]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8080
HTTP 200

The container got a new ID. Anything that keeps data inside such a resource loses it on a replacement, which is why reading "forces replacement" in a plan is the most useful habit in this guide.

Next, a replacement you ask for. -replace recreates one resource whose configuration did not change, for example a container that got into a bad state:

$ docker ps --format '{{.ID}} {{.Names}}'
846f379ce613 web
$ terraform apply -replace=docker_container.web -auto-approve | grep -E 'Destroying|Creating|Creation|Destruction|Apply complete'
docker_container.web: Destroying... [id=846f379ce613caab4a27188ede443ff6c77906729a08dc347b245399484795ee]
docker_container.web: Destruction complete after 0s
docker_container.web: Creating...
docker_container.web: Creation complete after 0s [id=440663efc89da3562469ad07953f6216ba837b7dd7f4866e0c62c925883e5f30]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ docker ps --format '{{.ID}} {{.Names}}'
440663efc89d web

-replace is the current way. The older terraform taint command marks a resource for replacement in the state and is deprecated, because it changes the state before you have seen a plan.

Last, a change that is neither: the dependency. Change the network name, and the container, which refers to it, must be replaced as well:

$ sed -i 's/name = "app-net"/name = "app-net2"/' main.tf
$ terraform plan | grep -E '^  # |forces replacement|^Plan:'
  # docker_container.web must be replaced
      - networks_advanced { # forces replacement
      + networks_advanced { # forces replacement
  # docker_network.app must be replaced
      ~ name         = "app-net" -> "app-net2" # forces replacement
Plan: 2 to add, 0 to change, 2 to destroy.

A rename of the network forces a replacement of the network, and the container (which uses its name) follows. The chain is worked out from the references in the configuration, and it is why you should refer to attributes (docker_network.app.name) instead of typing the same string twice. Put the name back:

$ sed -i 's/name = "app-net2"/name = "app-net"/' main.tf
$ terraform plan | tail -3

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Drift: someone changed it by hand

Drift is any difference between the state and the real thing that Terraform did not cause: someone ran docker update, clicked in a console, or deleted a resource. Every plan starts by refreshing, which means it asks the provider for the current values, so drift shows up in the next plan. Start from a clean plan:

$ terraform plan | tail -3

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

First a change made by hand. Someone sets the restart policy of the container with the docker command:

$ docker update --restart=always web
web
$ terraform plan | grep -E '^  # |^ +[~+-] |^Plan:|^No changes'
  ~ update in-place
  # docker_container.web will be updated in-place
  ~ resource "docker_container" "web" {
      ~ restart                                     = "always" -> "no"
Plan: 0 to add, 1 to change, 0 to destroy.

Terraform noticed that the real restart policy no longer matches the configuration and proposes to put it back (~ means in place, so the container is not recreated). That is the default behaviour: the configuration wins. If the change was right and the configuration is wrong, edit the configuration instead. If you want to record the change in the state without touching anything, use a refresh-only plan:

$ terraform plan -refresh-only | grep -vE '^(docker_[a-z]+\.[a-z]+: Refreshing)'

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:

  # docker_container.web has changed
  ~ resource "docker_container" "web" {
      + dns                                         = []
      + dns_opts                                    = []
      + dns_search                                  = []
      + group_add                                   = []
        id                                          = "440663efc89da3562469ad07953f6216ba837b7dd7f4866e0c62c925883e5f30"
      + log_opts                                    = {}
        name                                        = "web"
      ~ restart                                     = "no" -> "always"
      + storage_opts                                = {}
      + sysctls                                     = {}
      + tmpfs                                       = {}
        # (41 unchanged attributes hidden)

        # (3 unchanged blocks hidden)
    }


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.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

-refresh-only shows the drift and proposes only to update the state. The ~ restart line is the real change. The + lines for dns, log_opts and similar are the provider filling in empty defaults, which is noise and appears in many refresh-only plans. terraform apply -refresh-only would accept it. After that the state says "always", the configuration still says the default, and the next normal plan proposes the change back. So accept drift into the state only together with the matching change to the configuration. Now put it right by applying:

$ terraform apply -auto-approve | grep -E 'Modifying|Modifications|Apply complete'
docker_container.web: Modifying... [id=440663efc89da3562469ad07953f6216ba837b7dd7f4866e0c62c925883e5f30]
docker_container.web: Modifications complete after 0s [id=440663efc89da3562469ad07953f6216ba837b7dd7f4866e0c62c925883e5f30]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
$ docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' web
no

Now a deletion. Someone removes the container itself:

$ docker rm -f web
web
$ terraform plan | grep -E '^  # |^Plan:|has been deleted|^Note'
Note: Objects have changed outside of Terraform
  # docker_container.web has been deleted
  # docker_container.web will be created
Plan: 1 to add, 0 to change, 0 to destroy.
Note: You didn't use the -out option to save this plan, so Terraform can't
$ terraform apply -auto-approve | grep -E 'Creating|Creation|Apply complete'
docker_container.web: Creating...
docker_container.web: Creation complete after 1s [id=cc4e1939daa8d9c4b42b84ca9923c12a2bb612c6addc2b84a1ee11d28bf0a41c]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' localhost:8080
HTTP 200

Terraform noticed that the container was gone, said so ("Objects have changed outside of Terraform" and "docker_container.web has been deleted") and planned to create it again. A resource that is gone from the state and from the real world needs nothing, and a resource in the state that is gone from the real world is recreated.

To find drift before it surprises you, run a plan on a schedule and use the exit status. This checks the project and says only whether anything differs, and the same script can post to a chat or send mail:

$ cat ~/lab/drift-check.sh
#!/bin/bash
# usage: drift-check.sh DIRECTORY    (exit status 0 = no drift, 2 = drift, 1 = error)
cd "$1" || exit 1
terraform plan -detailed-exitcode -input=false -lock=false > /tmp/drift.out 2>&1
rc=$?
case $rc in
  0) echo "$1: no drift" ;;
  2) echo "$1: DRIFT"; grep -E '^  # ' /tmp/drift.out ;;
  *) echo "$1: terraform failed"; tail -5 /tmp/drift.out ;;
esac
exit $rc
$ ~/lab/drift-check.sh ~/lab/web
/home/ubuntu/lab/web: no drift
$ docker update --restart=unless-stopped web
web
$ ~/lab/drift-check.sh ~/lab/web; echo "exit status: $?"
/home/ubuntu/lab/web: DRIFT
  # docker_container.web will be updated in-place
exit status: 2
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

-lock=false matters for a read-only check. A check that takes the state lock would stop a real apply that starts at the same time. Only use it for plans that you will never apply.

Variables, locals, tfvars and validation

Hard-coded values make a configuration useful once. Variables are the inputs, locals are named values worked out inside the configuration, and outputs are what it hands back. This rewrites the project so that the port, the environment name and the image are inputs:

$ cat variables.tf
variable "env" {
  type        = string
  description = "Environment name, used in labels"
  default     = "dev"

  validation {
    condition     = contains(["dev", "test", "prod"], var.env)
    error_message = "env must be dev, test or prod."
  }
}

variable "web_port" {
  type        = number
  description = "Port on the host for the web container"
  default     = 8080
}

variable "image" {
  type    = string
  default = "nginx:alpine"
}

locals {
  labels = {
    team = "web"
    env  = var.env
  }
}
$ cat main.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

provider "docker" {}

resource "docker_network" "app" {
  name = "app-net"
}

resource "docker_container" "web" {
  name  = "web"
  image = var.image

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }

  networks_advanced {
    name = docker_network.app.name
  }

  ports {
    internal = 80
    external = var.web_port
  }
}
$ terraform validate
Success! The configuration is valid.
$ terraform plan | grep -E '^  # |forces replacement|^Plan:'
  # docker_container.web must be replaced
      + labels { # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

A variable block has a type, an optional description, an optional default, and optionally a validation. dynamic "labels" builds one labels block for each item of the map in local.labels, which is how you write a block that repeats. Adding the env label means a replacement (labels cannot change on a running container), so apply it once:

$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

A variable without a default must be given a value, and Terraform asks for it when it is missing. In automation that would hang, so run with -input=false, which turns the question into an error. A value can come from several places, and the later one wins. In order from weakest to strongest:

1. the default in the variable block
2. an environment variable named TF_VAR_web_port
3. terraform.tfvars (and terraform.tfvars.json)
4. any *.auto.tfvars file, in alphabetical order
5. -var-file=prod.tfvars and -var 'web_port=8082', in the order written on the command line

The console command evaluates an expression with all of that applied, which makes the order easy to check. Each line below adds one source:

$ echo 'var.web_port' | terraform console
8080
$ echo 'var.web_port' | TF_VAR_web_port=8083 terraform console
8083
$ echo 'web_port = 8081' > terraform.tfvars && echo 'var.web_port' | TF_VAR_web_port=8083 terraform console
8081
$ echo 'web_port = 8085' > prod.tfvars && echo 'var.web_port' | TF_VAR_web_port=8083 terraform console -var-file=prod.tfvars
8085
$ echo 'var.web_port' | TF_VAR_web_port=8083 terraform console -var-file=prod.tfvars -var web_port=8082
8082
$ rm terraform.tfvars prod.tfvars

An environment variable is the weakest source after the default, so a tfvars file overrides it. The command line is the strongest. (The old cheat sheet that stood on this page listed the environment variable last, which is wrong, and the lab output above is the evidence.)

A validation turns a typo into a clear message before anything is planned:

$ terraform plan -var env=staging
docker_network.app: Refreshing state... [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]

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


Error: Invalid value for variable

  on variables.tf line 1:
   1: variable "env" {
    ├────────────────
    │ var.env is "staging"

env must be dev, test or prod.

This was checked by the validation rule at variables.tf:6,3-13.
(exit status 1)
$ terraform plan -var env=prod | grep -E '^  # |forces replacement|^Plan:'
  # docker_container.web must be replaced
      - labels { # forces replacement
      + labels { # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

A variable that is set to the wrong type is caught the same way:

$ terraform plan -var web_port=http
docker_network.app: Refreshing state... [id=14ad4adbe9e737bce02d20758a094b5afe3145cdf1f6d5aaf1f4d03cb6de1ec8]

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


Error: Invalid value for input variable

  on variables.tf line 12:
  12: variable "web_port" {

Unsuitable value for var.web_port set using -var="web_port=...": a number is
required.
(exit status 1)
$ terraform plan -input=false -var-file=missing.tfvars

Error: Failed to read variables file

Given variables file missing.tfvars does not exist.
(exit status 1)

Keep per-environment values in files named dev.tfvars and prod.tfvars and pass the right one with -var-file. Never put secrets in a tfvars file that goes into git: pass those through TF_VAR_ variables from the CI secret store instead.

Data sources, for_each and count

A data source reads something that already exists, so that the configuration can use its value without managing it. Here, the gateway address of Docker's default bridge network:

$ cat data.tf
data "docker_network" "bridge" {
  name = "bridge"
}

output "bridge_gateway" {
  value = tolist(data.docker_network.bridge.ipam_config)[0].gateway
}
$ terraform apply -auto-approve | grep -E 'Apply complete|bridge_gateway'
  + bridge_gateway = "172.18.0.1"
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
bridge_gateway = "172.18.0.1"
$ docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'
172.18.0.1

The value came from the running Docker daemon, and Terraform did not create or own that network. (My first version indexed ipam_config[0] directly and failed with "Elements of a set are identified only by their value": the provider returns a set, which has no first element, so tolist() makes it a list first. Types like that are why you run validate and plan before you trust an expression.) Data sources run during the plan, so the plan can already use their values. In a cloud, the same mechanism looks up "the latest Ubuntu image" or "the subnet named prod" so that you never paste an ID.

To create several similar things, use for_each (or count). for_each takes a map or a set, and each item gets a name in the state. Here three small sites, each on its own port:

$ cat sites.tf
variable "sites" {
  type = map(number)
  default = {
    blog = 8091
    wiki = 8092
    shop = 8093
  }
}

resource "docker_container" "site" {
  for_each = var.sites

  name  = "site-${each.key}"
  image = "nginx:alpine"

  ports {
    internal = 80
    external = each.value
  }
}
$ terraform apply -auto-approve | grep -E 'Creat|Apply complete'
docker_container.site["wiki"]: Creating...
docker_container.site["shop"]: Creating...
docker_container.site["blog"]: Creating...
docker_container.site["wiki"]: Creation complete after 1s [id=de352b1e5fe5d470a0903ab15859187163a3f54f07e60edd66773339e92b0415]
docker_container.site["blog"]: Creation complete after 1s [id=2790ff87a44186a1c574d5d18530173c4dd5def5a9fdf01d484da94a3a0eeeba]
docker_container.site["shop"]: Creation complete after 1s [id=b34198441623f90cafc43ded211d2a9b566be101980628d9d9e39a5a507475dc]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ terraform state list
data.docker_network.bridge
docker_container.site["blog"]
docker_container.site["shop"]
docker_container.site["wiki"]
docker_container.web
docker_network.app
$ docker ps --format '{{.Names}} {{.Ports}}' | sort
site-blog 0.0.0.0:8091->80/tcp
site-shop 0.0.0.0:8093->80/tcp
site-wiki 0.0.0.0:8092->80/tcp
web 0.0.0.0:8080->80/tcp

The state addresses are docker_container.site["blog"] and so on: the key is the identity. Remove the wiki from the middle of the list and look at the plan:

$ sed -i '/wiki = 8092/d' sites.tf
$ terraform plan | grep -E '^  # |^Plan:'
  # docker_container.site["wiki"] will be destroyed
  # (because key ["wiki"] is not in for_each map)
Plan: 0 to add, 0 to change, 1 to destroy.

Only the one container is destroyed. The other two are untouched, because their keys did not change. Apply it:

$ terraform apply -auto-approve | grep -E 'Destroy|Apply complete'
docker_container.site["wiki"]: Destroying... [id=de352b1e5fe5d470a0903ab15859187163a3f54f07e60edd66773339e92b0415]
Apply complete! Resources: 0 added, 0 changed, 1 destroyed.
$ docker ps --format '{{.Names}}' | sort | tr '\n' ' '
site-blog site-shop web

Now the same thing with count, which numbers its items [0], [1], [2] and so identifies them by position. This one uses terraform_data, a built-in resource that does nothing and so plans quickly, to show the plan only:

$ cat ~/lab/counttest/main.tf
variable "names" {
  type    = list(string)
  default = ["blog", "wiki", "shop"]
}

variable "enable" {
  type    = bool
  default = true
}

resource "terraform_data" "site" {
  count = length(var.names)
  input = var.names[count.index]
}

resource "terraform_data" "switch" {
  count = var.enable ? 1 : 0
}
$ cd ~/lab/counttest && terraform state list
terraform_data.site[0]
terraform_data.site[1]
terraform_data.site[2]
terraform_data.switch[0]
$ cd ~/lab/counttest && terraform plan -var 'names=["blog","shop"]' | grep -E '^  # |^Plan:|-> '
  # terraform_data.site[1] will be updated in-place
      ~ input  = "wiki" -> "shop"
      ~ output = "wiki" -> (known after apply)
  # terraform_data.site[2] will be destroyed
  # (because index [2] is out of range for count)
      - id     = "1b4eff0e-f034-6740-e5bf-c116a5df59b9" -> null
      - input  = "shop" -> null
      - output = "shop" -> null
Plan: 0 to add, 1 to change, 1 to destroy.

With count, removing "wiki" shifts "shop" into position [1]. The plan changes the second item and destroys the third one: the thing you meant to remove is not what gets removed. For real resources that change is often a replacement, which is how a one-line edit deletes a database. The rule is simple: use for_each with a stable key for anything that has a name, and count only for "N identical copies" or for 0 or 1 (a switch).

A "0 or 1" switch (count = var.enable ? 1 : 0, the second resource in that file) is the common legitimate use of count:

$ cd ~/lab/counttest && terraform plan -var enable=false | grep -E '^  # |^Plan:'
  # terraform_data.switch[0] will be destroyed
  # (because index [0] is out of range for count)
Plan: 0 to add, 0 to change, 1 to destroy.

Import: bring what already exists under Terraform

Most real projects start with infrastructure that somebody built by hand. Importing writes the real thing into the state, so that Terraform manages it from then on. It changes only the state. It never changes the real resource, and it never writes configuration for you, unless you ask it to (below).

There are two ways. The older one is the command terraform import ADDRESS ID, which edits the state at once. The current one is an import block in the configuration, which goes through plan and apply like everything else, so a colleague can review it. Use the block.

Someone made a network and a volume by hand:

$ docker network create legacy-net
db596b47a1d76be246f8eb035115e42f34649a97d9737d2d0b78c7c3a0598313
$ docker volume create legacy-data
legacy-data

First write down what you want to import, and ask Terraform to draft the configuration:

$ cat imports.tf
import {
  to = docker_network.legacy
  id = "legacy-net"
}

import {
  to = docker_volume.legacy
  id = "legacy-data"
}
$ terraform plan -generate-config-out=generated.tf | grep -E '^Plan:|Warning|written it to'
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
$ cat generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "legacy-data"
resource "docker_volume" "legacy" {
  driver      = "local"
  driver_opts = {}
  name        = "legacy-data"
}

# __generated__ by Terraform from "legacy-net"
resource "docker_network" "legacy" {
  attachable   = false
  driver       = "bridge"
  ingress      = false
  internal     = false
  ipam_driver  = "default"
  ipam_options = {}
  ipv6         = false
  name         = "legacy-net"
  options      = {}
  ipam_config {
    aux_address = {}
    gateway     = "172.20.0.1"
    ip_range    = null
    subnet      = "172.20.0.0/16"
  }
}

The ID to give is whatever the provider uses for that kind of resource. The Docker provider uses the name for networks and volumes and the long ID for containers. Every provider documents it at the bottom of each resource page, under "Import". Without that line the block cannot work.

The generated file is a draft, with every attribute filled in, including values that are only defaults. It is marked experimental. Read it, remove what you do not need, and move it into your own files. For this volume, two lines are enough:

$ cat legacy.tf
resource "docker_network" "legacy" {
  name = "legacy-net"
}

resource "docker_volume" "legacy" {
  name = "legacy-data"
}
$ terraform plan | grep -E '^  # |^Plan:'
  # docker_network.legacy will be imported
  # docker_volume.legacy will be imported
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve | grep -E 'Importing|Import complete|Apply complete'
docker_volume.legacy: Importing... [id=legacy-data]
docker_volume.legacy: Import complete [id=legacy-data]
docker_network.legacy: Importing... [id=legacy-net]
docker_network.legacy: Import complete [id=legacy-net]
Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.
$ terraform plan | tail -3

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

The plan said "2 to import", the apply imported them, and the next plan is clean. That last plan is the test that matters: if it still shows changes, your configuration does not describe the thing as it really is, and applying would change it (or, worse, replace it). Fix the configuration until the plan is empty. The import blocks have done their job, so delete the file. They would do nothing if left, but a block whose target resource is later taken out of the configuration is an error, which is a confusing thing to meet months later.

$ rm imports.tf

Not every resource imports as cleanly as a network. A container is the example where it does not, with this provider. Import the hand-made one with the old command and look at the plan:

$ docker run -d --name legacy-web -p 8090:80 nginx:alpine
2996e69c027cfa97ef551d08f609d187636099b12f1edf6a82331ddc046a81f9
$ cat legacy-web.tf
resource "docker_container" "legacy_web" {
  name  = "legacy-web"
  image = "nginx:alpine"

  ports {
    internal = 80
    external = 8090
  }
}
$ terraform import docker_container.legacy_web $(docker inspect -f '{{.Id}}' legacy-web) | grep -E 'Import|imported'
docker_container.legacy_web: Importing from ID "2996e69c027cfa97ef551d08f609d187636099b12f1edf6a82331ddc046a81f9"...
docker_container.legacy_web: Import prepared!
Import successful!
The resources that were imported are shown above. These resources are now in
$ terraform plan | grep -E '^  # |forces replacement|^Plan:'
  # docker_container.legacy_web must be replaced
      + env                                         = (known after apply) # forces replacement
      - ports { # forces replacement
          - internal = 80 -> null # forces replacement
          - ip       = "::" -> null # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

After a successful import the plan wants to replace the container, because the provider reads some settings (the port bindings, the environment) in a shape that does not match what the configuration says. Applying that plan would delete the running container and make a new one. This is the thing to watch for, and the rule is: never apply after an import until the plan shows no unexpected changes. Here the right move is to stop, and to forget the import:

$ terraform state rm docker_container.legacy_web
Removed docker_container.legacy_web
Successfully removed 1 resource instance(s).
$ rm legacy-web.tf
$ docker rm -f legacy-web
legacy-web

For a resource that will not import cleanly, the usual choices are to write a configuration that matches what the provider reads (a few rounds of plan and edit), to use ignore_changes for the attributes that differ (a later section), or to leave that resource alone and manage only what you build from now on. With a cloud provider, servers, buckets, DNS records and security groups usually import cleanly. That was not run in the lab.

Changing the state safely: moved, state mv, state rm and removed

Renaming a resource in the configuration looks to Terraform like deleting the old one and creating a new one, because the address is the identity. Try it. Rename docker_container.web to frontend:

$ sed -i 's/resource "docker_container" "web"/resource "docker_container" "frontend"/' main.tf
$ sed -i 's/docker_container.web\./docker_container.frontend./g' outputs.tf
$ terraform plan | grep -E '^  # |^Plan:'
  # docker_container.frontend will be created
  # docker_container.web will be destroyed
  # (because docker_container.web is not in configuration)
Plan: 1 to add, 0 to change, 1 to destroy.

The plan would destroy the working container and build another. To say "this is the same thing under a new name", add a moved block:

$ cat moves.tf
moved {
  from = docker_container.web
  to   = docker_container.frontend
}
$ docker ps --format '{{.ID}} {{.Names}}' | grep -w web
4326d9618324 web
$ terraform plan | grep -E '^  # |^Plan:|has moved'
  # docker_container.web has moved to docker_container.frontend
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ docker ps --format '{{.ID}} {{.Names}}' | grep -w web
4326d9618324 web
$ terraform state list | grep frontend
docker_container.frontend

Same container ID before and after: only the address in the state changed. The moved block stays in the configuration (it does nothing once applied), so that everybody else's state moves too the next time they apply. This also works for moving a resource into a module and for count to for_each changes.

The older tool is terraform state mv. It changes the state at once, with no plan and nothing to review, so reach for it only where a moved block cannot do the job. Terraform keeps a copy of the previous state in terraform.tfstate.backup each time, and you can take your own before any edit:

$ terraform state pull > before.tfstate
$ terraform state mv docker_container.frontend docker_container.web
Move "docker_container.frontend" to "docker_container.web"
Successfully moved 1 object(s).
$ terraform state list | grep -E 'web|frontend'
docker_container.web
$ terraform state mv docker_container.web docker_container.frontend
Move "docker_container.web" to "docker_container.frontend"
Successfully moved 1 object(s).
$ ls terraform.tfstate*
terraform.tfstate
terraform.tfstate.1792438811.backup
terraform.tfstate.1792438820.backup
terraform.tfstate.1792438821.backup
terraform.tfstate.backup

state pull prints the whole state, so it is also how you read a remote state. state push writes a state back, and refuses one with a different lineage or an older serial, unless you force it. Do not push by hand unless you are repairing something and have a backup.

Now the opposite of an import. terraform state rm makes Terraform forget a resource and leaves the real one running. The configuration-file way, since 1.7, is a removed block, which can be reviewed in a plan:

$ cat legacy.tf
resource "docker_network" "legacy" {
  name = "legacy-net"
}

removed {
  from = docker_volume.legacy

  lifecycle {
    destroy = false
  }
}
$ terraform plan | grep -E '^  # |^Plan:|will no longer be managed'
 # docker_volume.legacy will no longer be managed by Terraform, but will not be destroyed
Plan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
$ terraform apply -auto-approve | grep -E 'Apply complete|Removed|forgotten'
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ docker volume ls --format '{{.Name}}' | grep legacy
legacy-data
$ terraform state list | grep legacy
docker_network.legacy

The volume still exists in Docker and is no longer in the state. Without destroy = false, a removed block would destroy the real thing, the same as deleting the resource from the configuration (which is what deleting a resource block does, and the reason people lose data: they delete a block to "tidy up" and read the plan too fast).

Put the volume back under Terraform with an import block again, as in the last section (the configuration for it is restored from a copy first):

$ cat imports.tf
import {
  to = docker_volume.legacy
  id = "legacy-data"
}
$ terraform apply -auto-approve | grep -E 'Importing|Import complete|Apply complete'
docker_volume.legacy: Importing... [id=legacy-data]
docker_volume.legacy: Import complete [id=legacy-data]
Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.
$ terraform state list | grep legacy
docker_network.legacy
docker_volume.legacy
$ rm imports.tf

Lifecycle: stop dangerous changes, ignore noise

A lifecycle block, inside a resource, changes how Terraform treats that one resource. These are the settings that matter, each run for real below.

prevent_destroy        refuse any plan that would destroy this resource. Put it on data you cannot rebuild.
ignore_changes         do not treat these arguments as drift. Use for values that something else manages.
replace_triggered_by   replace this resource whenever another resource or value changes.
create_before_destroy  build the replacement first, then remove the old one. Needs names that can exist twice.
precondition           a check that must hold before the resource is built (postcondition: after).

Start with prevent_destroy on a volume that holds data:

$ cat data-volume.tf
resource "docker_volume" "data" {
  name = "web-data"

  lifecycle {
    prevent_destroy = true
  }
}
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform destroy -auto-approve 2>&1 | grep -A9 -E '^Error'
Error: Instance cannot be destroyed

  on data-volume.tf line 1:
   1: resource "docker_volume" "data" {

Resource docker_volume.data 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.
$ terraform state list | grep volume
docker_volume.data
docker_volume.legacy

The destroy was refused with a message that names the resource and the setting, and nothing at all was destroyed (not even the other resources, because the plan failed as a whole). It also stops a plan that would replace the volume. It does not stop terraform state rm, a removed block with destroy = true, or someone deleting the volume with the docker command, and it disappears if the block is edited out in the same change. It is a seat belt for mistakes, not access control.

The next three settings go on one small container. A tool outside Terraform sets its restart policy, so Terraform should leave that alone. It should be rebuilt when a release number changes. And it should refuse a port below 1024:

$ cat release.tf
variable "release" {
  type    = string
  default = "1"
}

resource "terraform_data" "release" {
  input = var.release
}
$ cat tool.tf
variable "tool_port" {
  type    = number
  default = 8099
}

resource "docker_container" "tool" {
  name  = "tool"
  image = "nginx:alpine"

  ports {
    internal = 80
    external = var.tool_port
  }

  lifecycle {
    ignore_changes       = [restart]
    replace_triggered_by = [terraform_data.release]

    precondition {
      condition     = var.tool_port >= 1024
      error_message = "Ports below 1024 need root. Use 1024 or higher."
    }
  }
}
$ terraform apply -auto-approve | grep -E 'Creating|Apply complete'
terraform_data.release: Creating...
docker_container.tool: Creating...
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

A resource has one lifecycle block, and all three settings live in it. First ignore_changes: set the restart policy by hand, as the other tool would, and plan:

$ docker update --restart=always tool
tool
$ terraform plan | tail -3

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ docker update --restart=no tool
tool

Without ignore_changes the same edit would show as a change, as in the drift section. The setting hides the difference for the listed arguments only. Do not use ignore_changes = all except as a last resort, because it hides every real change as well.

Now replace_triggered_by. Raise the release number and plan:

$ terraform plan -var release=2 | grep -E '^  # |because|^Plan:'
  # docker_container.tool will be replaced due to changes in replace_triggered_by
  # terraform_data.release will be updated in-place
Plan: 1 to add, 1 to change, 1 to destroy.

The plan names the reason next to the replacement. That is the way to rebuild a container, or a server, when its configuration comes from somewhere Terraform cannot see: a file's checksum, an image digest, a release number.

Then the precondition. A port below 1024 is refused before anything is changed:

$ terraform plan -var tool_port=80 2>&1 | grep -A8 -E '^Error'
Error: Resource precondition failed

  on tool.tf line 20, in resource "docker_container" "tool":
  20:       condition     = var.tool_port >= 1024
    ├────────────────
    │ var.tool_port is 80

Ports below 1024 need root. Use 1024 or higher.

Last, create_before_destroy, and the way it fails with a fixed name. It builds the new container first and removes the old one after, so for a moment there are two, and they cannot share a name:

$ cat blue.tf
resource "docker_container" "blue" {
  name  = "blue"
  image = "nginx:alpine"

  ports {
    internal = 80
    external = 8098
  }

  lifecycle {
    create_before_destroy = true
    replace_triggered_by  = [terraform_data.release]
  }
}
$ terraform apply -auto-approve | grep -E 'Creating|Apply complete'
docker_container.blue: Creating...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform apply -var release=2 -auto-approve 2>&1 | grep -E 'Creating|Error' | cut -c1-175
docker_container.blue: Creating...
docker_container.tool: Creating...
Error: Unable to create container: Error response from daemon: Conflict. The container name "/blue" is already in use by container "443fc976a62a930d3c8529744d54c2a87cf78f2df9d
$ docker ps --format '{{.Names}}' | grep blue
blue

The new container could not be created, because the name "blue" was taken, and Terraform stopped with the old one still running. That is the safe outcome, and it is what a fixed name does to create_before_destroy. (The same port 8098 would also clash, so the port needs to differ too.) The fix is a name that includes something that changes, and a port that can exist twice, here by leaving the host port to Docker:

$ cat blue.tf
resource "docker_container" "blue" {
  name  = "blue-${var.release}"
  image = "nginx:alpine"

  ports {
    internal = 80
  }

  lifecycle {
    create_before_destroy = true
    replace_triggered_by  = [terraform_data.release]
  }
}
$ terraform apply -var release=2 -auto-approve 2>&1 | grep -E 'Creating|Creation|Destroying|Destruction|Error|Apply complete' | cut -c1-110
docker_container.blue: Creating...
docker_container.blue: Creation complete after 1s [id=f834e472b2cf0a1dd89293484a64abf490feeb94be16d9d65d1423df
docker_container.blue (deposed object c310e585): Destroying... [id=443fc976a62a930d3c8529744d54c2a87cf78f2df9d
docker_container.blue: Destruction complete after 0s
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ docker ps --format '{{.Names}}' | grep -E 'blue|tool' | sort | tr '\n' ' '
blue-2 tool

This time the order in the output is Creating, Creation complete, then Destroying: the new one was running before the old one was removed. That is what you want for something that serves traffic. The same release=2 is now in effect for the tool container as well, and it was replaced by the same apply. Put the release back to its default for the sections that follow:

$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 2 added, 1 changed, 2 destroyed.

Order, dependencies, -target and parallelism

Terraform builds a graph from the references in the configuration and walks it, doing independent things at the same time (ten at once by default). A reference such as docker_network.app.name is an implicit dependency: the network is made first and destroyed last. You can see the graph it built:

$ terraform graph | grep -- '->' | sed 's/\[root\] //; s/ (expand)//g' | grep -E 'frontend|tool' | head -8
  "docker_container.frontend" -> "docker_network.app";
  "docker_container.tool" -> "terraform_data.release";

Each line reads "this needs that". Where nothing in the configuration refers to the other thing, but order still matters (a container that needs a database to exist first, with no reference between them), say so with depends_on:

    resource "docker_container" "app" {
      depends_on = [docker_container.db]
      ...
    }

depends_on is a last resort, because it makes the plan more cautious: everything that depends on the other resource waits until it is fully known. Prefer a real reference.

-target limits a plan or an apply to one resource and what it depends on. It exists for emergencies, such as a half-failed apply or a very large state that needs a surgical fix, and it has a catch. Change two things, and apply only one:

$ terraform apply -target=docker_container.tool -var tool_port=8199 -var web_port=8180 -auto-approve 2>&1 | grep -E 'Warning|targeting|Apply complete'
Warning: Resource targeting is in effect
Warning: Applied changes may be incomplete
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ terraform plan -var tool_port=8199 -var web_port=8180 | grep -E '^  # |^Plan:'
  # docker_container.frontend must be replaced
Plan: 1 to add, 0 to change, 1 to destroy.

The apply warned that "Resource targeting is in effect", changed only the tool container, and left the web container's change waiting. Now the real state of the infrastructure is neither the old configuration nor the new one. In a team, nobody knows that, because the next plain apply will pick the rest up without warning. Finish the job, and go back to the defaults:

$ terraform apply -var tool_port=8199 -var web_port=8180 -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 2 added, 0 changed, 2 destroyed.

-parallelism=N changes how many operations run at once. Lower it (-parallelism=2) when a provider or an API rate-limits you, and the plan is otherwise the same. -refresh=false skips asking the provider about the real world, which is quick on a big state but also hides drift, so do not use it in the pipeline that is supposed to find drift. Neither flag changes what is built.

Workspaces: one configuration, several states

A workspace is a separate copy of the state for the same configuration. The name is available as terraform.workspace, which lets one configuration build a staging copy and a prod copy. Here a container whose name and port depend on the workspace:

$ cat main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

locals {
  ports = {
    default = 8180
    staging = 8181
    prod    = 8182
  }
}

resource "docker_container" "app" {
  name  = "app-${terraform.workspace}"
  image = "nginx:alpine"

  ports {
    internal = 80
    external = local.ports[terraform.workspace]
  }
}
$ terraform init | grep -E 'successfully'
Terraform has been successfully initialized!
$ terraform workspace list
* default
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new staging
Created and switched to workspace "staging"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new prod
Created and switched to workspace "prod"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace list
  default
* prod
  staging
$ docker ps --format '{{.Names}} {{.Ports}}' | grep app- | sort
app-default 0.0.0.0:8180->80/tcp
app-prod 0.0.0.0:8182->80/tcp
app-staging 0.0.0.0:8181->80/tcp

Each workspace has its own state, and with the local backend they are kept in a folder:

$ ls terraform.tfstate terraform.tfstate.d/*
terraform.tfstate

terraform.tfstate.d/prod:
terraform.tfstate

terraform.tfstate.d/staging:
terraform.tfstate
$ terraform workspace select staging && terraform state list
Switched to workspace "staging".
docker_container.app
$ terraform workspace show
staging

Take care with the workspace you are in. Nothing in the plan tells you "this is production" unless you print it, and a destroy in the wrong workspace is easy to type. Put terraform.workspace in names and tags, show it in your shell prompt, and require the workspace in scripts:

$ terraform workspace select default && terraform workspace show
Switched to workspace "default".
default

A workspace with resources cannot be deleted by mistake:

$ terraform workspace delete staging

Error: Workspace is not empty

Workspace "staging" is currently tracking the following resource instances:
  - docker_container.app

Deleting this workspace would cause Terraform to lose track of any associated
remote objects, which would then require you to delete them manually outside
of Terraform. You should destroy these objects with Terraform before deleting
the workspace.

If you want to delete this workspace anyway, and have Terraform forget about
these managed objects, use the -force option to disable this safety check.
(exit status 1)
$ terraform workspace select staging && terraform destroy -auto-approve | grep -E 'Destroy complete'
Switched to workspace "staging".
Destroy complete! Resources: 1 destroyed.
$ terraform workspace select default && terraform workspace delete staging
Switched to workspace "default".
Deleted workspace "staging"!

Workspaces suit short-lived copies of the same thing (one per feature branch, say). For long-lived environments many teams prefer a directory per environment, or the same module called from separate root directories with separate backends, because the environments can then differ in code, in who may apply, and in where the state is stored. Workspaces share one backend, one set of credentials and one copy of the code, which is exactly what you may not want between staging and production. Both approaches were not compared on a real cloud in the lab.

Remote state and locking

State in a file on one machine does not work for a team: two people applying at the same time can corrupt it, and the file is lost with the laptop. A backend stores the state somewhere shared and takes a lock while someone changes it. Terraform has many (S3, Google Cloud Storage, Azure Blob Storage, Consul, HTTP, Kubernetes and others). The lab uses the pg backend, which keeps the state in a PostgreSQL database, because the lab can run a real PostgreSQL and needs no cloud account.

Start a database, then a project with local state:

$ docker run -d --name tfstate-db -e POSTGRES_PASSWORD=labpass-tf1 -p 5432:5432 postgres:16 | cut -c1-12
eea1d57f78e5
$ docker exec tfstate-db createdb -U postgres terraform_state && echo database created
database created
$ cat main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

resource "docker_network" "shared" {
  name = "shared-net"
}

resource "terraform_data" "slow" {
  input = "x"

  provisioner "local-exec" {
    command = "sleep 20"
  }
}
$ terraform init | grep -E 'successfully'
Terraform has been successfully initialized!
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
$ ls
main.tf
terraform.tfstate

Now add the backend. The password must not be written in a file that goes into git, so the block stays empty and the connection string comes from an environment variable that the pg backend reads:

$ cat backend.tf
terraform {
  backend "pg" {}
}
$ export PG_CONN_STR='postgres://postgres:labpass-tf1@localhost:5432/terraform_state?sslmode=disable'; terraform plan 2>&1 | grep -E '^Error|Reason'
Error: Backend initialization required, please run "terraform init"
Reason: Initial configuration of the requested backend "pg"

Terraform refuses to guess: a changed backend needs init. init -migrate-state copies the existing state into the new backend (-force-copy answers its "yes" question, for scripts):

$ export PG_CONN_STR='postgres://postgres:labpass-tf1@localhost:5432/terraform_state?sslmode=disable'; terraform init -migrate-state -force-copy | grep -E 'successfully|Successfully'
Successfully configured the backend "pg"! Terraform will automatically
Terraform has been successfully initialized!
$ ls -s terraform.tfstate terraform.tfstate.backup
0 terraform.tfstate
4 terraform.tfstate.backup
$ export PG_CONN_STR='postgres://postgres:labpass-tf1@localhost:5432/terraform_state?sslmode=disable'; terraform state list
docker_network.shared
terraform_data.slow
$ docker exec tfstate-db psql -U postgres -d terraform_state -c 'select id, name, length(data) as bytes from terraform_remote_state.states'
 id |  name   | bytes
----+---------+-------
  1 | default |  1967
(1 row)

The local terraform.tfstate is now empty, and the state is a row in the database. Everyone who runs init with the same connection string shares it. The next thing a backend gives you is the lock. Start a slow apply, and run a second Terraform while it runs:

$ export PG_CONN_STR='postgres://postgres:labpass-tf1@localhost:5432/terraform_state?sslmode=disable'; terraform plan 2>&1 | head -12

Error: Error acquiring the state lock

Error message: Workspace is already locked: default
Lock Info:
  ID:        22268841-465f-9d05-056a-481355a245dd
  Path:
  Operation: OperationTypePlan
  Who:       ubuntu@practice
  Version:   1.16.6
  Created:   2026-10-19 19:42:01.22698015 +0000 UTC
  Info:
$ export PG_CONN_STR='postgres://postgres:labpass-tf1@localhost:5432/terraform_state?sslmode=disable'; terraform plan -lock-timeout=3s 2>&1 | head -3
Acquiring state lock. This may take a few moments...

Error: Error acquiring the state lock
$ tail -2 /tmp/slow.out

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

The second run was refused with "Workspace is already locked", and the message lists a lock ID, a user, a version and a time. Be careful how you read it with the pg backend: the Operation line says OperationTypePlan, which is the refused run's own operation and not the holder's (the holder was an apply). With the plain local backend, the same message did name the holder (OperationTypeApply in a separate test). So use the block to confirm that a lock exists, and ask the team, or look at the database, to find the owner. In CI, give jobs a -lock-timeout (for example 5m) so that a job waits for a running one instead of failing. Never use -lock=false for anything that changes state.

When a lock is left behind there is terraform force-unlock LOCK_ID, which removes it. In the lab I could not produce a stale lock to unlock. I killed Terraform with SIGKILL in the middle of an apply, with the pg backend and with the plain local file, and both released the lock by themselves: the pg lock belongs to the database connection, which closes when the process dies, and the local lock is an operating-system file lock. A lock stays behind with backends that keep the lock as a record (an object in a bucket, a row in a table, a key in Consul) when the process is killed or the network drops. That case was not run. Before force-unlock, check that nobody is really applying. It does not touch infrastructure, and it will happily let two applies run at once.

For the commonly used cloud backends, the settings are in the provider documentation (not run in the lab). The S3 backend needs a bucket with versioning on, so that an old state can be restored, and since Terraform 1.10 can keep its lock as a file in the same bucket (use_lockfile = true), where older setups used a DynamoDB table. The state in any bucket or database should be encrypted at rest and readable only by the people and pipelines that apply.

Secrets: what ends up in the state

Marking a value sensitive hides it from the screen. It does not keep it out of the state. Terraform has to store the value it created, so that it can compare it next time. This makes a password, with the provider hashicorp/random:

$ cat main.tf
terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }
}

resource "random_password" "db" {
  length  = 16
  special = false
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true
}
$ terraform init | grep -E 'successfully'
Terraform has been successfully initialized!
$ terraform apply -auto-approve | grep -E 'Apply complete|db_password'
  + db_password = (sensitive value)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
db_password = <sensitive>
$ terraform output
db_password = <sensitive>
$ terraform output db_password
"ZN1Zi1NJIbO1S8Rk"
$ terraform output -raw db_password; echo
ZN1Zi1NJIbO1S8Rk
$ jq -r '.resources[0].instances[0].attributes.result' terraform.tfstate
ZN1Zi1NJIbO1S8Rk
$ grep -c "$(terraform output -raw db_password)" terraform.tfstate
2

The plan shows (sensitive value), and the full terraform output listing shows <sensitive>, because that is what the sensitive flag does. But asking for the output by name prints it (terraform output db_password, and -raw without quotes), because a script needs the value. And the state file holds it in plain text, twice: once in the resource and once in the output (the count of 2 above). Forgetting this is the usual way a secret ends up in a repository. What follows from it:

the state is as secret as the passwords inside it: restrict who can read the bucket or the database, encrypt it at rest, never commit it
where you can, do not make Terraform own the secret: let the service generate it and keep it in a secrets manager (Vault, AWS Secrets Manager, ...), and pass Terraform a reference
a plan file can hold the same secrets: treat saved plans like state, and delete them after the apply
sensitive = true on a variable and on an output is still right: it keeps the value out of logs and CI output

A variable can carry the flag too. A value derived from it is hidden in the plan:

$ cat keys.tf
variable "api_key" {
  type      = string
  sensitive = true
  default   = "abc123"
}

resource "terraform_data" "key_holder" {
  input = var.api_key
}
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform plan -var api_key=xyz789 | grep -E 'input|^Plan:'
      ~ input  = (sensitive value)
Plan: 0 to add, 1 to change, 0 to destroy.
$ jq -r '.resources[] | select(.type=="terraform_data") | .instances[0].attributes.input.value' terraform.tfstate
abc123

The plan prints (sensitive value) in place of the old and new values, and the state still stores the current one. Newer Terraform (1.10 and 1.11) added ephemeral values and "write-only" arguments for the case where the value must never reach the state at all, for providers that support them. These were not run in the lab (the Docker and random providers used here do not use them), so check the provider's documentation for each resource before relying on them.

Automation: saved plans, JSON and a guard

In a pipeline, two rules cover most of the safety: apply the plan that was reviewed, and fail the job when the plan contains something that should never happen unattended. Terraform can print a saved plan as JSON, so a few lines of jq are enough to build the second rule. Back in the web project, plan a change that forces replacements and save it:

$ terraform plan -var release=2 -out=tfplan -input=false | grep -E '^Plan:'
Plan: 2 to add, 1 to change, 2 to destroy.
$ terraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions | join(",")) \(.address)"'
create,delete docker_container.blue
delete,create docker_container.tool
update terraform_data.release

Each change lists its actions: create, update, delete, or delete and create for a replacement. The guard script reads that list and refuses a plan that deletes anything:

$ cat ~/lab/plan-guard.sh
#!/bin/bash
# usage: plan-guard.sh PLANFILE   exit 0 = safe to apply, 1 = the plan deletes something
n=$(terraform show -json "$1" | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length')
if [ "$n" -gt 0 ]; then
  echo "BLOCKED: the plan deletes or replaces $n resource(s):"
  terraform show -json "$1" | jq -r '.resource_changes[] | select(.change.actions | index("delete")) | "  \(.change.actions | join(",")) \(.address)"'
  exit 1
fi
echo "plan is safe: nothing is deleted"
$ ~/lab/plan-guard.sh tfplan; echo "exit status: $?"
BLOCKED: the plan deletes or replaces 2 resource(s):
  create,delete docker_container.blue
  delete,create docker_container.tool
exit status: 1
$ terraform plan -out=tfplan2 -input=false | grep -E '^No changes'
No changes. Your infrastructure matches the configuration.
$ ~/lab/plan-guard.sh tfplan2; echo "exit status: $?"
plan is safe: nothing is deleted
exit status: 0

The whole sequence, as a script that a CI job could run (the job name and the way the plan is reviewed depend on your CI system; that part was not run):

$ cat ~/lab/ci.sh
#!/bin/bash
set -euo pipefail
export TF_IN_AUTOMATION=1 TF_INPUT=0
terraform init -input=false -lock-timeout=5m > /dev/null
terraform fmt -check -recursive
terraform validate
terraform plan -input=false -lock-timeout=5m -out=tfplan
~/lab/plan-guard.sh tfplan
terraform apply -input=false -lock-timeout=5m tfplan
rm -f tfplan
$ ~/lab/ci.sh 2>&1 | grep -vE 'Refreshing state' | head -20; echo "exit status: ${PIPESTATUS[0]}"
Success! The configuration is valid.

data.docker_network.bridge: Reading...
data.docker_network.bridge: Read complete after 0s [id=2fd944cc97be048e8d820171c2a5f1593978c0ee98e6fa68861ddf2e22fc3f26]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
plan is safe: nothing is deleted

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

Outputs:

bridge_gateway = "172.18.0.1"
web_ip = "172.19.0.2"
web_url = "http://localhost:8080"
exit status: 0

TF_IN_AUTOMATION=1 removes the "run terraform apply next" hints from the output, and TF_INPUT=0 (the same as -input=false) makes a missing value an error instead of a question that nobody can answer. set -e stops the script at the first failure, so a failing guard means the apply never runs. Since the plan file is applied, what runs is exactly what was checked.

Two more habits for pipelines. Run the plan on every change request and post it where reviewers see it, because reading a plan is the review. And use a read-only plan on a schedule (the drift section) to find changes made by hand. Neither was connected to a real CI system in the lab.

OpenTofu: the same project with tofu

OpenTofu is the Linux Foundation's open-source fork of Terraform, begun when HashiCorp changed Terraform's licence in 2023. The command is tofu, the files are the same .tf files, and most commands behave alike. Here is OpenTofu 1.13.1 run on a project that Terraform 1.16.6 created (a zip from the project's GitHub releases page, unzipped into /usr/local/bin):

$ curl -sLO https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.zip
$ sudo unzip tofu_1.13.1_linux_amd64.zip tofu -d /usr/local/bin
$ tofu version
OpenTofu v1.13.1
on linux_amd64
$ cat main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

resource "docker_network" "t" {
  name = "tofu-net"
}
$ terraform init | grep -E 'successfully'
Terraform has been successfully initialized!
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ tofu init | grep -E 'successfully|OpenTofu has'
OpenTofu has made some changes to the provider dependency selections recorded
OpenTofu has been successfully initialized!
$ tofu plan | tail -3
docker_network.t: Refreshing state... [id=0d92d444923e678b9803d10eba5693b79997edc611c3dece366384be8cc5a85c]

No changes. Your infrastructure matches the configuration.
$ head -4 .terraform.lock.hcl; grep -m2 provider .terraform.lock.hcl
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.

provider "registry.opentofu.org/kreuzwerker/docker" {
provider "registry.opentofu.org/kreuzwerker/docker" {

OpenTofu read the state that Terraform wrote and found nothing to change. It used its own registry (registry.opentofu.org) for the provider, so it rewrote the lock file with its own header, source address and checksums. The two tools therefore fight over that file if a team mixes them. This agrees with the general advice: pick one tool per project and keep to it, and test a move on a copy first. Moving between them is easy at this size and was not tried with modules, many providers, a remote backend, or a state written by later versions of either tool.

One thing OpenTofu has that Terraform 1.16.6 does not: it can encrypt the state and plan files itself. The settings go in an encryption block:

$ cat encryption.tf
terraform {
  encryption {
    key_provider "pbkdf2" "lab" {
      passphrase = "correct-horse-battery-staple-lab"
    }

    method "aes_gcm" "lab" {
      keys = key_provider.pbkdf2.lab
    }

    method "unencrypted" "migrate" {}

    state {
      method = method.aes_gcm.lab

      fallback {
        method = method.unencrypted.migrate
      }
    }
  }
}
$ tofu apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ head -c 160 terraform.tfstate; echo
{"serial":2,"lineage":"dfbb7a8f-b2e6-a218-6868-b1711ec3a123","meta":{"key_provider.pbkdf2.lab":"eyJzYWx0IjoiMG1PekJ1T3pXVFE2L2tlVDhjaXJDaVBySVhBS3daY3lsWHpUMHhS

The method "unencrypted" and the fallback are what allows OpenTofu to read the existing plain state once and write it back encrypted. Without them, OpenTofu refuses ("encountered unencrypted payload without unencrypted method configured"). After the first apply, take the fallback out, so that a plain state would be refused from now on:

$ cat encryption.tf
terraform {
  encryption {
    key_provider "pbkdf2" "lab" {
      passphrase = "correct-horse-battery-staple-lab"
    }

    method "aes_gcm" "lab" {
      keys = key_provider.pbkdf2.lab
    }

    state {
      method = method.aes_gcm.lab
    }
  }
}
$ tofu plan | tail -2

No changes. Your infrastructure matches the configuration.
$ mv encryption.tf encryption.tf.off && terraform plan 2>&1 | grep -A4 -E '^Error'; mv encryption.tf.off encryption.tf
Error: Error acquiring the state lock

Error message: Unsupported state file format: The state file does not have a
"version" attribute, which is required to identify the format version.

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
$ sed -i 's/battery-staple-lab/battery-staple-WRONG/' encryption.tf && tofu plan 2>&1 | grep -A6 -E '^Error'
Error: Error acquiring the state lock

Error message: failed to write backup file: decryption failed for all
provided methods
attempted decryption failed for state: decryption failed: cipher: message
authentication failed

OpenTofu acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try

Terraform cannot read the encrypted file ("does not have a version attribute"), and with a wrong passphrase OpenTofu cannot either ("decryption failed"). Both are the point. A passphrase in a configuration file is only for the lab: real use takes the key from a key management service (OpenTofu has providers for AWS KMS, GCP KMS and OpenBao) or from an environment variable, and those were not run here. The pbkdf2 provider also needs a long passphrase, and a lost key means a lost state, so keep a copy of the key somewhere safe.

OpenTofu and Terraform keep adding features separately, so a configuration that uses newer features of one may not run on the other. The way to know for a given project is to run its plan with both on a copy of the state. Check each tool's release notes before you move.

Debugging: logs, console and the graph

When a plan or an apply fails and the message is not enough, turn on logging. TF_LOG sets the level (TRACE, DEBUG, INFO, WARN, ERROR), TF_LOG_PATH writes it to a file, and TF_LOG_CORE and TF_LOG_PROVIDER set the level for Terraform itself and for the providers separately.

$ TF_LOG=INFO terraform plan 2>&1 | grep -c ''
2827
$ TF_LOG=TRACE terraform plan 2>&1 | grep -c ''
5041
$ TF_LOG=INFO terraform plan 2>&1 | grep -E 'INFO' | cut -c1-120 | head -5
2026-10-19T19:43:07.530Z [INFO]  Terraform version: 1.16.6
2026-10-19T19:43:07.530Z [INFO]  Go runtime version: go1.26.8
2026-10-19T19:43:07.530Z [INFO]  CLI args: []string{"terraform", "plan"}
2026-10-19T19:43:07.531Z [INFO]  Loading CLI configuration from /home/ubuntu/.terraformrc
2026-10-19T19:43:07.531Z [INFO]  TF_CLI_ARGS value: "-no-color"
$ TF_LOG_PROVIDER=DEBUG TF_LOG_PATH=/tmp/tf.log terraform plan > /dev/null; grep -c . /tmp/tf.log; grep -m3 -i 'docker' /tmp/tf.log | cut -c1-150
7821
2026-10-19T18:45:08.829Z [DEBUG] provider: starting plugin: path=.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/linux_amd64/terra
2026-10-19T18:45:08.829Z [DEBUG] provider: plugin started: path=.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/linux_amd64/terraf
2026-10-19T18:45:08.829Z [DEBUG] provider: waiting for RPC address: plugin=.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/linux_a

In this project INFO printed 2,827 lines (the plan itself and a lot of provider start-up lines are in that count) and TRACE printed 5,041. A log file written with TF_LOG_PATH is the thing to attach to a bug report, after removing secrets (it can contain tokens). To see what the provider asked the cloud, the provider's DEBUG lines are the part to read.

terraform console evaluates expressions against the real configuration and state, which is the fastest way to check what a for expression, a map lookup or a function produces:

$ echo 'docker_container.frontend.ports' | terraform console
tolist([
  {
    "external" = 8080
    "internal" = 80
    "ip" = "0.0.0.0"
    "protocol" = "tcp"
  },
])
$ echo 'local.labels' | terraform console
{
  "env" = "dev"
  "team" = "web"
}
$ echo 'merge(local.labels, {tier = "web"})' | terraform console
{
  "env" = "dev"
  "team" = "web"
  "tier" = "web"
}
$ echo 'cidrsubnet("10.0.0.0/16", 8, 3)' | terraform console
"10.0.3.0/24"

And when Terraform crashes (a Go stack trace and a message that says "Terraform crashed"), it writes crash.log in the current directory. That has the same secrets warning as the log file. Such crashes are bugs in the provider or in Terraform, so update both first and report what is left. A crash was not produced in the lab.

Errors by symptom

Each of these was triggered on purpose in the lab and is the real message.

A provider could not be found, because of a typo in its address:

$ cat main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/dockr"
      version = "~> 3.9"
    }
  }
}
$ terraform init 2>&1 | grep -A4 -E '^Error'
Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider
kreuzwerker/dockr: provider registry registry.terraform.io does not have a
provider named registry.terraform.io/kreuzwerker/dockr

The version in the configuration no longer fits the version in the lock file:

$ cat main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.9"
    }
  }
}

provider "docker" {}

resource "docker_network" "e" {
  name = "err-net"
}
$ terraform init | grep -E 'successfully'
Terraform has been successfully initialized!
$ sed -i 's/~> 3.9/~> 3.0, < 3.9/' main.tf && terraform plan 2>&1 | grep -A6 -E '^Error'
Error: Inconsistent dependency lock file

The following dependency selections recorded in the lock file are
inconsistent with the current configuration:
  - provider registry.terraform.io/kreuzwerker/docker: locked version selection 3.9.0 doesn't match the updated version constraints "~> 3.0, < 3.9.0"

To update the locked dependency selections to match a changed configuration,
$ terraform init -upgrade | grep -E 'Installing|successfully'
- Installing kreuzwerker/docker v3.8.0...
Terraform has been successfully initialized!
$ sed -i 's/~> 3.0, < 3.9/~> 3.9/' main.tf && terraform init -upgrade | grep -E 'Installing|successfully'
- Installing kreuzwerker/docker v3.9.0...
Terraform has been successfully initialized!

A saved plan that is out of date (somebody changed the state after the plan was made):

$ terraform plan -out=old.tfplan | grep -E '^Plan:'
Plan: 1 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve | grep -E 'Apply complete'
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform destroy -auto-approve | grep -E 'Destroy complete'
Destroy complete! Resources: 1 destroyed.
$ terraform apply old.tfplan 2>&1 | grep -A5 -E '^Error'
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.

Two resources that refer to each other:

$ cat cycle.tf
resource "terraform_data" "a" {
  input = terraform_data.b.id
}

resource "terraform_data" "b" {
  input = terraform_data.a.id
}
$ terraform validate 2>&1 | grep -A8 -E '^Error'
Error: Cycle:
  terraform_data.a
  terraform_data.b

A reference to something that does not exist:

$ cat typo.tf
output "oops" {
  value = docker_network.eee.name
}
$ terraform validate 2>&1 | grep -A6 -E '^Error'
Error: Reference to undeclared resource

  on typo.tf line 2, in output "oops":
   2:   value = docker_network.eee.name

A managed resource "docker_network" "eee" has not been declared in the root
module.

The provider cannot reach what it manages (here, the Docker daemon). A plan can still print its changes, because the provider only connects when it must act, so the error shows at apply:

$ DOCKER_HOST=unix:///nonexistent.sock terraform apply -auto-approve 2>&1 | grep -A6 -E '^Error' | cut -c1-170
Error: failed to create Docker client: Error pinging Docker server, please make sure that unix:///nonexistent.sock is reachable and has a  '_ping' endpoint. Error: Cannot

  with docker_network.e,
  on main.tf line 12, in resource "docker_network" "e":
  12: resource "docker_network" "e" {

The state lock held by someone else, a changed backend and a prevent_destroy refusal appear in the sections on remote state and lifecycle. What to do, by symptom:

"Failed to query available provider packages"          check the source address (namespace/name), then the network or proxy
"Inconsistent dependency lock file"                    run terraform init -upgrade after changing a version constraint
"Saved plan is stale"                                  make the plan again; somebody changed the state
"Cycle:"                                               two things refer to each other; break the loop with a third value
"Reference to undeclared resource"                     a typo, or a resource that was renamed or removed
"Backend initialization required"                      run terraform init (init -reconfigure if you changed the backend on purpose)
"Error acquiring the state lock"                       wait with -lock-timeout; force-unlock only when sure nobody runs
"Instance cannot be destroyed"                         prevent_destroy: read what the plan removes; change the code on purpose
"failed to create Docker client" / cloud credentials   check the provider's environment variables, socket or credentials
plan replaces something that works                     read the line with "forces replacement"; find the argument that changed

Safe habits

What a team that uses Terraform for a year ends up doing, and what each habit guards against.

commit .terraform.lock.hcl                         everyone and the CI use the same provider version
pin required_version and the providers             no surprise upgrade on somebody else's laptop
never commit state, plan files or secret .tfvars   the state holds passwords in plain text
use a remote backend with locking                  two applies at once cannot corrupt the state
read every plan; look for "forces replacement"     it is the only warning before a rebuild
save the plan and apply that file                  what was reviewed is what runs
use for_each with stable keys                      removing an item removes that item
refer to attributes, not repeated strings          a rename changes the dependencies too
separate state per environment and team            a mistake in one cannot reach the other
prevent_destroy on data you cannot rebuild         a seat belt for the worst mistakes
avoid -target and -refresh=false                   half-applied changes and hidden drift
a read-only plan on a schedule                     you find drift, not an incident
back up the state before editing it                state commands have no undo (terraform state pull)

A .gitignore that matches:

$ cat ~/lab/gitignore.example
# Local .terraform directories
.terraform/

# State, backups and saved plans (they contain secrets)
*.tfstate
*.tfstate.*
*.tfplan
tfplan*
crash.log

# Variable files that may hold secrets
*.auto.tfvars
secrets.tfvars

# Keep the lock file: do NOT ignore .terraform.lock.hcl

State bucket or database: versioning or backups on, encryption on, access limited to the pipeline and to the few people who need it. The usual pattern for a pipeline is the plan on every change request, a human review, and an apply after merge, with the apply run by the pipeline and not by a person's laptop. Pipelines for a real cloud were not run in the lab.

What was not run in the lab

The lab was one Ubuntu 24.04 machine with Terraform 1.16.6, OpenTofu 1.13.1 and a Docker daemon in a container, and the Docker, random and built-in providers. Everything in the sections above ran there. These did not:

a real cloud provider (AWS, Azure, Google Cloud, Oracle): credentials, quotas, slow creation, resources that need minutes, eventual consistency, billing
cloud backends: S3 (with lock file or DynamoDB), GCS, Azure Blob, HCP Terraform; so the stale-lock case and force-unlock on a real lock were not seen
importing cloud resources (servers, buckets, security groups); only a network and a volume imported cleanly, and a container did not
modules in a registry, in git, and in more than one directory (see the Terraform Modules page for those)
ephemeral resources and write-only arguments, the Terraform 1.10 and 1.11 features that keep values out of the state
terraform test, policy checks (Sentinel, OPA, Checkov, tflint) and drift tools (driftctl, Terraform Cloud health checks)
Terragrunt, Atlantis, and CI systems such as GitHub Actions, GitLab CI and Jenkins: the CI script was run by hand
OpenTofu on modules, remote backends, and its other newer features; the encryption keys from a key management service
Terraform versions older than 1.16, and 1.17 and later; Ubuntu versions other than 24.04; Windows and macOS
terraform import with a for_each address, state mv between modules, state replace-provider, workspaces on a remote backend
the provisioners other than local-exec, and the null_resource that terraform_data replaced
anything with a graphical console

The cheat sheet

terraform init                          download providers and modules, set up the backend
terraform init -upgrade                 update providers within their constraints
terraform init -migrate-state           move state to a new backend (-force-copy for scripts)
terraform init -reconfigure             use the new backend settings, ignore the old state
terraform fmt -recursive                format all files (-check for CI: exit status 3 if anything changes)
terraform validate                      check syntax and references, no network
terraform plan                          what would change (changes nothing)
terraform plan -out=tfplan              save it
terraform plan -detailed-exitcode       exit status 0 = nothing, 2 = changes waiting, 1 = error
terraform plan -refresh-only            show drift only
terraform plan -destroy                 preview a destroy
terraform plan -generate-config-out=f.tf   draft configuration for import blocks
terraform apply                         plan, ask, make the changes
terraform apply tfplan                  apply exactly that saved plan
terraform apply -auto-approve           skip the question (CI)
terraform apply -replace=ADDR           rebuild one resource (replaces terraform taint)
terraform apply -target=ADDR            only that resource (emergencies)
terraform apply -refresh-only           accept drift into the state
terraform destroy                       remove everything in this state
-var 'name=value'   -var-file=x.tfvars  set variables; TF_VAR_name sets them from the environment
-lock-timeout=5m    -lock=false         wait for the lock; skip the lock (read-only plans only)
-parallelism=N      -input=false        fewer concurrent operations; never ask questions

terraform output [-raw|-json] [name]    outputs from the state
terraform show [tfplan]                 the state, or a plan
terraform show -json tfplan | jq        a plan as JSON
terraform state list                    every resource in the state
terraform state show ADDR               one resource
terraform state mv A B                  rename in the state (prefer a moved block)
terraform state rm ADDR                 forget a resource (prefer a removed block); the real one stays
terraform state pull > backup.tfstate   read the state
terraform import ADDR ID                adopt a resource (prefer an import block)
terraform force-unlock LOCK_ID          remove a stuck lock (check first)
terraform workspace list|new|select|show|delete
terraform providers                     which providers the configuration uses
terraform graph                         dependency graph (DOT format)
terraform console                       try expressions
terraform version

Blocks that matter:
  moved   { from = A  to = B }                  rename without rebuilding
  removed { from = ADDR  lifecycle { destroy = false } }   forget without destroying
  import  { to = ADDR  id = "..." }             adopt, through plan and apply
  lifecycle { prevent_destroy  ignore_changes  create_before_destroy  replace_triggered_by  precondition }

Variable precedence, weakest to strongest: default, TF_VAR_name, terraform.tfvars, *.auto.tfvars, then -var-file and -var in the order written. (The old version of this cheat sheet had the environment variable as the strongest. The lab showed otherwise.)

Environment: TF_LOG=TRACE|DEBUG|INFO|WARN|ERROR   TF_LOG_PATH=file   TF_INPUT=0   TF_IN_AUTOMATION=1
             TF_CLI_ARGS_plan="-parallelism=5"   TF_PLUGIN_CACHE_DIR=~/.terraform.d/plugin-cache
             TF_WORKSPACE=name   TF_DATA_DIR=dir

Common problems and tips

A plan that wants to replace something and you do not know why. Search the plan for "forces replacement" and read the argument that changed. Often it is something you edited yourself, or a default that a newer provider version changed after init -upgrade.

The plan is huge and nearly empty. The provider is filling in empty defaults (dns = [] and similar) after a refresh. Those lines are noise: read the lines with ~, + and - at the start that name an argument you care about.

A destroy "to tidy up". Deleting a resource block, or a removed block without destroy = false, destroys the real thing. If you only want Terraform to forget it, use terraform state rm or destroy = false.

"It says no changes but the resource is wrong." Terraform only compares what the provider reports. An argument the provider does not read back cannot show drift. Check the real object.

Rename a resource and everything is destroyed. The address is the identity. Add a moved block.

Removing an item from a count list shifts the others. Use for_each with a stable key.

Two people apply at once. Use a remote backend with locking and a -lock-timeout in CI.

The state has a password in it. It always does, for anything the provider creates. Protect the state, and prefer references to a secrets manager.

A lock file from another tool. tofu rewrites .terraform.lock.hcl with its own source addresses and checksums. Keep one tool per project.

Tips:
  Run terraform fmt and validate in a pre-commit hook, so they never fail in CI.
  Use a shared plug-in cache (TF_PLUGIN_CACHE_DIR) so each project does not download its own copy of a large provider.
  Put the workspace, the environment and the owner in resource names and tags, so that a stray resource can be traced.
  After an import, plan until it says no changes, and only then apply.
  Keep the number of resources in one state manageable. A state with thousands of resources makes every plan slow and every mistake large; split by team or by layer (network, data, apps).
  Read the changelog of a provider before init -upgrade, and test the upgrade in a copy first.
  Commit the code, never the state. The first is your design, and the second is a record that belongs to the backend.