01 — Foundations
Core concepts
Terraform is a declarative infrastructure-as-code tool. You describe the desired state of your infrastructure in configuration files; Terraform builds an execution plan to reach that state and applies it through provider plugins. It reconciles configuration against recorded state — not against imperative steps.
- Provider
- A plugin for a platform (aws, google, azurerm). Configured once, exposes resources and data sources.
- Resource
- A managed object — a VM, bucket, DNS record. Terraform creates, updates, and destroys it to match config.
- Data source
- A read-only lookup of something that already exists (an AMI id, an existing VPC). It never changes infrastructure.
- State
- A JSON file mapping your config to real-world objects and caching their attributes. Terraform's source of truth for what it manages.
- Plan / apply
- The core loop: init to set up, plan to preview the diff, apply to enact it, destroy to tear down.
A minimal configuration declares a provider and one resource:
+Cert check — declarative vs imperative
Terraform is declarative: you state the desired end state, not the steps. Given the same config and state, repeated applies are idempotent — no change if reality already matches. This is why the order of resource blocks in a file does not matter; dependencies are derived from references.
02 — Language
HCL syntax
Configuration is written in HCL — HashiCorp Configuration Language. Everything is either a block (a labelled body in braces) or an argument (name = expression). Expressions reference other objects as type.name.attribute.
Types
Primitives string, number, bool; collections list(), set(), map(); structural object({}) and tuple([]). null omits an argument.
Meta-arguments
Available on most resource and module blocks: count and for_each (multiple instances), depends_on (explicit ordering), lifecycle (create_before_destroy, prevent_destroy, ignore_changes), and provider (which aliased provider to use).
Prefer for_each over count — it keys instances by a stable string, so removing one item doesn't re-index and recreate the rest:
Useful expression forms: conditional cond ? a : b, for comprehensions [for x in list : upper(x)], splat aws_instance.web[*].id, and interpolation "${var.env}-app".
03 — Interface
Variables & outputs
variable blocks are a module's inputs; output blocks are its return values; locals are named intermediate expressions. Give variables a type and — where it helps — a default, a validation, and sensitive = true.
Where values come from
In increasing precedence: environment variables (TF_VAR_name), the auto-loaded terraform.tfvars and *.auto.tfvars, then -var-file, then -var on the command line. Later sources win.
+Cert check — does sensitive hide the value everywhere?
No. sensitive = true redacts a value from CLI plan/apply output, but it is still written in plaintext to state. Protect state itself (encrypted remote backend, restricted access) — sensitivity is not encryption.
04 — Composition
Modules
A module is any directory of .tf files. The directory you run Terraform in is the root module; a module block calls a child module. Its variables are the inputs you pass, its outputs are what you read back — that is the entire interface. Always pin a version for registry and Git sources.
Sources include local paths (./modules/web), the public/private registry (namespace/name/provider), and Git (git::https://...). Reference a module's output as module.NAME.OUTPUT.
05 — State
State & backends
State lives locally in terraform.tfstate by default. For any shared or production work, use a remote backend so the team reads one state and Terraform can lock it against concurrent applies.
Common backends: s3 (with DynamoDB locking), gcs, azurerm, and Terraform Cloud / HCP. Manage state with the terraform state subcommands — list, show, mv (rename/move an address), rm (forget without destroying), pull / push. Bring existing infrastructure under management with terraform import ADDRESS ID.
+Cert check — what does locking prevent?
State locking stops two applies from writing state at the same time and corrupting it. It is acquired for the duration of a write operation and released after. Not all backends support it; S3 uses a DynamoDB table as the lock.
06 — Environments
Workspaces
CLI workspaces give one configuration multiple independent state files under the same backend. Switch with terraform workspace new staging / select staging, and branch on terraform.workspace inside config.
Caveat. Workspaces share the same backend, config, and credentials, so they are weak isolation. For real environment separation (prod vs staging), most teams prefer separate directories or separate backend keys, not workspaces alone.
07 — Escape hatch
Provisioners
Provisioners run scripts as part of resource creation or destruction — local-exec (on the machine running Terraform), remote-exec and file (on the target, over a connection). HashiCorp calls them a last resort: they break the declarative model and aren't tracked in state.
Prefer the cloud-native path — pass a startup script through user_data / cloud-init, or bake an image with Packer — before reaching for a provisioner.
08 — Workflow
CLI commands
The commands you'll type every day, in roughly the order you meet them.
| Command | What it does |
|---|---|
| init | Downloads providers and configures the backend. Run first, and after backend or provider changes. |
| fmt | Rewrites files to canonical style. Run in CI with -check. |
| validate | Checks syntax and internal consistency without touching any provider. |
| plan | Shows the diff between config and state. Save with -out=tfplan. |
| apply | Enacts the plan. apply tfplan applies a saved plan with no re-prompt. |
| destroy | Destroys everything in state. Equivalent to apply -destroy. |
| output | Prints output values; -json for machine use. |
| state | list, show, mv, rm — inspect and surgically edit state. |
| import | Brings an existing object under Terraform management. |
| -replace= | Forces one resource to be recreated (replaces the deprecated taint). |
| console | An interactive REPL for evaluating expressions against state. |
09 — Craft
Best practices & gotchas
- →Use a remote backend with locking from day one; never commit .tfstate or .tfvars holding secrets.
- →Pin provider versions (required_providers) and module versions. Commit the .terraform.lock.hcl lock file.
- →Keep modules small with a clear input/output interface. Compose, don't nest deeply.
- →Always plan and read the diff before apply. Wire fmt -check and validate into CI.
- →Favour for_each over count for anything that will change; avoid provisioners.
Gotchas that bite
+count re-indexes on removal
With count, instances are addressed by position ([0], [1]…). Delete a middle item and every later one shifts index — Terraform destroys and recreates them. for_each keys by a string and is stable.
+Secrets live in state in plaintext
Passwords, keys, and any sensitive value are stored unencrypted inside state. Encrypt the backend and lock down who can read it. Prefer generating secrets outside Terraform where you can.
+Drift: reality changed out of band
If someone edits a resource in the console, the next plan shows a diff to pull it back. Use lifecycle { ignore_changes = [...] } for fields you intentionally let others manage.
10 — Patterns
Example configs
Complete, copy-ready starting points. Switch between them: