Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Terraform on AWS

Intermediate⏱ ~18 minbash

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

Not yet marked complete on this device.

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 = true on every aws_ebs_volume and every root_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/0 egress 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

  1. State bucket deleted without force_destroy = false honoured. A teammate runs terraform destroy on a stack that owns the state bucket. The bucket is destroyed. Every subsequent init fails with “bucket does not exist.” Recovery requires restoring the bucket from a versioned snapshot and re-init-ing against it.
  2. 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 refresh to rebuild state from the live infrastructure.
  3. EC2 IMDSv2 not enforced. A SSRF in the web application is exploited; the attacker reads IMDS and uses the instance role to call s3:ListBucket against a sensitive bucket. Detection comes from CloudTrail, not from the instance.
  4. RDS password in plaintext in state. The password argument was set to a literal string instead of an SSM data source. terraform state shows the password; anyone with read access to the state bucket has database root.
  5. Default VPC used for production workloads. The default VPC in a new AWS account has a /20 CIDR, 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.
  6. 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 -vz test. The fix is a aws_route entry in each VPC’s route table pointing to the pcx- connection.

What to do in production

  • Pin the provider version, the Terraform version, and the backend configuration. Lock all three.
  • Use default_tags to ensure every resource has Owner, Environment, and ManagedBy. 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 data sources. Never put a password = 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

  1. Q1. Why is the DynamoDB lock table required alongside an S3 state backend?

  2. Q2. Which of the following are mandatory defaults for a production EC2 instance on AWS? (Select all that apply.)

  3. Q3. RDS defaults multi_az to false, so leaving it at the default puts a production database in a single availability zone.

  4. Q4. What is the correct pattern for handling a database password in Terraform?

  5. Q5. What does the default_tags block in the AWS provider do?

  6. 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?

  7. Q7. What is the production failure mode of using the default VPC for workloads?

Passing score: 75%. Answers are checked in this browser.