TerraformXXVI · Cloud and Platform OperationsProduction Terraform
Terraform on AWS
What you'll learn
- Configure the hashicorp/aws provider with version constraints and an S3 remote backend
- Compose a minimal VPC with subnets, IGW, route table, and security groups
- Wire an EC2 instance, RDS database, and ALB together with explicit dependencies
- Apply IAM roles and policies via Terraform without exceeding least-privilege
- Recognise the cost of leaving AWS defaults in place
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
AWS is the most common target for production Terraform in 2026. The provider is mature, the resource coverage is deep, and the default account comes with enough knobs misconfigured that a sysadmin who treats the AWS console as the source of truth will spend the next year discovering bills and open security groups they never intended to create. This lesson covers the practical shape of an AWS Terraform configuration: provider pinning, state backend, the VPC stack, IAM, and a three-tier compute example.
Provider version and authentication
The hashicorp/aws provider is on the 5.x line as of August 2026.
Pinning is mandatory. Provider 5.x removed and renamed resources
that 4.x relied on; an unconstrained version constraint can break
the apply on a fresh terraform init.
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
}
backend "s3" {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "eu-west-2"
dynamodb_table = "acme-tfstate-lock"
encrypt = true
}
}
provider "aws" {
region = "eu-west-2"
default_tags {
tags = {
ManagedBy = "Terraform"
Environment = "production"
Owner = "platform@example.com"
Repository = "github.com/acme/infra"
}
}
}
The default_tags block is the single highest-leverage piece of
configuration in the file. AWS charges by resource, and untagged
resources are the resources nobody owns and nobody cleans up.
Default tags propagate to every resource the provider creates.
For credentials, do not put access keys in the configuration. Use
OIDC for CI (covered in the Azure lesson, the AWS pattern is the
same shape), and use IAM Identity Center (formerly SSO) for human
operators. The provider "aws" block takes credentials from the
environment or from ~/.aws/credentials; it does not declare
them in HCL.
The S3 backend and DynamoDB lock
┌──────────────────────┐
│ terraform apply │
│ on CI runner │
└──────────┬───────────┘
│ read / write
▼
┌──────────────────────┐ ┌──────────────────────┐
│ S3 bucket │ │ DynamoDB table │
│ acme-tfstate-prod │ │ acme-tfstate-lock │
│ terraform.tfstate │ │ lockid (hash key) │
│ versioned + KMS │ │ ttl on item │
└──────────────────────┘ └──────────────────────┘
▲ ▲
│ serialised by │
└──────── DynamoDB ────────────┘
Both pieces are required. The S3 bucket holds the state. The
DynamoDB table prevents two applies from running concurrently and
trampling each other’s writes. Without the table, two operators
running terraform apply at the same time will both succeed and
one of them will lose their changes silently.
To set them up once, by hand:
# READ-ONLY: inspect existing buckets
aws s3api list-buckets --query 'Buckets[?starts_with(Name, `acme-tfstate`)].Name'
# CONFIGURATION: create the bucket if it does not exist
aws s3api create-bucket \
--bucket acme-tfstate-prod \
--region eu-west-2 \
--create-bucket-configuration LocationConstraint=eu-west-2
# CONFIGURATION: enable versioning and encryption
aws s3api put-bucket-versioning \
--bucket acme-tfstate-prod \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket acme-tfstate-prod \
--server-side-encryption-configuration '{
"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]
}'
# CONFIGURATION: create the lock table
aws dynamodb create-table \
--table-name acme-tfstate-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region eu-west-2
After this is in place, terraform init reads the backend block
and uses the bucket. State is no longer on the runner’s disk.
The minimal VPC
A production VPC is not one resource. It is a stack with explicit dependencies and explicit subnet planning. The minimum viable shape:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "acme-prod-vpc"
}
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "eu-west-2a"
map_public_ip_on_launch = true
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
availability_zone = "eu-west-2a"
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "web"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
For real use, write the VPC as a module and parameterise the
availability zones, the CIDR blocks, and the public/private split.
The community terraform-aws-modules/vpc/aws module is the
de-facto starting point and is worth reading even if you end up
maintaining your own.
EC2 with IMDSv2 enforced
The single most common AWS security regression is an EC2 instance with IMDSv1 enabled and an IAM role attached. IMDSv1 lets any process on the instance request temporary credentials for the attached role with no authentication. A SSRF vulnerability in the web app turns into “attacker has the role’s credentials.”
resource "aws_iam_role" "web" {
name = "acme-web-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "web_ssm" {
role = aws_iam_role.web.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.small"
subnet_id = aws_subnet.private_a.id
vpc_security_group_ids = [aws_security_group.web.id]
iam_instance_profile {
role = aws_iam_role.web.name
}
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 only
http_put_response_hop_limit = 1 # block container SSRF
instance_metadata_tags = "enabled"
}
root_block_device {
encrypted = true
kms_key_id = aws_kms_key.ebs.arn
}
}
http_tokens = "required" is the IMDSv2-only setting. The
hop_limit = 1 setting blocks containers on the instance from
reaching IMDS — a critical defence against SSRF in a web app.
RDS, ALB, and the full shape
A typical three-tier stack looks like this:
resource "aws_db_subnet_group" "main" {
name = "main"
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
}
resource "aws_security_group" "db" {
name = "db"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
}
resource "aws_db_instance" "main" {
identifier = "acme-prod-db"
engine = "postgres"
engine_version = "16.4"
instance_class = "db.t3.medium"
allocated_storage = 100
max_allocated_storage = 500
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
username = "acme_app"
password = data.aws_ssm_parameter.db_password.value
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
backup_retention_period = 14
deletion_protection = true
skip_final_snapshot = false
multi_az = true
}
password = data.aws_ssm_parameter.db_password.value keeps the
password out of state. The value is fetched from SSM Parameter
Store at plan time. Combined with KMS encryption on the parameter
and IAM authorisation on the read, this is the production pattern.
resource "aws_lb" "web" {
name = "acme-web-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}
resource "aws_lb_target_group" "web" {
name = "web"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/healthz"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 15
}
}
The cost of AWS defaults
Leaving AWS defaults in place is the most common production mistake on the platform. A short list of defaults that bite:
- RDS is single-AZ by default. A single-AZ DB instance loses
its data on an AZ failure. Set
multi_az = true. - EBS volumes are not encrypted by default. Set
encrypted = trueon everyaws_ebs_volumeand everyroot_block_device. - S3 buckets are private but not versioned. A bucket without
versioning cannot be recovered from an accidental
aws s3 rm. Enable versioning and a lifecycle policy that expires noncurrent versions. - Security groups default-deny inbound, allow-all egress. The
egress rule is rarely reviewed. A web app that can
0.0.0.0/0egress can also phone home to a command-and-control endpoint after a compromise. Tighten egress to the destinations the service actually needs. - IMDSv1 is the legacy default. Newer accounts default to
IMDSv2; older accounts and older AMIs may still allow v1. Set
http_tokens = "required"explicitly.
How to validate
# READ-ONLY: confirm provider version is pinned
terraform version
terraform providers
# CONFIGURATION: format and validate
terraform fmt -recursive
terraform validate
# CONFIGURATION: plan and review the change
terraform plan -out=tfplan
# CONFIGURATION: confirm the plan contains no surprises
terraform show tfplan | grep -E '^\s*[+#~-]'
# SERVICE-IMPACT: apply with explicit approval
terraform apply tfplan
For drift detection, after the apply has settled:
# READ-ONLY: confirm the live state matches the recorded state
terraform plan -detailed-exitcode
# Exit 0 = no diff. Exit 1 = error. Exit 2 = drift present.
Production failure modes
- State bucket deleted without
force_destroy = falsehonoured. A teammate runsterraform destroyon a stack that owns the state bucket. The bucket is destroyed. Every subsequentinitfails with “bucket does not exist.” Recovery requires restoring the bucket from a versioned snapshot and re-init-ing against it. - DynamoDB lock table removed. Two applies run concurrently.
Both write to the same state file. One apply wins; the other
overwrites it. Recovery requires enabling the table again and
running
terraform refreshto rebuild state from the live infrastructure. - EC2 IMDSv2 not enforced. A SSRF in the web application is
exploited; the attacker reads IMDS and uses the instance role
to call
s3:ListBucketagainst a sensitive bucket. Detection comes from CloudTrail, not from the instance. - RDS password in plaintext in state. The
passwordargument was set to a literal string instead of an SSM data source.terraform stateshows the password; anyone with read access to the state bucket has database root. - Default VPC used for production workloads. The default VPC
in a new AWS account has a
/20CIDR, public subnets, and no flow logs. Production workloads end up there because someone ran an example module that targets “the default VPC.” The fix is to delete the workload and re-provision into a real VPC. - Cross-region peering without route propagation. Two VPCs are
peered, but the route tables do not include the peering
connection. Connectivity fails at the first
nc -vztest. The fix is aaws_routeentry in each VPC’s route table pointing to thepcx-connection.
What to do in production
- Pin the provider version, the Terraform version, and the backend configuration. Lock all three.
- Use
default_tagsto ensure every resource hasOwner,Environment, andManagedBy. Untagged resources are unowned resources and the AWS bill eventually proves it. - Use SSM Parameter Store (or Secrets Manager) for secrets and
reference them via
datasources. Never put apassword =literal in HCL. - Enforce IMDSv2 and EBS encryption by default in the provider block. New resources should have to opt out, not opt in.
- Run drift detection (
terraform plan -detailed-exitcode) in CI on a schedule. The plan that says “no changes” is the assertion that reality matches the recorded state.
Verification
After working through this lesson, confirm the following:
- You can describe why a DynamoDB lock table is required alongside an S3 state backend.
- You can write a minimal VPC, subnets, IGW, and route table from memory.
- You can explain why IMDSv2 and EBS encryption are mandatory defaults, not optional extras.
- You can name three AWS defaults that produce security or cost incidents when left in place.
Knowledge check · 7 questions
Q1. Why is the DynamoDB lock table required alongside an S3 state backend?
Q2. Which of the following are mandatory defaults for a production EC2 instance on AWS? (Select all that apply.)
Q3. RDS defaults multi_az to false, so leaving it at the default puts a production database in a single availability zone.
Q4. What is the correct pattern for handling a database password in Terraform?
Q5. What does the default_tags block in the AWS provider do?
Q6. A team provisions an RDS instance with the password hard-coded as a variable default. The state file lives in an encrypted S3 bucket with KMS, accessed only by the CI role. Is this acceptable?
Q7. What is the production failure mode of using the default VPC for workloads?
Passing score: 75%. Answers are checked in this browser.