Skip to content

Terraform — the 80/20 course for AWS infrastructure

Scope: provision and operate AWS infrastructure from a control machine or a CI runner, with reviewable plans, durable remote state, and a sane module structure. Custom providers, Terraform Cloud/Enterprise specifics, on-prem providers, and Terragrunt deep dives are intentionally out of scope — pointers at the end.

Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.


TL;DR — in 30 seconds:

  • Terraform replaces console-clicking with declarative config: every managed resource lives in state, every apply is preceded by a plan that shows the exact diff before anything moves, and modules compose cloud resources behind explicit inputs/outputs.
  • State, not config, is the source of truth for what's managed — config says "I want X", state says "I'm tracking these specific X instances"; plan is read-only, apply is the only mutator.
  • The classic S3-backend lock uses a DynamoDB table; Terraform 1.11 GA'd S3-native locking (use_lockfile = true) so a lock table is no longer required on current versions.

1. Why Terraform — and when not to use it

Terraform replaces console-clicking and one-off scripts with one declarative configuration that:

  • Records what you've created. Every managed resource is in state. You can list it, diff it, refactor its address, delete it without touching the cloud.
  • Plans before it acts. Every apply is preceded by a plan — a precise list of API calls that will happen. Reviewers (and you) see the exact diff before anything moves.
  • Composes. Modules are functions over inputs that emit resources and outputs. You build a root module from child modules — VPC, EKS, RDS, security groups — wired together with explicit interfaces.
  • Is provider-pluggable. AWS, GCP, Azure, Cloudflare, Datadog, GitHub, Vault — every notable cloud or SaaS ships a Terraform provider. Same language, same workflow, everywhere.

When not to reach for Terraform:

  • You need real-time orchestration of events (failover, autoscale decisions) → Terraform is plan/apply, not a control loop. Use Lambda, EventBridge, or the cloud-native autoscaler.
  • You need application configuration management on running hosts → Ansible. Terraform doesn't enter EC2 instances; it creates them.
  • You need pure container deployment → Helm/Helmfile. Terraform creates the cluster; Helm runs on top.
  • You only ever provision two resources, never to be reused → an AWS console click might genuinely be the right answer. Don't introduce IaC for a one-off.

The 80/20 sweet spot Terraform nails: the cloud-side infrastructure that your apps run on — VPC, subnets, load balancers, databases, queues, buckets, IAM, the EKS cluster itself.

A note on the fork: HashiCorp relicensed Terraform to BSL in August 2023, and OpenTofu is the MPL-2.0 fork (Linux Foundation). The CLI is drop-in compatible for the 80/20 surface; everything in this course works on both. Use whichever your org standardises on.


2. Mental model

+--------------------+        +----------------+        +----------------+        +----------------+
|  HCL config        |        |  terraform     |        |  Plan file     |        |  Cloud APIs    |
|  (.tf files)       |        |  init          |        |  (binary)      |        |  (AWS, GCP, …) |
|                    |        |                |        |                |        |                |
|  - resource blocks |  read  |  download      | plan   |  exact list of | apply  |  POST/PUT/     |
|  - data sources    | -----> |  providers,    | -----> |  API calls     | -----> |  DELETE        |
|  - variables       |        |  modules       |        |                |        |                |
|  - outputs         |        |  configure     |        |                |        |                |
|  - module calls    |        |  backend       |        |                |        |                |
+--------------------+        +----------------+        +----------------+        +-------+--------+
                                       ^                                                  |
                                       |                                                  |
                                       |                                                  v
                                +------+---------+                                +-------+--------+
                                |  Backend       |  state read/write              |  Cloud         |
                                |  (S3 + DDB)    | <----------------------------- |  resources     |
                                +----------------+                                +----------------+

Three things to internalise:

  1. State is the source of truth for what Terraform manages. Not the config. Config says "I want X"; state says "I'm currently tracking these specific X instances with these IDs". Without state, terraform plan doesn't know what to update vs create vs leave alone.
  2. Plan is read-only; apply is the only mutator. Every cloud-changing action goes through apply. Diff in your head between "I'm planning" and "I'm applying" — the friction of typing apply is intentional.
  3. A provider is a Go binary downloaded at init. It bridges Terraform's abstract resource model to a real API. The provider is what defines aws_s3_bucket's schema; Terraform itself doesn't know what S3 is.

3. Setup on the control machine

