Step 12 of 25

Infrastructure as code with Terraform

Manage cloud infrastructure with Terraform: resources and the dependency graph, remote state, modules, environments, refactoring and a CI workflow.

19 min 10 sections

Clicking through a cloud console works for one experiment and fails for everything after it: nobody can review the change, reproduce it in another account, or say why a security group allows port 22 from anywhere. Infrastructure as code (IaC) describes infrastructure in files that live in Git, are reviewed in pull requests and are applied by a tool. Terraform (and its open-source fork OpenTofu, which uses the same language and commands) is the most widely used. This lesson teaches Terraform properly, from the language to state, modules and environments, so the IaC security lesson can focus on securing it.

Why infrastructure as code

  • Review: every change is a diff in a pull request, so a teammate (and an automated scanner) sees "this opens port 22 to the internet" before it happens.
  • Reproducibility: staging and production are built from the same code, so "works in staging" means something.
  • History and rollback: Git records who changed what and why; reverting a commit and applying reverts the infrastructure.
  • Drift detection: the tool can tell you when someone changed something by hand.

Terraform is declarative: you describe the end state ("a bucket with versioning on"), not the steps. Terraform works out what to create, change or destroy to get there.

The language: providers, resources and references

A Terraform configuration is a directory of .tf files written in HCL. Providers are plugins that talk to an API (AWS, Google Cloud, Azure, Cloudflare, GitHub, Kubernetes). Resources are things the provider manages.

hcl
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_s3_bucket" "reports" {
  bucket = "acme-reports-prod"
}

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

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

aws_s3_bucket.reports.id is a reference. It does two jobs: it passes the bucket's ID into the other resources, and it tells Terraform that they depend on the bucket. Terraform builds a dependency graph from references and creates independent resources in parallel and dependent ones in order. You almost never need depends_on; if you do, it's usually a hidden dependency worth commenting.

The rest of the language:

hcl
variable "environment" {
  type        = string
  description = "Deployment environment: staging or prod."
  validation {
    condition     = contains(["staging", "prod"], var.environment)
    error_message = "environment must be staging or prod."
  }
}

locals {
  name_prefix = "acme-${var.environment}"
  common_tags = { environment = var.environment, managed-by = "terraform" }
}

data "aws_caller_identity" "current" {} # reads, never creates

output "reports_bucket_arn" {
  value = aws_s3_bucket.reports.arn
}
  • Variables are inputs; give them types, descriptions and validation.
  • Locals are named expressions, for values you'd otherwise repeat.
  • Data sources read existing things (the current account ID, an AMI, a VPC managed elsewhere) without managing them.
  • Outputs expose values to the user or to other configurations.

Pin provider versions (~> 6.0 allows 6.x but not 7.0) and commit the generated .terraform.lock.hcl, which records exact versions and checksums so every machine uses identical provider binaries.

Plan and apply

bash
terraform init               # download providers, set up the state backend
terraform plan -out=tfplan   # compare code with reality, save the proposed changes
terraform apply tfplan       # apply exactly what was planned

Read plans carefully. The symbols tell you what will happen: + create, ~ update in place, - destroy, and -/+ destroy and recreate, which for a database means data loss. Some attribute changes force replacement; the plan marks them with # forces replacement.

text
  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ identifier = "orders-db" -> "orders-database" # forces replacement

Applying a saved plan file guarantees that what you reviewed is what runs; if the state has changed since the plan was made, Terraform refuses to apply it as stale. Running terraform apply without a plan computes a fresh one, which may differ from what you reviewed.

State: Terraform's memory

Terraform maps each resource in your code to a real object through its state: a JSON document recording IDs and attributes of everything it manages. Without state, Terraform can't know that aws_s3_bucket.reports is the bucket acme-reports-prod.

