Terraform Essentials
Providers, state, modules and safe plan review.
Every example here was run for real with the local provider, so you can follow along without a cloud account.
What Terraform does
Terraform reads configuration files that describe the objects you want, compares them with what it recorded last time, and makes the API calls needed to close the gap. Five terms cover most of it.
- Configuration: the
.tffiles in a directory. That directory is the root module. - Provider: a plugin that talks to one kind of API. It supplies the resource types.
- Resource: one declared object, addressed as
type.name, for examplelocal_file.this. - State: Terraform’s record that binds each resource in your configuration to a real object.
- Plan: the list of actions Terraform proposes after comparing configuration, state and the real objects.
The habit that keeps you safe is simple: the plan is the change request. Apply is only the last step, so most of your care belongs in reading the plan.
The workflow
The documentation names plan, apply and destroy as the core provisioning commands. In practice you run three more before them.
| Command | What it does |
|---|---|
terraform init | Sets up the working directory: initializes the backend, installs modules and installs providers. Safe to run again. |
terraform fmt | Rewrites files into canonical style. -check only reports (non-zero exit if a file needs formatting), -recursive includes subdirectories. |
terraform validate | Checks syntax and internal consistency. It needs init first and does not validate remote services such as provider APIs. |
terraform plan | Proposes changes and applies nothing. |
terraform apply | Plans (unless you pass a saved plan), asks for approval, then performs the changes. |
terraform destroy | Destroys everything the current directory and workspace manage. plan -destroy previews it. |
Here is the smallest useful loop, using the configuration built in the next sections. Validation fails before init because the module is not installed yet.
$ terraform fmt -check -recursive
$ terraform validate
Error: Module not installed
on main.tf line 25:
25: module "motd" {
$ terraform init
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
Terraform has been successfully initialized!
$ terraform validate
Success! The configuration is valid.Providers and the lock file
Providers are versioned separately from Terraform. Declare each one in required_providers with a source address ([hostname/]namespace/type, and the public registry is the default host) and a version constraint.
terraform {
required_version = ">= 1.5"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}The ~> operator lets only the right-most component of the version increment. So ~> 2.5 accepts any 2.x release from 2.5 up, and never 3.0. The documentation recommends this kind of bounded constraint for root modules.
terraform init records the exact choice in .terraform.lock.hcl:
$ head -8 .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/local" {
version = "2.9.1"
constraints = "~> 2.5"
hashes = [
"h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=",The constraint says what is allowed. The lock file says what was chosen, plus checksums of the provider packages. Later init runs reuse that choice, so you and your CI install the same provider. Commit the file, so that a provider upgrade shows up in code review like any other change. To move to a newer version inside your constraint, run terraform init -upgrade on purpose and review the diff of the lock file.
State, backends and locking
State stores the bindings between the objects in your configuration and the remote objects. The documentation says it also tracks metadata and improves performance for large infrastructures. By default Terraform keeps it in the local backend: a terraform.tfstate file in the working directory. The outputs in this section come from the configuration and the apply in the section on variables and modules, so read that part first if you want to follow along.
$ terraform state list
local_sensitive_file.db
terraform_data.release
module.motd.local_file.this
$ terraform state show terraform_data.release
# terraform_data.release:
resource "terraform_data" "release" {
id = "ead76de7-37f1-3bc7-5990-dec2b6991530"
input = "hello from dev"
output = "hello from dev"
}State can hold secrets
State and plan files contain resource attributes, and those can include passwords and tokens. Marking a value sensitive hides it from CLI output, but it is still stored. Here a local_sensitive_file holds a fake password:
$ grep -n "s3cr3t" terraform.tfstate
22: "content": "s3cr3t-password",So treat the state file and saved plan files as secrets. The documentation recommends storing state remotely, encrypting it at rest, limiting access and keeping audit logs. It also says to avoid keeping state in version control or in storage without locking and access control.
Remote backends and locking
For teams, the documentation recommends storing state in HCP Terraform or a remote backend. Terraform locks state automatically for all operations that could write it, so that two runs do not write it at the same time. Not every backend supports locking, so check the documentation of yours. The local backend locks the state using system APIs. To reproduce the next output, use a separate directory with this backend block and a resource that takes a while to apply (for example a terraform_data with a local-exec provisioner running sleep 25). While that apply runs in one terminal, a second command in another one stops:
terraform {
backend "local" {
path = "state/terraform.tfstate"
}
}$ terraform plan
Error: Error acquiring the state lock
Error message: resource temporarily unavailable
Lock Info:
ID: ce4930f9-7c41-715f-bb88-604e860468ab
Path: state/terraform.tfstate
Operation: OperationTypeApply
(Who, Version, Created and Info lines trimmed)Wait, or use -lock-timeout to retry acquiring the lock for a while. -lock=false exists, but the documentation calls it dangerous if others might run commands at the same time. terraform force-unlock removes a lock, and the locking page warns that unlocking state while someone else holds the lock could cause multiple writers. A backend block cannot refer to variables or locals, and changing it means running terraform init again.
Do not edit the state file by hand. The documentation says to use the terraform state command for basic modifications instead.
Variables, outputs and a small module
Three block types make a configuration reusable. Input variables are the parameters, locals name an expression you use more than once, and outputs return values to the CLI or to a parent module.
variable "env" {
type = string
default = "dev"
validation {
condition = contains(["dev", "prod"], var.env)
error_message = "env must be dev or prod."
}
}
locals {
greeting = "hello from ${var.env}"
}
module "motd" {
source = "./modules/note"
filename = "${path.module}/out/motd.txt"
text = local.greeting
}
output "motd_path" {
value = module.motd.path
}resource "terraform_data" "release" {
input = local.greeting
}
resource "local_sensitive_file" "db" {
filename = "${path.module}/out/db.secret"
content = "s3cr3t-password"
}Values reach a variable from -var or -var-file (highest precedence), *.auto.tfvars, terraform.tfvars.json, terraform.tfvars, TF_VAR_ environment variables, then the default. You read a local as local.greeting, a module output as module.motd.path.
A module is a container for resources used together. The .tf files you run in are the root module, and it calls child modules with a module block. A local path is enough to start:
variable "filename" {
type = string
description = "Where to write the note."
}
variable "text" {
type = string
}
resource "local_file" "this" {
filename = var.filename
content = var.text
}
output "path" {
value = local_file.this.filename
}Adding a module call, like adding a provider, means running terraform init again. Now save the plan, apply it, and check that a second plan is empty. The first plan creates all three resources, and the output is trimmed to the module’s file:
$ terraform plan -out=tfplan
# module.motd.local_file.this will be created
+ resource "local_file" "this" {
+ content = "hello from dev"
+ filename = "./out/motd.txt"
+ id = (known after apply)
(content_* hash and permission attributes, and the other two resources, trimmed)
}
Plan: 3 to add, 0 to change, 0 to destroy.
$ terraform apply tfplan
module.motd.local_file.this: Creation complete after 0s [id=3836d201b7a0485338c0300aa84c14784876b3b7]
(other Creation lines trimmed)
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ terraform plan -detailed-exitcode
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.
$ echo $?
0With -detailed-exitcode, exit code 0 means an empty diff, 1 an error and 2 a non-empty diff. That is handy in a CI drift check.
Reviewing a plan safely
Before you approve anything, read the plan in this order.
1. Read the symbols
| Symbol | Meaning | Ask yourself |
|---|---|---|
+ | create | Is this new object expected? Any cost or exposure? |
~ | update in place | Which attribute changes, and does the object stay alive while it changes? |
- | destroy | Is anything stateful or hard to recreate? |
-/+ | destroy and create a replacement | Which attribute has # forces replacement? What is lost? |
A change to content on local_file cannot be done in place. Changing env to prod shows both an update and a replacement:
$ terraform plan -var env=prod
Resource actions are indicated with the following symbols:
~ update in-place
-/+ destroy and then create replacement
# terraform_data.release will be updated in-place
~ resource "terraform_data" "release" {
id = "ead76de7-37f1-3bc7-5990-dec2b6991530"
~ input = "hello from dev" -> "hello from prod"
~ output = "hello from dev" -> (known after apply)
}
# module.motd.local_file.this must be replaced
-/+ resource "local_file" "this" {
~ content = "hello from dev" -> "hello from prod" # forces replacement
(hash and id attributes trimmed)
}
Plan: 1 to add, 1 to change, 1 to destroy.Note the summary line. A replacement counts as one add and one destroy, so a plan that says “1 to destroy” may be a replacement, not a removal. Search the plan for “must be replaced” and “will be destroyed” every time. To force a replacement on purpose, use -replace=ADDRESS, and the plan says “will be replaced, as requested”.
2. Save the plan and apply exactly that plan
A plan you approved and an apply that plans again are two different things: the world can change between them. Save the plan with -out, review it with terraform show, then pass the file to apply. Terraform then performs that plan without prompting again, so the review is your approval step. Do not use a name ending in .tf, and remember that the file can contain sensitive values.
$ terraform plan -var env=prod -out=tfplan
$ terraform show tfplan
$ terraform show -json tfplan | python3 -c "import json,sys; p=json.load(sys.stdin); [print(c['address'], c['change']['actions']) for c in p['resource_changes']]"
local_sensitive_file.db ['no-op']
terraform_data.release ['update']
module.motd.local_file.this ['delete', 'create']The JSON form is easier for scripts to read. In my test a saved plan also refused to run after the state had changed. The pages cited here do not describe this behaviour, so treat it as observed with this version. I saved a plan, changed the state with another apply, and then tried the old file:
$ terraform apply tfplan
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.Make a new plan and review it again.
3. Treat -target as an exception tool
The documentation says -target is for exceptional circumstances, such as recovering from mistakes or working around Terraform limitations, and is not recommended for routine use because it can lead to undetected configuration drift. Compare a full plan with a targeted one on the same configuration:
$ terraform plan -var env=dev
Plan: 1 to add, 1 to change, 1 to destroy.
$ terraform plan -var env=dev -target=terraform_data.release
Plan: 0 to add, 1 to change, 0 to destroy.
Warning: Resource targeting is in effect
You are creating a plan with the -target option, which means that the result
of this plan may not represent all of the changes requested by the current
configuration.
(rest of the warning trimmed)The replacement of local_file.this does not appear in the targeted plan, and only the warning hints at it. If you must use -target, run a full plan afterwards.
4. Guard the stateful resources
Databases, disks and buckets with data are the objects where a -/+ hurts. Add lifecycle { prevent_destroy = true } inside the resource block, and Terraform rejects plans that would destroy the object, according to the documentation. Here local_sensitive_file.db is guarded, and I changed its content to force a replacement:
$ terraform plan
# local_sensitive_file.db must be replaced
-/+ resource "local_sensitive_file" "db" {
~ content = (sensitive value) # forces replacement
(hash and id attributes trimmed)
}
(changes to the other two resources trimmed)
Plan: 2 to add, 1 to change, 2 to destroy.
Error: Instance cannot be destroyed
on main.tf line 39:
39: resource "local_sensitive_file" "db" {
Resource local_sensitive_file.db 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.In my test terraform destroy hit the same error (the documentation cited here does not describe that case). The guard has limits: the documentation says it does not prevent Terraform from destroying a resource if you remove its configuration. I confirmed it: deleting the whole block gave a plan with will be destroyed and the reason (because local_sensitive_file.db is not in configuration). That is why you still read every plan. The other lifecycle arguments change ordering and ignored attributes: create_before_destroy builds the replacement first (only if the remote object can exist twice), and ignore_changes skips listed attributes when planning updates.
Pre-apply checklist. Saved plan reviewed. No unexpected -/+ or will be destroyed. Stateful resources guarded or expected to change. No -target, or a reason written down. The apply uses the same plan file you reviewed.
Practices and cheat sheet
Practices that pay off
- Commit
.terraform.lock.hcl. Do not commit state or plan files. A common convention is to also ignore the local.terraform/directory. - Use a remote backend with locking for anything shared. Restrict who can read it, because it holds secrets.
- Pin providers with
~>in root modules and upgrade deliberately withinit -upgrade. - Run
fmt -checkandvalidatein CI. Remember that validate does not check remote services. - Avoid
apply -auto-approvewithout a reviewed plan. The documentation says it skips approval even for destructive changes. - Change state only with
terraform statesubcommands, never by editing the file.
Cheat sheet
| Goal | Command |
|---|---|
| Set up directory | terraform init |
| Format, check formatting | terraform fmt -recursive, terraform fmt -check |
| Validate | terraform validate |
| Save a plan | terraform plan -out=tfplan |
| Read a saved plan | terraform show tfplan, terraform show -json tfplan |
| Apply that exact plan | terraform apply tfplan |
| CI gate on changes | terraform plan -detailed-exitcode (0 none, 2 changes, 1 error) |
| Force a replacement | terraform plan -replace=ADDRESS |
| Preview a full teardown | terraform plan -destroy |
| Inspect state | terraform state list, terraform state show ADDRESS |
| Upgrade providers | terraform init -upgrade |
Official documentation
Keep going
Every command and output below was captured from a real run in throwaway directories with Terraform v1.16.3 and the hashicorp/local provider v2.9.1. Ids, hashes, timestamps and host names differ on your machine, and long lines are trimmed where marked. Wording and defaults can change between versions, so check the documentation for yours. No cloud account is needed. For a first end-to-end run, see the Terraform tutorial.
benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.