# Terraform 1.6+
brew install terraform                                    # or: https://developer.hashicorp.com/terraform/install
# OR OpenTofu 1.6+
brew install opentofu

terraform version                                         # confirm

# AWS credentials — use any method aws-cli honors
aws configure sso                                         # SSO; preferred for personal accounts
aws sts get-caller-identity                               # check it works

Terraform reads AWS credentials the same way aws-cli does: environment variables, the credential file, the SSO profile, IAM roles for service accounts in EKS, instance profiles on EC2. You don't configure credentials in HCL.

Your repo layout for a single env, single-region setup:

infra/
├── terraform.tf            # terraform block: required_providers, backend
├── providers.tf            # provider configurations
├── variables.tf            # input variables
├── locals.tf               # local values
├── main.tf                 # resources and module calls (or split per concern)
├── outputs.tf              # outputs
├── terraform.tfvars        # default variable values
└── modules/
    ├── network/
    └── storage/

Initialise:

cd infra
terraform init                                            # download providers + modules; configure backend
terraform fmt -recursive                                  # canonicalise style
terraform validate                                        # static check

terraform init is idempotent; re-run any time you change providers, modules, or backend. Always commit the resulting .terraform.lock.hcl (it pins exact provider versions).


4. HCL essentials

HCL is HashiCorp Configuration Language. Everything is a block with optional labels and a brace-delimited body:

# Block with two labels (the most common form).
resource "aws_s3_bucket" "logs" {
  bucket = "hiro-prod-logs"          # argument

  tags = {                            # nested argument value (a map)
    Owner = "platform"
    Env   = "prod"
  }

  lifecycle {                         # nested block
    prevent_destroy = true
  }
}

# Block with no labels.
locals {
  name_prefix = "hiro-prod"
}

# Block with one label.
variable "region" {
  type    = string
  default = "eu-west-1"
}

Top-level blocks you'll see constantly:

Block Purpose
terraform CLI-level config: required version, required providers, backend.
provider Configure a provider instance (credentials, region, defaults).
resource Declare one cloud resource to be managed.
data Read existing data from the cloud without modifying it.
module Call a child module.
variable Declare an input parameter to this module.
output Expose a value to the caller / terraform output.
locals Define module-scoped named expressions.
import Import an existing resource into state (1.5+).
moved Refactor: record that a resource was renamed without recreating.

Three idioms that come up constantly:

# References — read another element's value.
subnet_id = aws_subnet.private[0].id
vpc_id    = module.network.vpc_id
account   = data.aws_caller_identity.current.account_id

# String interpolation — embed an expression in a string.
name = "${local.name_prefix}-bucket"
# You almost never need ${...}; bare references work where the value type matches.
name = "${local.name_prefix}-${var.env}-logs"

# Heredoc — multi-line string.
policy = <<-EOT
  {
    "Version": "2012-10-17",
    "Statement": [...]
  }
EOT

5. Your first resource

A minimum-viable main.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }
}

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project = "hiro"
      Env     = var.env
      Owner   = "platform"
    }
  }
}

variable "region" {
  type    = string
  default = "eu-west-1"
}

variable "env" {
  type    = string
  default = "dev"
}

resource "aws_s3_bucket" "logs" {
  bucket = "hiro-${var.env}-logs"
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

output "logs_bucket" {
  value = aws_s3_bucket.logs.bucket
}

The standard loop:

terraform init                                            # download AWS provider, configure backend
terraform fmt
terraform validate
terraform plan -out plan.bin                              # the diff; saves a binary plan file
terraform apply plan.bin                                  # executes exactly what plan said
terraform output                                          # prints `logs_bucket = "hiro-dev-logs"`

Two things to notice on the second run:

terraform plan -out plan.bin
# Plan: 0 to add, 0 to change, 0 to destroy.
terraform apply plan.bin
# No changes. Your infrastructure matches the configuration.

That's idempotency. A second apply after a successful one is a no-op. If it isn't, your config references something non-deterministic (a timestamp() call, an external data source whose value moves).


6. Variables, outputs, locals

The three pieces of module IO:

Variables — inputs. Declared with variable, set via terraform.tfvars, -var, -var-file, or TF_VAR_* env vars.

# variables.tf
variable "region" {
  description = "AWS region for all resources."
  type        = string
  default     = "eu-west-1"
}

variable "env" {
  description = "Environment name."
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.env)
    error_message = "env must be one of: dev, staging, prod."
  }
}

variable "subnet_cidrs" {
  description = "Per-subnet CIDR blocks."
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}

variable "instance_count" {
  type    = number
  default = 2
}

variable "tags" {
  type    = map(string)
  default = {}
}

variable "database_password" {
  type      = string
  sensitive = true
}

Pass values:

# terraform.tfvars  (auto-loaded)
region = "eu-west-1"
env    = "prod"
terraform apply -var-file=prod.tfvars
TF_VAR_database_password='hunter2' terraform apply
terraform apply -var env=staging

Precedence (lowest to highest): variable default → terraform.tfvars / *.auto.tfvarsTF_VAR_* env → -var / -var-file (in order on CLI).

Outputs — exports. Read by callers and by terraform output.

# outputs.tf
output "logs_bucket" {
  description = "Name of the logs bucket."
  value       = aws_s3_bucket.logs.bucket
}

output "database_endpoint" {
  description = "RDS endpoint."
  value       = aws_db_instance.main.endpoint
  sensitive   = true                                       # hides from output
}
terraform output                                          # human-readable
terraform output -json                                    # for jq / scripts
terraform output -raw logs_bucket                         # stripped string

Locals — module-scoped intermediates. No external interface; just a way to name a complex expression.

# locals.tf
locals {
  name_prefix = "${var.project}-${var.env}"

  tags = merge(var.tags, {
    Project = var.project
    Env     = var.env
    Owner   = "platform"
  })

  azs = slice(data.aws_availability_zones.available.names, 0, 3)
}

Use locals to keep main.tf readable and to compute values once.


7. Expressions, types, references

HCL has primitive types (string, number, bool), the special null, and collection/structural types (list, set, map, object, tuple).

Things you'll do constantly:

# References — read other elements
subnet_id = aws_subnet.private[0].id
vpc_id    = module.network.vpc_id
region    = var.region
prefix    = local.name_prefix

# Conditional
size = var.env == "prod" ? "db.r6i.large" : "db.t4g.small"

# Function call
cidr  = cidrsubnet(var.vpc_cidr, 4, 0)
ids   = toset(["a", "b", "c"])
hash  = sha256(file("${path.module}/policies/foo.json"))
json  = jsonencode({ foo = 1, bar = ["a", "b"] })
yaml  = yamldecode(file("${path.module}/spec.yaml"))

# for expression
public_ips   = [for inst in aws_instance.web : inst.public_ip]
subnet_by_az = { for s in aws_subnet.private : s.availability_zone => s.id }

# Splat (sugar for `[for x in y : x.attr]` over count/for_each results)
private_ids = aws_subnet.private[*].id

# null = "absent"
kms_key_id = var.encrypt ? aws_kms_key.main.id : null

# try (returns first non-failing expression)
extra_tags = try(jsondecode(var.extra_tags_json), {})

The function reference (terraform-docs functions) is long; the ones you'll use:

file, fileexists, templatefile          # file IO
jsonencode, jsondecode, yamlencode, yamldecode
length, contains, lookup, merge, keys, values
toset, tolist, tomap                    # type conversions
concat, distinct, sort, slice, flatten
cidrsubnet, cidrhost, cidrnetmask       # CIDR math
sha256, base64encode, base64decode
timestamp, formatdate                   # avoid in resource args (non-deterministic)
substr, format, formatlist, replace, lower, upper, trimspace
coalesce, coalescelist, try, can

terraform console is the fastest way to learn them — drop in and call them against your real values:

terraform console
> cidrsubnet("10.0.0.0/16", 4, 2)
"10.0.32.0/20"
> [for s in aws_subnet.private : s.id]
[...]

8. Iteration — count, for_each, dynamic

Three primitives. Use the right one.

count — when you want N identical instances and don't care which is which.

resource "aws_instance" "web" {
  count = 3
  ami           = "ami-0123456789abcdef0"
  instance_type = "t3.small"

  tags = {
    Name = "web-${count.index}"
  }
}

# Reference: aws_instance.web[0].id, aws_instance.web[1].id, …

Trouble: removing index 1 shifts indices 2 and 3 down → Terraform destroys and recreates instances. Avoid count for anything with state or identity.

for_each — when each instance has an identity (a name, a key). Pass a map or set.

resource "aws_subnet" "private" {
  for_each = {
    "private-a" = { cidr = "10.0.1.0/24", az = "eu-west-1a" }
    "private-b" = { cidr = "10.0.2.0/24", az = "eu-west-1b" }
    "private-c" = { cidr = "10.0.3.0/24", az = "eu-west-1c" }
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    Name = each.key
  }
}