State is the part of Terraform that teams most often get wrong:

  • Store it remotely, with locking. A local terraform.tfstate on a laptop can't be shared, and two people applying at once corrupt it. Use a remote backend (an S3 bucket with use_lockfile = true, Google Cloud Storage, Azure Blob, or HCP Terraform) so state is shared and only one apply runs at a time.
  • It contains secrets. Generated passwords and keys appear in state in plain text. Encrypt the backend, restrict who can read it as strictly as production credentials, and enable versioning on the state bucket so a corrupted state can be restored. Better still, keep secrets out of state: let the service own them (manage_master_user_password = true on an RDS instance stores the password in Secrets Manager), or use write-only arguments such as password_wo (Terraform 1.11+), which are sent to the provider but never saved.
  • Never edit it by hand. Use the commands: terraform state list, terraform state show <address>, and the refactoring blocks below.

Drift is when reality no longer matches state, usually because someone changed a resource in the console. terraform plan -refresh-only shows drift without proposing changes; a normal plan proposes changing reality back to the code. Running a scheduled drift check in CI and alerting on differences catches manual changes early.

Modules: reusable building blocks

A module is a directory of Terraform files with variables as inputs and outputs as its interface. Every configuration is already a module (the root module); you create child modules to reuse patterns.

text
modules/
  private-bucket/
    main.tf        # bucket, versioning, public access block, encryption
    variables.tf   # name, tags, retention_days
    outputs.tf     # arn, id
envs/
  staging/main.tf
  prod/main.tf
hcl
# envs/prod/main.tf
module "reports_bucket" {
  source         = "../../modules/private-bucket"
  name           = "acme-reports-prod"
  retention_days = 365
}

module "uploads_bucket" {
  source         = "../../modules/private-bucket"
  name           = "acme-uploads-prod"
  retention_days = 30
}

Good modules encode your organisation's defaults: a private-bucket module that always blocks public access and enables encryption means nobody can forget to. That's one of the most effective security controls IaC gives you. Keep modules small and focused, version shared modules (a Git tag in source, or a registry version), and avoid modules that wrap a single resource without adding anything.

To create several similar resources, use for_each with a map, not count:

hcl
module "buckets" {
  source   = "../../modules/private-bucket"
  for_each = { reports = 365, uploads = 30, exports = 7 }

  name           = "acme-${each.key}-prod"
  retention_days = each.value
}

With count, resources are addressed by position ([0], [1]); removing the first item shifts the rest and Terraform plans to destroy and recreate them. for_each addresses them by key (["uploads"]), so removing one touches only that one.

Environments

Staging and production must have separate state, so a mistake in staging can't touch production. The most common and clearest layout is a directory per environment, each a small root module calling shared modules with different inputs, each with its own backend key (orders/staging.tfstate, orders/prod.tfstate), and ideally its own cloud account and credentials.

Terraform workspaces (terraform workspace new staging) also give separate state from one directory, but every environment then shares the same code and backend configuration, and it's easy to apply in the wrong workspace. Prefer directories for environments; workspaces suit short-lived copies such as per-branch preview stacks.

Split large configurations into several smaller ones (network, data, application), each with its own state. A plan over 2,000 resources is slow, hard to review, and gives every change a large blast radius. Share values between them with outputs and data sources, not by copying IDs.

Refactoring safely

Renaming a resource in code looks to Terraform like "delete the old one, create a new one". For a database, that's an outage. Tell Terraform it's the same object with a moved block (if you renamed or added a module call, run terraform init again first, or plan fails with "Module not installed"):

hcl
moved {
  from = aws_s3_bucket.reports
  to   = module.reports_bucket.aws_s3_bucket.this
}

To bring an existing, hand-made resource under Terraform, use an import block (Terraform 1.5+), then run plan until it shows no changes:

hcl
import {
  to = aws_s3_bucket.legacy_logs
  id = "acme-legacy-logs"
}

And protect resources whose loss would be a disaster:

hcl
resource "aws_db_instance" "main" {
  # ...
  deletion_protection = true # enforced by AWS itself
  lifecycle {
    prevent_destroy = true   # Terraform refuses any plan that destroys it
  }
}

Terraform in CI

