From "what is Infrastructure as Code?" to writing reusable modules with remote state and safe workflows. Follow it end to end, or jump to a section.
The journey:
| You need | Why |
|---|---|
| Terraform (or OpenTofu) CLI installed | Runs every command here |
| Docker running locally | The examples use the local Docker provider — no cloud account needed |
| A terminal + text editor | You'll write .tf files by hand |
You do not need an AWS/Azure/GCP account to follow along. Confirm the CLI is ready:
terraform version # 1.x reported
docker ps # Docker daemon reachable (the provider talks to it)
Terraform is an Infrastructure as Code tool: you declare the infrastructure you want (servers, networks, databases, DNS) in configuration files, and Terraform figures out the API calls to create, update, or destroy resources to match. It is:
The core loop: write config → plan (preview) → apply (execute) → state updated.
# macOS
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
terraform version
A minimal config using the local Docker provider (no cloud account needed):
# main.tf
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:1.27"
}
resource "docker_container" "web" {
name = "tf-web"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8080
}
}
terraform init # download the provider
terraform plan # preview: "+ 2 to add"
terraform apply # type 'yes' — nginx is now running on :8080
terraform destroy # tear it all down
You just managed real infrastructure declaratively.
This loop is the heart of Terraform, and it's what makes it safe:
terraform fmt # format code canonically
terraform validate # check syntax + types
terraform plan -out=tfplan # compute the diff, save it
terraform apply tfplan # apply exactly what you reviewed (no surprises)
Read plan output carefully — the symbols are the whole story:
| Symbol | Meaning |
|---|---|
+ |
create |
- |
destroy |
~ |
update in place |
-/+ |
replace (destroy then create — potential downtime/data loss!) |
Always review a plan before apply, and be especially alert to -/+ replacements on stateful resources.
Terraform stores a state file (terraform.tfstate) mapping your config to the real-world resource IDs. It's how Terraform knows that docker_container.web corresponds to a specific running container.
Why it matters:
plan diffs config against state, not against reality directly (though it refreshes).terraform state list # everything Terraform tracks
terraform state show docker_container.web
terraform state rm <addr> # stop tracking without destroying
terraform import <addr> <real-id> # adopt an existing resource into state
If state drifts from reality (someone changed a resource by hand), the next plan shows the difference so you can reconcile it. State is the single most important concept to internalize.
Hard-coding values doesn't scale. Parameterize with variables, expose results with outputs, and compute reusable values with locals.
# variables.tf
variable "container_name" {
type = string
default = "tf-web"
description = "Name of the web container"
}
variable "external_port" {
type = number
default = 8080
}
# locals.tf
locals {
common_tags = { managed_by = "terraform", env = terraform.workspace }
}
# outputs.tf
output "url" {
value = "http://localhost:${var.external_port}"
}
Set variables via defaults, -var, *.tfvars files, or TF_VAR_ env vars:
terraform apply -var="external_port=9090"
terraform apply -var-file="prod.tfvars"
terraform output url # read an output
Mark sensitive variables/outputs with sensitive = true so they're not printed.
Terraform builds a dependency graph automatically from references. When you write docker_image.nginx.image_id, Terraform knows the image must exist before the container — no manual ordering needed.
resource "docker_container" "web" {
image = docker_image.nginx.image_id # implicit dependency
}
For dependencies Terraform can't infer, use depends_on explicitly. Data sources let you read existing infrastructure you don't manage:
data "docker_registry_image" "nginx" {
name = "nginx:1.27"
}
Prefer implicit dependencies (references) over depends_on — they're self-documenting and more precise.
Create many similar resources without copy-paste.
# count — N identical-ish resources, indexed by number
resource "docker_container" "worker" {
count = 3
name = "worker-${count.index}"
image = docker_image.nginx.image_id
}
# for_each — a resource per key (stable identity, preferred for named sets)
resource "docker_container" "svc" {
for_each = toset(["api", "web", "cache"])
name = each.key
image = docker_image.nginx.image_id
}
Rule of thumb: use for_each when items have stable names (a map/set), because removing one from the middle won't reindex the rest. count reindexes on removal, which can cause unwanted replacements. Combine with for expressions and dynamic blocks for complex shapes.
A module is a folder of .tf files you can call repeatedly with different inputs — the key to DRY, maintainable infrastructure.
# modules/webapp/main.tf (variables in, outputs out)
variable "name" {}
variable "port" { default = 80 }
resource "docker_container" "this" {
name = var.name
image = "nginx:1.27"
ports {
internal = 80
external = var.port
}
}
output "container_id" { value = docker_container.this.id }
# root main.tf — call the module twice
module "blog" {
source = "./modules/webapp"
name = "blog"
port = 8081
}
module "shop" {
source = "./modules/webapp"
name = "shop"
port = 8082
}
Modules can come from the local path, the Terraform Registry, or Git. Keep them small and focused (a "network" module, a "database" module), version them, and pin the version when sourcing from a registry.
Local state doesn't work for teams — you'd overwrite each other and there's no locking. A remote backend stores state centrally with locking so two applies can't run at once.
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks" # state locking
encrypt = true
}
}
Benefits: shared state, encryption at rest, locking (via DynamoDB/native), and history. Alternatives: Terraform Cloud/HCP, Azure Storage, GCS. This is a hard requirement for any team — never share terraform.tfstate over Git or Slack.
Workspaces give you multiple state files from one configuration — handy for dev/staging/prod of the same infra:
terraform workspace new staging
terraform workspace select staging
terraform workspace list
# reference it in config via terraform.workspace
Caveat: workspaces share the same backend/config, so for strongly divergent environments many teams prefer separate directories or state keys per environment (with a module holding the shared definition) over workspaces. Use workspaces for lightweight variation, directory separation for real prod isolation.
plan before apply, and review -/+ replacements — they can destroy data.required_providers), and modules. Commit .terraform.lock.hcl.*.tfstate, *.tfvars with secrets, and .terraform/ out of Git.fmt -check, validate, plan on PRs (post the plan), apply on merge with approval.plan; reconcile rather than hand-editing.terraform import to adopt existing resources instead of recreating them.provisioner blocks (they're an escape hatch, not a config-management tool) — prefer provider resources or a dedicated tool.-/+ replace symbol on a database or disk means destroy-then-create — data loss. Always review, especially replacements on stateful resources.terraform.tfstate holds resource IDs and plaintext secrets. Keep it (and *.tfvars with secrets, and .terraform/) out of Git; use a remote backend.terraform state/import/mv instead..terraform.lock.hcl means teammates and CI silently get different provider versions. Commit it.count for named sets. Removing a middle item reindexes the rest and triggers needless replacements. Use for_each for anything with stable identity.provisioner blocks. They're a fragile escape hatch, not config management. Prefer provider resources or a tool like Ansible.nginx image tag in main.tf and run terraform plan. Does it show ~ (update) or -/+ (replace)? Explain why from the resource's arguments.variables.tf, add an output for the URL, and apply with -var and then a prod.tfvars. Read the output back with terraform output.for_each to stand up three named containers (api, web, cache) from one resource block. Then remove web from the set and check the plan — did api/cache stay put?webapp module (name + port in, container id out) and call it twice. Confirm terraform state list shows module.blog.* and module.shop.*.Self-check:
plan actually diff against — reality, or state?for_each safer than count for a set of named resources?You now have the full loop: write HCL → plan → apply → manage state → modularize → collaborate with remote state. That's zero to hero.
Learned the concepts? Test yourself with Terraform interview questions.
Go to the question bank →