# Reference: aws_subnet.private["private-a"].id

Removing the "private-b" key destroys only that subnet. The others are unchanged. This is what you want most of the time.

dynamic — generate a variable number of nested blocks inside a resource.

resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.from
      to_port     = ingress.value.to
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidrs
      description = ingress.key
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Rule of thumb: if you find yourself copy-pasting an ingress block four times, switch to dynamic. If you find yourself writing two unrelated dynamic blocks for one resource, the resource is doing two jobs — split it.


9. Modules — composition

A module is just a directory of .tf files. Call it from another module:

# infra/main.tf (root)
module "network" {
  source = "./modules/network"

  name       = local.name_prefix
  cidr_block = var.vpc_cidr
  azs        = local.azs
  tags       = local.tags
}

module "storage" {
  source = "./modules/storage"

  name = local.name_prefix
  vpc_id     = module.network.vpc_id
  log_bucket = "hiro-${var.env}-logs"
  tags       = local.tags
}

The child module declares its own variables and outputs; the calling module passes inputs as arguments and reads outputs via module.<name>.<output>.

A small modules/network/main.tf:

variable "name"       { type = string }
variable "cidr_block" { type = string }
variable "azs"        { type = list(string) }
variable "tags"       { type = map(string) default = {} }

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(var.tags, { Name = var.name })
}

resource "aws_subnet" "public" {
  for_each = { for i, az in var.azs : "public-${az}" => { cidr = cidrsubnet(var.cidr_block, 4, i), az = az } }

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value.cidr
  availability_zone       = each.value.az
  map_public_ip_on_launch = true

  tags = merge(var.tags, { Name = each.key })
}

output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = [for s in aws_subnet.public : s.id]
}

Sourcing. The source = argument tells Terraform where to fetch the module:

source = "./modules/network"                                 # local — fastest, no external dep
source = "terraform-aws-modules/vpc/aws"                     # public registry
source = "git::https://github.com/example/tf-modules.git//network?ref=v1.4.0"  # git
source = "git::ssh://git@github.com/example/tf-modules.git//network?ref=v1.4.0"

Pin everything. Public registry modules: version = "5.1.0". Git modules: ?ref=v1.4.0 (tag), never a moving branch.

Designing modules. Two non-negotiables:

  1. Modules don't have hidden inputs. Everything they need comes from variables. No environment variables, no implicit terraform.workspace reads (unless you genuinely want workspace-aware modules — say so loudly).
  2. Modules expose useful outputs. A VPC module should output vpc_id, public_subnet_ids, private_subnet_ids, route_table_ids. Callers shouldn't need to know what's inside.

Module size hint: if the module is fewer than 30 lines of HCL, it's probably premature abstraction. If it's more than 800, it's probably two modules.


10. Providers in depth

A provider configuration tells Terraform how to talk to one cloud:

provider "aws" {
  region = var.region

  default_tags {
    tags = local.tags
  }
}

Multiple instances via alias. When you need a second region or a second account:

provider "aws" {
  alias  = "us"
  region = "us-east-1"
}

resource "aws_acm_certificate" "cloudfront_cert" {
  provider          = aws.us              # ACM for CloudFront must live in us-east-1
  domain_name       = "example.com"
  validation_method = "DNS"
}

Passing providers into modules. A module declares the providers it needs:

# modules/cdn/providers.tf
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.us]
    }
  }
}

resource "aws_cloudfront_distribution" "this" {
  provider = aws.us
  # ...
}

The caller wires it up:

module "cdn" {
  source    = "./modules/cdn"
  providers = {
    aws.us = aws.us                       # map caller's alias to child's expected alias
  }
}

Version constraints. Pin in required_providers:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"                 # >= 5.40, < 6.0
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

terraform init writes .terraform.lock.hcl with the exact resolved version. Commit it. terraform init -upgrade re-resolves; in a team, do it intentionally.


11. State — local vs remote, the S3 backend