Applying from laptops doesn't scale: credentials spread, applies aren't reviewed and nobody knows what ran. A standard pipeline:

  1. On every pull request: fmt -check, validate, security scanning, and plan. Post the plan output to the pull request for review.
  2. On merge to main: plan again and apply that saved plan. The pull request's plan is usually stale by then, because other changes may have merged first. Use short-lived cloud credentials from OIDC federation, never long-lived access keys stored as secrets (the cloud IAM lesson shows how).
  3. Production applies run in a protected environment that requires approval.
  4. A scheduled job runs plan -detailed-exitcode (exit code 2 means changes are pending) to detect drift.

Practice

Write a small Terraform project for an "orders" service with two environments:

  1. A modules/private-bucket module that creates a bucket with versioning, a public access block, server-side encryption and a lifecycle rule expiring noncurrent versions after retention_days. It outputs the bucket's arn and id.
  2. envs/staging and envs/prod root modules, each with its own backend key, creating reports and uploads buckets via for_each.
  3. Rename the module call from buckets to storage in prod without replacing the buckets.
  4. Run terraform validate and terraform plan (against a sandbox account, or LocalStack if you have no cloud account).
Show solution
hcl
# modules/private-bucket/variables.tf
variable "name" {
  type = string
}

variable "retention_days" {
  type    = number
  default = 30
}

variable "tags" {
  type    = map(string)
  default = {}
}
hcl
# modules/private-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.name
  tags   = var.tags
}

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

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

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true # far fewer KMS requests, so far lower cost
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    id     = "expire-noncurrent"
    status = "Enabled"
    filter {}
    noncurrent_version_expiration {
      noncurrent_days = var.retention_days
    }
  }
  depends_on = [aws_s3_bucket_versioning.this] # configure versioning before rules about noncurrent versions
}
hcl
# modules/private-bucket/outputs.tf
output "arn" {
  value = aws_s3_bucket.this.arn
}

output "id" {
  value = aws_s3_bucket.this.id
}
hcl
# envs/prod/main.tf
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "orders/prod.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

provider "aws" {
  region = "eu-west-1"
}

module "storage" {
  source   = "../../modules/private-bucket"
  for_each = { reports = 365, uploads = 30 }

  name           = "acme-orders-${each.key}-prod"
  retention_days = each.value
  tags           = { environment = "prod", service = "orders" }
}

moved {
  from = module.buckets
  to   = module.storage
}

envs/staging/main.tf is identical except for the backend key (orders/staging.tfstate), the names (-staging) and shorter retention. After renaming the module call, run terraform init (Terraform installs modules per call name), then terraform plan: it reports each bucket's resources as has moved to module.storage[...], with Plan: 0 to add, 0 to change, 0 to destroy.

Two details in the module are worth knowing. S3 already encrypts every new object with S3-managed keys by default; choosing aws:kms means access to the key is controlled by IAM and every use is logged in CloudTrail, and the bucket key keeps the KMS bill down. The depends_on is one of the rare legitimate uses: the lifecycle configuration doesn't reference the versioning resource, yet its rule is about noncurrent versions, which only exist once versioning is on. The AWS provider's documentation recommends exactly this ordering.

Key takeaways

  • IaC makes infrastructure reviewable, reproducible and auditable; Terraform compares declared resources with reality and plans the difference.
  • References build the dependency graph; read every plan for -/+ replacements before applying, and apply saved plans.
  • State maps code to real resources: keep it remote, locked, encrypted, versioned and tightly access-controlled.
  • Use modules to encode secure defaults, for_each over count, and a separate directory and state per environment.
  • Refactor with moved and import blocks, protect critical resources with prevent_destroy, and apply only from CI with OIDC credentials.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A plan shows -/+ next to your production database with '# forces replacement'. What does that mean?

  2. 2.Why use for_each with a map instead of count for a set of similar buckets?

  3. 3.You rename resource aws_s3_bucket.reports to aws_s3_bucket.report_files in code. How do you avoid Terraform deleting and recreating the bucket?

  4. 4.Which statement about Terraform state is true?

Finished reading?

Mark it complete to fill in your roadmap.