Terraform — Declare infrastructure and let a tool converge it
| Levels | Beginner → Intermediate → Production |
| Time | Beginner ~30 min · full module ~4h |
| Prerequisites | Linux; reading HCL is enough — no cloud account for Beginner |
| You will be able to | (1) explain desired state vs state file vs plan (2) init/plan/apply/destroy a local_file (3) say why you never commit .tfstate |
Last verified: 2026-08-16 · Tested with: Terraform 1.9+ (OpenTofu is a fine substitute; use tofu in place of terraform)
60-second overview
You write files that say what should exist. Terraform compares that desired state to a state file (its notebook of what it already created), shows a plan, then calls a provider API to make reality match. First success uses the local provider and creates a file on disk. No AWS account.
Mental model
Terraform is a building inspector with a notebook. The .tf files are the blueprints (desired state). The state file is the notebook of what it already built and the IDs those things have. Plan is walking the site with both in hand.
flowchart LR TfFiles[Desired state .tf] --> Plan State[State file] --> Plan Plan --> Apply Apply --> CloudOrLocal[Provider API] Apply --> State
Skip to
| Band | What you get | Go |
|---|---|---|
| Beginner | Concepts + local_file on your laptop |
below |
| Intermediate | Variables, outputs, a tiny module | below |
| Production | Remote state, pinning, workspaces, drift | below |
Beginner: core concepts
Desired state (HCL)
- What it is: HashiCorp Configuration Language in
.tffiles. You declare the end state, not the click path. - Why it exists: A second engineer (or you, next month) should get the same result from the same files.
- How it looks: examples/local-file/main.tf — one
local_filewith a path and content. - Common confusion: The
.tffiles are not a log of what ran. They are the blueprint. The log of what Terraform thinks exists is the state file.
Providers and resources
- What it is: A provider is the plugin that talks to an API (
local,docker,aws, …). A resource is one object that provider can create (local_file,aws_instance, …). - Why it exists: The language stays the same; only the plugin changes when you move from a file on disk to a VM in a cloud.
- How it looks: examples/local-file/versions.tf pins
hashicorp/local.main.tfthen usesresource "local_file" "hello". - Common confusion: Installing the
terraformbinary does not install every provider.terraform initdownloads the onesrequired_providersnames.
State file
- What it is:
terraform.tfstate— JSON Terraform writes after apply. It stores IDs and attributes of what it created. - Why it exists: The next plan has to know “this
local_file.hellois that file on disk” (or “thisaws_instance.webisi-abc123”). - How it looks: After first apply,
ls terraform.tfstatesucceeds. Open it: you will see the file content and checksum. - Common confusion: State is not the same as the
.tffiles. Lose the state and Terraform no longer knows what it owns. Commit the.tffiles; never commit*.tfstate.
Plan vs apply
- What it is:
plancomputes the diff (add / change / destroy) and prints it.applyexecutes that kind of diff and updates state. - Why it exists: You review the blast radius before anything is created or deleted.
- How it looks: First
plansays1 to add. SecondapplysaysNo changes. - Common confusion:
planwithout-outis a preview, not a contract. In CI you save a plan file and apply that file.
Idempotency / converge
- What it is: Apply twice with the same files and Terraform does nothing the second time. Reality already matches desired state.
- Why it exists: You re-run apply from CI. “No changes” is success, not a no-op you should skip forever.
- How it looks: First apply:
Resources: 1 added. Second:No changes. Your infrastructure matches the configuration. - Common confusion: If every apply still wants to replace a resource, something is not convergent (a timestamp in a name, a provider bug, or drift).
Beginner: first success
No cloud account. The local provider writes a file in the example directory.
Goal: Create hello.txt with Terraform, prove a second apply is a no-op, then destroy it.
Time: ~15 minutes
Install — pick one. This is a Linux-first guide; Homebrew is optional.
# Ubuntu/Debian: HashiCorp apt repo
# https://developer.hashicorp.com/terraform/install
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 $(. /etc/os-release && echo "$VERSION_CODENAME") main" \
| sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
# Or download the linux amd64 zip from that same install page and put terraform on PATH.
# Optional on macOS: brew install terraform
terraform version
Then:
cd examples/local-file
terraform init
terraform plan
terraform apply -auto-approve
cat hello.txt
terraform apply -auto-approve # no changes
ls terraform.tfstate
terraform destroy -auto-approve
Expected output: hello.txt contains hello from terraform. First apply reports Resources: 1 added, 0 changed, 0 destroyed and prints outputs hello_path / hello_id. Second apply prints No changes. Your infrastructure matches the configuration. terraform.tfstate exists after apply. Destroy prints Resources: 1 destroyed and removes hello.txt.
If it failed: terraform: command not found → the binary is not on PATH. Failed to query available provider packages → no network during init (providers are downloaded, not bundled). A leftover hello.txt from a previous run is fine; apply will adopt or replace it according to state.
Intermediate: go deeper
You should already have created and destroyed examples/local-file.
Variables and outputs
The same example already wires them:
- examples/local-file/variables.tf —
greetingandfilename, both with defaults so first success needs no flags. - examples/local-file/outputs.tf — path and content checksum after apply.
cd examples/local-file
terraform apply -auto-approve -var='greeting=hello again'
cat hello.txt
-var, a *.tfvars file, or TF_VAR_greeting all set the same variable. Outputs are how a later module or a human reads values out of state without opening the JSON.
A tiny module and for_each
examples/module-hello wraps local_file in examples/module-hello/modules/hello. The caller in examples/module-hello/main.tf uses the module once, then again with for_each to create alpha.txt and beta.txt.
cd examples/module-hello
terraform init
terraform apply -auto-approve
ls *.txt
terraform destroy -auto-approve
A module is a directory with its own variables and outputs. It is not a different language. for_each (or count) turns one block into several instances keyed by a map or set.
Terraform vs Ansible
| Terraform | Ansible | |
|---|---|---|
| Job | Create (and destroy) infrastructure | Configure a machine that already exists |
| Memory | State file | The machine itself |
| Typical object | VPC, VM, DNS record, local_file |
package, file, service |
| Converge by | plan/apply against state | re-running modules against the host |
Terraform creates the box. Ansible installs nginx on it. Swapping those jobs fights both tools.
Usual next lab (not in this repo)
The common cloud follow-on is a VPC plus one EC2 instance. Use HashiCorp’s AWS Get Started when you have an account. This module does not ship an AWS example and does not need keys.
Production
You should already be able to: explain what the state file is for; run apply twice and get No changes; destroy the local_file.
Remote state and locking
On a laptop, state is terraform.tfstate in the working directory. On a team, two applies at once will corrupt it. Put state in a backend that supports locking (S3 + DynamoDB, Terraform Cloud, GitLab, …). The backend stores the notebook; your git repo still stores only the blueprints. This module does not include a working AWS backend — configure one when you have somewhere to lock.
Never commit tfstate
examples/local-file/.gitignore ignores .terraform/ and *.tfstate*. After init, commit .terraform.lock.hcl (the provider version pin). Do not commit terraform.tfstate. State can contain secret-shaped values even for local_file.content.
Pin provider versions
examples/local-file/versions.tf uses required_version and required_providers { local = { version = "~> 2.5" } }. Unpinned providers float under you. The lock file records the exact plugin init selected.
Workspaces — and when they are the wrong tool
A workspace is a named copy of state in the same directory (terraform workspace new staging). It is a thin switch. It is the wrong tool when environments need different backends, different variable files, or different pipelines. Prefer separate directories (or separate CI jobs) for prod vs staging.
fmt, validate, plan in CI
terraform fmt -check
terraform validate
terraform plan -out=tfplan
Run those on every pull request. Apply the saved plan on merge, not a fresh plan. GitHub Actions is the module that runs this on a git event.
Drift
Drift is reality no longer matching state: someone edited the file (or the cloud console) by hand. terraform plan shows the diff. Apply writes desired state back. The failure you are preventing is “the console is the real source of truth.”
Pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| State missing or unreadable | Deleted terraform.tfstate, or two applies raced |
Restore state from backup/backend; stop applying from laptops against shared infra |
| Provider version clash | Unpinned required_providers, or lock file ignored |
Pin in versions.tf, commit .terraform.lock.hcl, re-run init -upgrade on purpose |
| Plan wants to change something you did not edit | Someone changed the real object (drift), or a timestamp/random value in config | terraform plan is the detection; stop console edits or import the change |
| Secret leaked | Password or key in a .tf argument (it is copied into state) |
Keep secrets out of .tf; treat state as sensitive; use a remote backend with encryption |
How this connects
- Previous: Ansible configures the OS; Linux is what you are configuring. Terraform should have created that OS first. Git — commit
.tffiles, not the state file. - Next: Kubernetes — later, Terraform can create a cluster (EKS and friends). GitHub Actions — run
fmt/validate/planin CI. - When not to use this: Do not use Terraform to install packages on a VM — that is Ansible. Do not use Terraform as the day-to-day deploy tool for app YAML (kubectl, Helm, or GitOps).
Practice
Basic
Setup: First success in examples/local-file.
Task: Changegreetingand apply again.
Hint:terraform apply -auto-approve -var='greeting=edited by me'(add a newline if you want one in the file).
Success:hello.txthas the new text; a third apply with the same-varisNo changes.Setup: Same directory.
Task: Add a secondlocal_fileresource that writesgoodbye.txt.
Hint: Copy the existing resource block; change the label andfilename.
Success: Apply createsgoodbye.txt; destroy removes both files.
Intermediate
- Setup: examples/module-hello.
Task: Add"gamma"to thefor_eachset and apply.
Hint:toset(["alpha", "beta", "gamma"])in examples/module-hello/main.tf.
Success:gamma.txtexists withhello gamma; the next apply isNo changes.
Production
- Setup: A
terraform.tfstatefrom a local apply (create one if you destroyed it).
Task: Open the state file, find the file content inside it, and confirm.gitignorewould hide the state from git.
Hint: Search the JSON forhello from terraformor your greeting. Check examples/local-file/.gitignore.
Success: You can point at the field that would leak if this were a password, andgit statusdoes not list*.tfstate.
Solution sketches
-var='greeting=…'updates in place (0 added, 1 changedor replace, depending on the provider); thenNo changes.- A second
resource "local_file" "goodbye"withfilename = "goodbye.txt". - One extra key in the set; apply adds one resource.
- State JSON includes
content;.gitignorehas*.tfstateand*.tfstate.*.
Cheat sheet
One-page commands: cheatsheet.md.
Official documentation
- Start here: Terraform intro — desired state, providers, the core loop.
- Install — apt, zip, and other packages.
- Deep reference: Language — resources, variables, modules, state, backends.
- AWS Get Started — usual next lab; needs an AWS account.