Local state (the default) is terraform.tfstate in the working directory. Fine for learning, never for a team. Three problems: no locking (two operators can stomp each other), no sharing (everyone runs against their own state), no durability (rm -rf and you've lost it).

The standard production setup: S3 bucket for state + DynamoDB table for locks. Both AWS, both cheap, both manageable with Terraform itself (bootstrap once, then self-host).

The bootstrap (one-time, hand-written, applied with local state):

# bootstrap/main.tf
resource "aws_s3_bucket" "tfstate" {
  bucket = "hiro-tfstate-${var.org_id}"
}

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_dynamodb_table" "tflocks" {
  name         = "hiro-tflocks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

Then in your real root modules, configure the backend:

# infra/terraform.tf
terraform {
  required_version = ">= 1.6.0"

  backend "s3" {
    bucket         = "hiro-tfstate-abc123"
    key            = "infra/prod/terraform.tfstate"        # one key per env / per module
    region         = "eu-west-1"
    dynamodb_table = "hiro-tflocks"
    encrypt        = true
  }
}

On first terraform init after adding the backend, Terraform offers to migrate local state into S3. Say yes.

A note on locking. The dynamodb_table above is the classic S3-backend lock. Terraform 1.11 promoted S3-native locking to GA: set use_lockfile = true in the backend "s3" block and the lock is held as a .tflock object in the state bucket itself — no DynamoDB table needed.

That's now the preferred path; the DynamoDB lock table is the legacy mechanism. This course pins 1.6 (which predates native locking), so the examples keep dynamodb_table — but on 1.11+ prefer use_lockfile = true (both can run side by side during a migration, then drop the table).

Key conventions.

  • One key per (env, root module). E.g. network/prod/terraform.tfstate, network/staging/terraform.tfstate, apps/prod/terraform.tfstate. Never share a key across envs — collisions destroy production.
  • Bucket: one per org, versioned, encrypted, blocked from public access.
  • Lock table: one per org.

State operations. Sometimes you have to fix state:

terraform state list                                       # all resources
terraform state show aws_s3_bucket.logs                    # one resource
terraform state mv aws_s3_bucket.logs module.storage.aws_s3_bucket.logs   # rename in state only
terraform state rm aws_iam_role.deprecated                 # forget; doesn't destroy in cloud
terraform state pull > terraform.tfstate.local             # download a copy (read-only debugging)

state mv is the refactor primitive: rename a resource block in config, then state mv to move the existing state entry without recreating the resource. The moved {} block (1.1+) does the same thing in code:

moved {
  from = aws_s3_bucket.logs
  to   = module.storage.aws_s3_bucket.logs
}

Drift. Run terraform plan -refresh-only to update state from the cloud and report differences without proposing any other changes. The right thing to schedule in CI on a daily cron — it surfaces manual changes early.

The whole loop, end to end:

flowchart LR
    Config["HCL config<br/>(desired state)"]
    State[("terraform.tfstate<br/>(last-known state)")]
    Cloud["Real infrastructure<br/>(actual state)"]
    Plan{"terraform plan<br/>diff: desired vs. refreshed"}
    Apply["terraform apply<br/>reconciles cloud to config"]

    Config --> Plan
    State -->|refresh| Plan
    Cloud -.->|drift?| Plan
    Plan -->|"+ / ~ / -"| Apply
    Apply --> Cloud
    Apply --> State
    State -.->|next run| Plan

12. Workspaces and environment patterns

Terraform offers two ways to handle dev/staging/prod.

Workspaces — same root module, multiple state keys.

terraform workspace new staging
terraform workspace new prod
terraform workspace select prod
terraform apply

The active workspace name is ${terraform.workspace}; the backend stores state under env:/prod/... automatically.

When workspaces work: short-lived ephemeral envs (per-PR review envs), feature branches. Cheap to spin up and tear down.

When workspaces don't work: production. The downside is that the same root module config has to fit every workspace, with branching by terraform.workspace. Branches multiply, reviews get harder, and accidentally running terraform destroy in the wrong workspace destroys the wrong env.

Per-env root module — the production default.

infra/
├── modules/
│   ├── network/
│   ├── storage/
│   └── apps/
├── envs/
│   ├── staging/
│   │   ├── terraform.tf       # backend key: envs/staging/terraform.tfstate
│   │   ├── providers.tf
│   │   ├── main.tf            # calls modules with staging values
│   │   ├── terraform.tfvars
│   │   └── variables.tf
│   └── prod/
│       ├── terraform.tf       # backend key: envs/prod/terraform.tfstate
│       ├── providers.tf
│       ├── main.tf
│       ├── terraform.tfvars
│       └── variables.tf

Each env has its own root module, its own state, its own provider config (maybe a different AWS account, a different region). The modules are shared. CI runs cd envs/<env> && terraform apply per env, gated by branch.

The price is duplication: two main.tfs that differ only in inputs. The benefit is isolation: there's no way to fat-finger a prod action while in a staging shell.

Default to per-env root modules — the isolation is worth the duplication.


13. Lifecycle and meta-arguments

Per-resource controls inside a lifecycle {} block:

resource "aws_db_instance" "main" {
  identifier     = "hiro-prod"
  instance_class = "db.r6i.large"
  # ...

  lifecycle {
    prevent_destroy       = true                            # any destroy plan errors
    create_before_destroy = true                            # replace-then-destroy on changes that require new
    ignore_changes        = [password]                      # rotated externally; don't drift on it
    replace_triggered_by  = [aws_kms_key.db.id]             # rebuild when KMS key rotates
  }
}

When to use each:

  • prevent_destroy = true — for genuinely irreplaceable resources: RDS prod instance, root S3 buckets, KMS keys with data encrypted under them. Forces you to remove the protection in code before any destroy can happen.
  • create_before_destroy = true — for resources that mustn't have a downtime window: a new load balancer target group, a new launch template, a new ACM cert with DNS validation. Watch out for name uniqueness — if the resource has a stable, unique name, you can't create the new one before destroying the old.
  • ignore_changes — for fields managed by another system: a Deployment's replicas managed by HPA, a tag added by AWS Backup. The list is field paths inside the resource.
  • replace_triggered_by — for "rebuild this when that changes" chains. Niche; useful when the resource doesn't naturally depend on the triggering attribute but should.

depends_on is a peer meta-argument (not inside lifecycle) — use it for hidden dependencies Terraform can't infer:

resource "aws_iam_role_policy_attachment" "ec2_ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_instance" "web" {
  # ...
  depends_on = [aws_iam_role_policy_attachment.ec2_ssm]      # ensure SSM policy is attached first
}

Use sparingly; over-using depends_on makes plans slow and graphs incomprehensible.


14. The everyday workflow

# 0. (One-time per checkout) Initialise
terraform init

# 1. Format and validate
terraform fmt -recursive
terraform validate

# 2. Plan (read-only)
terraform plan -out plan.bin
# Plan: 3 to add, 1 to change, 0 to destroy.

# 3. Inspect the plan
terraform show plan.bin                            # human-readable
terraform show -json plan.bin | jq .               # machine-readable

# 4. Apply exactly that plan
terraform apply plan.bin

# 5. Verify
terraform output
terraform state list

# 6. Iterate
# Edit .tf files...
terraform plan -out plan.bin                       # see what would change
terraform apply plan.bin                           # apply it

# 7. Drift detection
terraform plan -refresh-only                       # check whether cloud has diverged

Two flags worth memorising for iteration:

  • -target=aws_s3_bucket.logs — restrict plan/apply to one address and its dependencies. Use sparingly; broad use leads to confusing state. Acceptable for "let me get this one resource unstuck" moments.
  • -replace=aws_instance.web[0] — force destroy-and-recreate of one address on the next apply. The modern replacement for the deprecated terraform taint.

Destructive operations:

terraform destroy                                   # plan + apply, but the plan is "destroy everything"
terraform destroy -target=aws_s3_bucket.scratch     # destroy one resource (and its dependents)

terraform destroy always plans first and prompts. With -auto-approve it doesn't — never run that interactively against prod.


15. CI workflow

The shape every production setup converges to:

  1. On PR open / push:
  2. terraform fmt -check (style gate)
  3. terraform validate (syntax + types)
  4. terraform plan -out plan.bin (against the target env's state)
  5. Post the plan output as a PR comment.
  6. Optionally run policy checks (checkov, conftest against terraform show -json plan.bin).
  7. On merge to main:
  8. Re-plan (state may have drifted since PR).
  9. terraform apply -auto-approve against the planned change set.
  10. Notify on success/failure.
  11. Nightly:
  12. terraform plan -refresh-only -detailed-exitcode per env.
  13. If exit code is non-zero, notify — there's drift.

Two infrastructure pieces support this:

  • State locking — the DynamoDB lock prevents two CI runs (or a CI run + a human) from applying concurrently. Always-on once the S3 backend is configured.
  • IAM for CI — the CI runner authenticates with an IAM role assumable from the runner (OIDC trust on GitHub Actions, instance profile on a self-hosted runner). Never a long-lived access key.

Atlantis and Spacelift are open-source / commercial wrappers that turn this into a managed flow — PR comments containing plan output, apply via comment commands, multi-env routing. Worth it once your CI Terraform setup grows past ~5 root modules.

Terragrunt is a thin wrapper that adds DRY composition over multiple root modules — useful if you have ≥10 env × component combinations. Don't reach for it before then; the indirection costs you.


16. Worked example — a small AWS footprint

End-to-end. One root module per env, two local modules (network, storage), one provider, S3 backend.

Repo layout:

infra/
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── versions.tf
│   └── storage/
│       ├── main.tf
│       ├── variables.tf
│       ├── outputs.tf
│       └── versions.tf
└── envs/
    └── prod/
        ├── terraform.tf
        ├── providers.tf
        ├── variables.tf
        ├── locals.tf
        ├── main.tf
        ├── outputs.tf
        └── terraform.tfvars

infra/envs/prod/terraform.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }

  backend "s3" {
    bucket         = "hiro-tfstate-abc123"
    key            = "envs/prod/infra/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "hiro-tflocks"
    encrypt        = true
  }
}

