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.
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.
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:
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
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 plannedRead 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.
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ identifier = "orders-db" -> "orders-database" # forces replacementApplying 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.tfstateon a laptop can't be shared, and two people applying at once corrupt it. Use a remote backend (an S3 bucket withuse_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 = trueon an RDS instance stores the password in Secrets Manager), or use write-only arguments such aspassword_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.
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# 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:
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"):
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:
import {
to = aws_s3_bucket.legacy_logs
id = "acme-legacy-logs"
}And protect resources whose loss would be a disaster:
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:
- On every pull request:
fmt -check,validate, security scanning, andplan. Post the plan output to the pull request for review. - On merge to main:
planagain 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). - Production applies run in a protected environment that requires approval.
- 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:
- A
modules/private-bucketmodule that creates a bucket with versioning, a public access block, server-side encryption and a lifecycle rule expiring noncurrent versions afterretention_days. It outputs the bucket'sarnandid. envs/stagingandenvs/prodroot modules, each with its own backend key, creatingreportsanduploadsbuckets viafor_each.- Rename the module call from
bucketstostoragein prod without replacing the buckets. - Run
terraform validateandterraform plan(against a sandbox account, or LocalStack if you have no cloud account).
Show solution
# modules/private-bucket/variables.tf
variable "name" {
type = string
}
variable "retention_days" {
type = number
default = 30
}
variable "tags" {
type = map(string)
default = {}
}# 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
}# modules/private-bucket/outputs.tf
output "arn" {
value = aws_s3_bucket.this.arn
}
output "id" {
value = aws_s3_bucket.this.id
}# 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_eachovercount, and a separate directory and state per environment. - Refactor with
movedandimportblocks, protect critical resources withprevent_destroy, and apply only from CI with OIDC credentials.
Check yourself
Quiz
0/4 correct · 4 left
1.A plan shows -/+ next to your production database with '# forces replacement'. What does that mean?
2.Why use for_each with a map instead of count for a set of similar buckets?
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.Which statement about Terraform state is true?
Finished reading?
Mark it complete to fill in your roadmap.