Terraform is declarative IaC: you write desired infrastructure in HCL, it diffs
against state (a JSON record of what it manages) and makes the real world match.
The loop is init → plan → apply. Almost everything hard is about state.
1. Core concepts
| Term | What |
|---|---|
| Provider | Plugin for a platform (aws, google, azurerm, kubernetes). Exposes resources + data sources. |
| Resource | A managed infra object (aws_instance.web) — type + local name = address. |
| Data source | Read-only lookup of existing infra (data.aws_ami.x). |
| State | JSON mapping config → real resource IDs + cached attributes. Terraform's source of truth. |
| Module | Reusable folder of .tf with inputs (variables) + outputs. |
| Backend | Where state lives (local, S3+DynamoDB lock, GCS, Terraform Cloud). |
| Plan | The computed diff (create/update/replace/destroy) before applying. |
How a run works: Terraform reads config + refreshes state against real infra, builds a dependency graph, computes a diff, and applies it in dependency order (parallel where possible). Dependencies are implicit when one resource references another's attribute.
2. Workflow & CLI
terraform init # download providers, configure backend, install modules terraform fmt -recursive # canonical formatting terraform validate # syntax + internal consistency terraform plan # preview diff (read this EVERY time) terraform plan -out=tfplan # save a plan terraform apply tfplan # apply exactly that plan (CI-safe) terraform apply -auto-approve terraform destroy # tear down everything in state terraform apply -target=aws_instance.web # narrow (use sparingly — hides drift) terraform output # show outputs ; -json for machines terraform console # REPL to test expressions terraform graph | dot -Tsvg > graph.svg terraform providers ; terraform version
3. HCL essentials
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = var.region }
variable "env" { type = string default = "dev" }
variable "tags" { type = map(string) default = {} }
locals { name = "app-${var.env}" }
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter { name = "name" values = ["ubuntu/images/*-24.04-*"] }
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # implicit dependency
instance_type = "t3.micro"
tags = merge(var.tags, { Name = local.name })
}
output "ip" {
value = aws_instance.web.public_ip
sensitive = false
}
Types: string, number, bool, list, set, map, object, tuple. Common functions:
merge, lookup, coalesce, try,
for expressions, jsonencode, templatefile.
Variable precedence (high → low): CLI -var /
-var-file > *.auto.tfvars > terraform.tfvars > env
TF_VAR_* > default.
4. Meta-arguments
| Arg | Use |
|---|---|
count | N copies by index. Address res[0]. Good for identical resources. |
for_each | One per map/set key. Address res["key"] — stable. Preferred. |
depends_on | Explicit ordering when there's no attribute reference. |
provider | Pick a non-default provider alias (e.g. multi-region). |
lifecycle | create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by. |
resource "aws_iam_user" "u" {
for_each = toset(["alice", "bob"])
name = each.key
}
resource "aws_instance" "web" {
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastModified"]]
}
}
count keys by index — remove the middle item and everything after shifts and gets
recreated. for_each keys by name — stable, no churn. Prefer for_each
for anything you'll add to/remove from.5. State management
terraform state list # what's managed terraform state show# full attributes of one resource terraform import # adopt an existing resource into state terraform state mv # rename/move without recreating terraform state rm # stop managing (does NOT delete real infra) terraform refresh # (or plan -refresh-only) sync state to reality terraform force-unlock # release a stuck lock (only if sure)
# remote, locked, versioned backend (the right setup for teams)
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/app.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks" # state locking
encrypt = true
}
}
6. Drift & imports
Drift = real infra changed outside Terraform (a console click). Detect:
terraform plan -refresh-only. Reconcile by updating config to match reality, or let
Terraform revert the manual change. Import existing resources so TF manages them:
# import block (TF 1.5+) — reviewable, in code
import {
to = aws_s3_bucket.logs
id = "my-existing-bucket"
}
# then: terraform plan -generate-config-out=generated.tf
7. Modules
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "main"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
# consume outputs elsewhere:
resource "aws_instance" "web" { subnet_id = module.vpc.public_subnets[0] }
- Module sources: local path, Terraform Registry, Git, S3.
- Inputs =
variableblocks; returns =outputblocks. - Pin
versionon registry modules; keep modules small + composable.
8. Environments & workspaces
terraform workspace list / new staging / select prod # terraform.workspace is available in config
Workspaces give multiple states from one config — fine for light dev/staging splits. For real prod isolation prefer separate state files / directories per environment (and often separate backends/accounts) so a mistake in dev can't touch prod.
9. Provisioners (last resort)
local-exec / remote-exec run scripts during create/destroy. Avoid them
— they're not tracked in state, break idempotency, and fail unpredictably. Prefer cloud-init /
user_data, configuration management, or baked images (Packer). If you must, pair with
null_resource + triggers.
10. Best practices
- Remote, locked, versioned state backend; one state per environment.
- Pin
required_version, provider versions, and module versions. - Never commit secrets or state to git; mark sensitive outputs
sensitive = true. planin CI on every PR; gatedapplyon merge (apply the saved plan).- Prefer data sources +
for_eachover copy-paste; small modules. - Use
-targetandforce-unlockrarely — they hide problems. terraform fmt+validate+ a linter (tflint) + a policy gate (OPA/Sentinel) in CI.
11. Troubleshooting
| Problem | Fix |
|---|---|
| Error acquiring the state lock | Another apply running, or a crashed one. Confirm, then force-unlock <id>. |
| Plan wants to change untouched things | Drift — plan -refresh-only; reconcile config or accept reality. |
| AlreadyExists on apply | Resource exists outside state — terraform import. |
| Cycle error | Bad depends_on / mutual reference — let implicit deps order it. |
| Forces replacement unexpectedly | An immutable attribute changed; check the plan's "# forces replacement". Use create_before_destroy. |
| Need verbose logs | TF_LOG=DEBUG terraform apply (provider API calls). |
12. Rapid-fire Q&A
- What is state and why does it matter?JSON mapping config to real resource IDs. It's how TF knows what exists, computes diffs, and avoids recreating things. Lose/corrupt it and TF loses track of your infra.
- count vs for_each?count = index-keyed (shifts on removal → recreation); for_each = key-keyed (stable). Prefer for_each.
- plan vs apply?plan computes/preview the diff; apply executes it. In CI, save a plan and apply exactly that.
- How do you manage existing infra?terraform import (or import blocks) to bring it under state, then plan should be a no-op.
- How do teams share state safely?Remote backend with locking (S3+DynamoDB / TF Cloud) so applies serialize; encrypted, versioned.
- local vs variable vs output vs data?local = computed reuse; variable = input; output = exposed/module return; data = read existing infra.
- What is drift?Real infra changed outside Terraform. Detect with plan -refresh-only; reconcile.
- create_before_destroy?Lifecycle rule to make the replacement before destroying the old — avoids downtime on replace.
- Why avoid provisioners?Not in state, not idempotent, fail unpredictably. Prefer user_data / images / config management.
- How is dependency order decided?Implicitly, by attribute references between resources; depends_on only when there's no reference.