infra/envs/prod/providers.tf:

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project = "hiro"
      Env     = var.env
      Owner   = "platform"
    }
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

infra/envs/prod/variables.tf:

variable "region"   { type = string }
variable "env"      { type = string }
variable "vpc_cidr" { type = string }

infra/envs/prod/locals.tf:

locals {
  name_prefix = "hiro-${var.env}"
  azs         = slice(data.aws_availability_zones.available.names, 0, 3)
}

infra/envs/prod/main.tf:

module "network" {
  source = "../../modules/network"

  name       = local.name_prefix
  cidr_block = var.vpc_cidr
  azs        = local.azs
}

module "storage" {
  source = "../../modules/storage"

  name       = local.name_prefix
  vpc_id     = module.network.vpc_id
  log_bucket = "${local.name_prefix}-logs"
}

infra/envs/prod/outputs.tf:

output "vpc_id"            { value = module.network.vpc_id }
output "private_subnets"   { value = module.network.private_subnet_ids }
output "logs_bucket"       { value = module.storage.logs_bucket }
output "logs_bucket_arn"   { value = module.storage.logs_bucket_arn }

infra/envs/prod/terraform.tfvars:

region   = "eu-west-1"
env      = "prod"
vpc_cidr = "10.10.0.0/16"

infra/modules/network/main.tf:

variable "name"       { type = string }
variable "cidr_block" { type = string }
variable "azs"        { type = list(string) }

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = { Name = var.name }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = var.name }
}

resource "aws_subnet" "public" {
  for_each = { for i, az in var.azs : "public-${az}" => { cidr = cidrsubnet(var.cidr_block, 4, i),     az = az } }

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value.cidr
  availability_zone       = each.value.az
  map_public_ip_on_launch = true

  tags = { Name = each.key }
}

resource "aws_subnet" "private" {
  for_each = { for i, az in var.azs : "private-${az}" => { cidr = cidrsubnet(var.cidr_block, 4, i + 8), az = az } }

  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = { Name = each.key }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }
  tags = { Name = "${var.name}-public" }
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

infra/modules/network/outputs.tf:

output "vpc_id"             { value = aws_vpc.this.id }
output "public_subnet_ids"  { value = [for s in aws_subnet.public  : s.id] }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }

infra/modules/storage/main.tf:

variable "name"       { type = string }
variable "vpc_id"     { type = string }
variable "log_bucket" { type = string }

resource "aws_kms_key" "logs" {
  description             = "${var.name} logs bucket KMS key"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

resource "aws_kms_alias" "logs" {
  name          = "alias/${var.name}-logs"
  target_key_id = aws_kms_key.logs.id
}

resource "aws_s3_bucket" "logs" {
  bucket = var.log_bucket

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.logs.arn
    }
  }
}

resource "aws_s3_bucket_public_access_block" "logs" {
  bucket                  = aws_s3_bucket.logs.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

infra/modules/storage/outputs.tf:

output "logs_bucket"     { value = aws_s3_bucket.logs.bucket }
output "logs_bucket_arn" { value = aws_s3_bucket.logs.arn }
output "logs_kms_key_id" { value = aws_kms_key.logs.id }

Run order, with verification at each step:

cd infra/envs/prod

# 0. One-time per checkout
terraform init

# 1. Static checks
terraform fmt -recursive ../..
terraform validate

# 2. Plan + review
terraform plan -out plan.bin
terraform show plan.bin                            # human-readable diff

# 3. Apply exactly the reviewed plan
terraform apply plan.bin

# 4. Verify
terraform output
terraform state list

# 5. Idempotency check — second apply should be a no-op
terraform plan -out plan.bin
# Plan: 0 to add, 0 to change, 0 to destroy.

# 6. Drift check
terraform plan -refresh-only

# 7. Iterate on one resource
terraform plan -target=module.storage -out plan.bin
terraform apply plan.bin

# 8. (Eventually) Decommission
# Remove `prevent_destroy = true` from the bucket lifecycle block first.
terraform plan -destroy -out plan.bin
terraform apply plan.bin

When that whole pipeline behaves, you have the 80%. Everything else (custom providers, advanced module composition, multi-region deployments with provider aliases, EKS + Helm bootstrap) is layered on top.


17. What's intentionally out of scope here

Topic Pointer when you need it
Terraform Cloud / Enterprise (HCP) The managed runner + state + policy layer. Pay the bill if you want managed; otherwise build the same with S3 backend + GitHub Actions.
Custom providers (Go SDK) Write a provider when none exists for your API. Niche; mostly internal platforms.
Terragrunt DRY wrapper around multi-root-module repos. Worth it past ~10 env × component combinations.
Pulumi / CDKTF / AWS CDK Imperative IaC alternatives in real programming languages. Different mental model; choose at the org level.
Sentinel / OPA policy as code Enforce rules over terraform show -json plan.bin. Add when you have ≥3 engineers shipping Terraform.
On-prem providers (vSphere, OpenStack) Provider-specific; the workflow is identical to AWS.
Terraform Cloud workspaces vs CLI workspaces Different concepts despite the name. Read the docs only if you're on HCP.
null_resource + local-exec Shell-out provisioner. Almost always a smell; prefer a real resource or move the action to CI.
Provisioners (remote-exec, file) Configure an instance over SSH after creation. Use Ansible (see module 08, Ansible) instead.
Migrating from 0.11 / 0.12 / 0.13 Assume 1.6+. Migration guides exist; you almost never need them today.

18. Reference card — daily commands

# Setup
terraform init
terraform init -upgrade                            # re-resolve providers
terraform init -reconfigure                        # re-read backend block

# Static checks
terraform fmt -recursive
terraform fmt -check                               # CI gate
terraform validate

# Plan / apply
terraform plan -out plan.bin
terraform show plan.bin
terraform show -json plan.bin | jq .
terraform apply plan.bin
terraform apply -auto-approve                      # CI only

# Targeted / replace
terraform plan -target=aws_s3_bucket.logs -out plan.bin
terraform apply -replace=aws_instance.web[0]

# Outputs
terraform output
terraform output -raw logs_bucket
terraform output -json | jq .

# State
terraform state list
terraform state show aws_s3_bucket.logs
terraform state mv aws_s3_bucket.logs module.storage.aws_s3_bucket.logs
terraform state rm aws_iam_role.deprecated
terraform state pull > state.local.json            # debug copy

# Drift
terraform plan -refresh-only
terraform plan -refresh-only -detailed-exitcode    # CI: nonzero means drift

# Console / debug
terraform console
TF_LOG=DEBUG terraform plan
TF_LOG_PATH=tf.log TF_LOG=TRACE terraform apply

# Destroy
terraform plan -destroy -out destroy.bin
terraform apply destroy.bin

# Workspaces
terraform workspace list
terraform workspace new feature-x
terraform workspace select prod
terraform workspace delete feature-x

That's the 80%. Go run terraform init && terraform plan against an empty config and read what falls out.


19. How this connects

  • This course's own four submodules — State, Modules, Providers & lifecycle, and Testing and CI — go deeper on §11's backend internals and drift, §9's composition patterns, §8/§10/§13's iteration/provider- alias/lifecycle mechanics, and §15's CI shape respectively. Read them next if a section here felt compressed.
  • Ansible (module 08, Ansible, course.md §21) — reciprocal: that page already points back here for this course's own §1 boundary, "Terraform doesn't enter EC2 instances; it creates them." Read together, Ansible's static EC2 patterns (its §17) are scoped to a fleet a terraform apply here has already produced.
  • Helmfile (module 06, Helmfile, course.md §20) — reciprocal: that page already names this course's plan-then-apply contract (§5, §14) as the same shape as its own diff-then-apply — both refuse to touch anything until a diff against real state says something changed.