Education › DevOps Engineering › Guided project

Terraform an AWS environment from scratch

The zero-to-prod project put a container on one EC2 host. This project builds the environment a real team would run it in, entirely from Terraform: a VPC with public and private subnets, an Application Load Balancer, an ECS Fargate service pulling the image you already publish, a Postgres database in RDS whose password never touches a file, and remote state with locking. You finish by handing the whole thing to GitHub Actions — plan on every pull request, apply on merge — and then tearing it all down with one command, because you built it so that you can.

Intermediate about 8 hours 8 phases · 48 steps 0 / 48 done
What you will have at the end

A public aws-platform repository containing about 400 lines of HCL split into network, database and service modules; a running stack reachable at an ALB DNS name that returns your API's greeting and reports a healthy database connection; the state in S3 with lockfile locking; an infra.yml workflow that posts the plan on pull requests and applies on merge using OIDC; and a terraform destroy you have run at least once and rebuilt from, proving the environment is reproducible.

Before you start
  • The zero-to-prod project completed — you need the ghcr.io/YOUR_USER/zero-to-prod image, the AWS account, the AWS CLI logged in, and the GitHub OIDC provider it created
  • The DevOps track's Terraform, cloud and networking modules (VPC, subnets, security groups, IAM roles)
  • A credit card on the AWS account. Everything here is small, but a NAT gateway, an ALB and RDS are not free-tier; expect roughly $0.10 per hour while the stack is up, so destroy it when you stop for the day
Tools you will install
  • Terraform 1.10+ — the S3 backend's native lockfile locking needs 1.10 or newer, so no DynamoDB table ↗
  • AWS CLI v2 — bootstrapping the state bucket, checking what Terraform built, reading logs ↗
  • TFLint — catches invalid instance types, deprecated arguments and unused variables before plan does ↗
  • GitHub CLI — creating the repo, setting variables, opening and merging pull requests from the terminal ↗
  • psql — one connection to prove the database works; install the PostgreSQL client only, not the server ↗
Repository layout at the end
aws-platform/
├── .github/workflows/
│   └── infra.yml            # fmt/validate/tflint, plan on PR, apply on merge
├── modules/
│   ├── network/             # VPC, subnets, IGW, NAT, route tables
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── database/            # RDS Postgres, subnet group, SG, secret
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── service/             # ALB, ECS cluster + Fargate service, IAM, logs
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── envs/
│   └── dev/
│       ├── main.tf          # wires the three modules together
│       ├── backend.tf       # S3 remote state
│       ├── versions.tf
│       ├── variables.tf
│       ├── outputs.tf
│       └── dev.tfvars       # non-secret values, committed
├── .tflint.hcl
├── .gitignore
└── README.md

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Set up the repository and a spending guardrail

A new repository with the module layout, pinned tool versions, and an AWS budget alert so a forgotten stack cannot surprise you.

  1. Check the tools. Terraform must be 1.10 or newer for lockfile-based state locking; anything older needs a DynamoDB table, which this project deliberately avoids.
    bash
    terraform version
    aws --version
    tflint --version
    gh --version
    aws sts get-caller-identity   # confirms which account you are about to build in
  2. Create a budget with an email alert at $5. AWS Budgets is free; the alert arrives when forecast or actual spend crosses the threshold, which is exactly the safety net you want while learning.
    bash
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    cat > budget.json <<EOF
    {"BudgetName": "aws-platform-guardrail", "BudgetLimit": {"Amount": "5", "Unit": "USD"},
     "TimeUnit": "MONTHLY", "BudgetType": "COST"}
    EOF
    cat > notify.json <<EOF
    [{"Notification": {"NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN", "Threshold": 80},
      "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "you@example.com"}]}]
    EOF
    aws budgets create-budget --account-id "$ACCOUNT" --budget file://budget.json --notifications-with-subscribers file://notify.json
    rm budget.json notify.json
    Put your real address in before running it. The alert fires at 80% of $5; raise the limit later if you keep the stack up for longer experiments.
  3. Create the repository and the directory layout. Modules hold reusable pieces; envs/dev is the one place that says which pieces, in which region, with which sizes.
    bash
    mkdir aws-platform && cd aws-platform
    git init -b main
    mkdir -p modules/network modules/database modules/service envs/dev .github/workflows
    gh repo create aws-platform --public --source=. --remote=origin
  4. Add a .gitignore that keeps state, provider binaries and local variable files out of the repo. The committed dev.tfvars holds only non-secret values; anything secret comes from AWS itself.
    text
    .terraform/
    *.tfstate
    *.tfstate.*
    crash.log
    *.auto.tfvars
    secret.tfvars
    .terraform.lock.hcl.bak
    tfplan
  5. Pin the provider and Terraform versions in envs/dev/versions.tf. A pinned provider means the plan you review today is the plan that applies tomorrow.
    hcl
    terraform {
      required_version = ">= 1.10, < 2.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.80"
        }
        random = {
          source  = "hashicorp/random"
          version = "~> 3.6"
        }
      }
    }
    
    provider "aws" {
      region = var.region
    
      default_tags {
        tags = {
          project     = "aws-platform"
          environment = "dev"
          managed_by  = "terraform"
        }
      }
    }
    default_tags stamps every resource. When you look at the bill or the console later, you can filter on project=aws-platform and see exactly what this repo owns.
  6. Commit the skeleton so the first pull request has something to diff against.
    bash
    git add .
    git commit -m "chore: module layout, pinned versions, gitignore"
    git push -u origin main
Phase 2

Remote state with locking

State lives in a versioned, encrypted S3 bucket and every apply takes a lock, so two people (or you and the pipeline) cannot corrupt it.

  1. Create the state bucket by hand — it is the one resource Terraform cannot manage for itself, because it has to exist before the first init. Versioning gives you every previous state; public access is blocked outright.
    bash
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    REGION=eu-west-1
    BUCKET="tfstate-aws-platform-$ACCOUNT"
    aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" --create-bucket-configuration LocationConstraint="$REGION"
    aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled
    aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
    aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
    echo "$BUCKET"
    If you did the zero-to-prod project you already have a tfstate-zero-to-prod-… bucket. Reusing it with a different key is fine; a bucket per project is also fine. Do not share a key.
  2. Write envs/dev/backend.tf. use_lockfile = true makes Terraform create terraform.tfstate.tflock in the bucket for the duration of a run, which is the locking mechanism that replaced the DynamoDB table.
    hcl
    terraform {
      backend "s3" {
        bucket       = "tfstate-aws-platform-123456789012"
        key          = "envs/dev/terraform.tfstate"
        region       = "eu-west-1"
        encrypt      = true
        use_lockfile = true
      }
    }
  3. Declare the environment's inputs in envs/dev/variables.tf and give them values in envs/dev/dev.tfvars. The image is the one your zero-to-prod release workflow publishes.
    hcl
    # envs/dev/variables.tf
    variable "region" {
      type    = string
      default = "eu-west-1"
    }
    
    variable "name" {
      type    = string
      default = "aws-platform-dev"
    }
    
    variable "vpc_cidr" {
      type    = string
      default = "10.20.0.0/16"
    }
    
    variable "image" {
      description = "Container image the service runs"
      type        = string
    }
    
    variable "desired_count" {
      type    = number
      default = 2
    }
    
    variable "db_instance_class" {
      type    = string
      default = "db.t4g.micro"
    }
    
    # envs/dev/dev.tfvars
    # image         = "ghcr.io/YOUR_GITHUB_USER/zero-to-prod:latest"
    # desired_count = 2
    Write the two dev.tfvars lines into their own file without the leading #. They are shown here in one block so the variables and their values sit together.
  4. Initialise against the remote backend and confirm the lock works by holding one open in a second terminal.
    bash
    cd envs/dev
    terraform init
    terraform plan -var-file=dev.tfvars   # nothing to build yet; it should say 'No changes'
    # in a second terminal, inside envs/dev:
    #   terraform console      (keeps the state lock while open)
    # back in the first terminal:
    terraform plan -var-file=dev.tfvars   # -> 'Error acquiring the state lock' until you exit the console
    Seeing the lock error once, on purpose, is worth more than reading about it. Close the console and the plan goes through.
Phase 3

The network module

A VPC with two public and two private subnets across two availability zones, an internet gateway for the public side, and one NAT gateway so private tasks can pull images and reach AWS APIs without being reachable themselves.

  1. Declare the module's inputs in modules/network/variables.tf. Taking the availability zones as data rather than hard-coding them makes the module work in any region.
    hcl
    variable "name" {
      type = string
    }
    
    variable "cidr" {
      type = string
    }
    
    variable "az_count" {
      type    = number
      default = 2
    }
  2. Write modules/network/main.tf: the VPC, one public and one private subnet per AZ, an internet gateway, and a single NAT gateway with an Elastic IP. cidrsubnet carves /20 blocks out of the VPC range so the addressing is derived, not typed.
    hcl
    data "aws_availability_zones" "available" {
      state = "available"
    }
    
    locals {
      azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
    }
    
    resource "aws_vpc" "this" {
      cidr_block           = var.cidr
      enable_dns_support   = true
      enable_dns_hostnames = true
      tags                 = { Name = var.name }
    }
    
    resource "aws_internet_gateway" "this" {
      vpc_id = aws_vpc.this.id
      tags   = { Name = var.name }
    }
    
    resource "aws_subnet" "public" {
      count                   = var.az_count
      vpc_id                  = aws_vpc.this.id
      cidr_block              = cidrsubnet(var.cidr, 4, count.index)
      availability_zone       = local.azs[count.index]
      map_public_ip_on_launch = true
      tags                    = { Name = "${var.name}-public-${local.azs[count.index]}", tier = "public" }
    }
    
    resource "aws_subnet" "private" {
      count             = var.az_count
      vpc_id            = aws_vpc.this.id
      cidr_block        = cidrsubnet(var.cidr, 4, count.index + 8)
      availability_zone = local.azs[count.index]
      tags              = { Name = "${var.name}-private-${local.azs[count.index]}", tier = "private" }
    }
    
    resource "aws_eip" "nat" {
      domain = "vpc"
      tags   = { Name = "${var.name}-nat" }
    }
    
    resource "aws_nat_gateway" "this" {
      allocation_id = aws_eip.nat.id
      subnet_id     = aws_subnet.public[0].id
      tags          = { Name = var.name }
      depends_on    = [aws_internet_gateway.this]
    }
    
    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" "private" {
      vpc_id = aws_vpc.this.id
      route {
        cidr_block     = "0.0.0.0/0"
        nat_gateway_id = aws_nat_gateway.this.id
      }
      tags = { Name = "${var.name}-private" }
    }
    
    resource "aws_route_table_association" "public" {
      count          = var.az_count
      subnet_id      = aws_subnet.public[count.index].id
      route_table_id = aws_route_table.public.id
    }
    
    resource "aws_route_table_association" "private" {
      count          = var.az_count
      subnet_id      = aws_subnet.private[count.index].id
      route_table_id = aws_route_table.private.id
    }
    One NAT gateway is a deliberate dev-environment trade-off: it costs about $0.045/hour and is a single point of failure for outbound traffic. Production runs one per AZ; the change is count = var.az_count on the EIP, the gateway and the private route table.
  3. Expose what the other modules need in modules/network/outputs.tf.
    hcl
    output "vpc_id" {
      value = aws_vpc.this.id
    }
    
    output "public_subnet_ids" {
      value = aws_subnet.public[*].id
    }
    
    output "private_subnet_ids" {
      value = aws_subnet.private[*].id
    }
    
    output "vpc_cidr" {
      value = aws_vpc.this.cidr_block
    }
  4. Wire the module into envs/dev/main.tf and apply just the network first. Building in layers keeps each plan small enough to actually read.
    hcl
    module "network" {
      source   = "../../modules/network"
      name     = var.name
      cidr     = var.vpc_cidr
      az_count = 2
    }
  5. Format, validate, plan and apply. Read the plan: it should add exactly 14 resources — 1 VPC, 4 subnets, 1 IGW, 1 EIP, 1 NAT gateway, 2 route tables, 4 associations. The availability-zone data source is read, not created, so it does not count.
    bash
    cd envs/dev
    terraform fmt -recursive ..
    terraform validate
    terraform plan -var-file=dev.tfvars -out=tfplan
    terraform apply tfplan
    aws ec2 describe-subnets --filters Name=tag:project,Values=aws-platform \
      --query 'Subnets[].{az:AvailabilityZone,cidr:CidrBlock,tier:Tags[?Key==`tier`].Value|[0]}' --output table
    The NAT gateway takes a minute or two to become available; Terraform waits for it. If your count differs, read the plan to find out why before applying — that habit is the whole skill.
  6. Commit the module on a branch and merge it. From here on, every phase is a pull request, so you build the habit before the pipeline enforces it.
    bash
    cd ../..
    git switch -c feat/network
    git add modules/network envs/dev
    git commit -m "feat(network): VPC with public/private subnets, IGW and NAT"
    git push -u origin feat/network
    gh pr create --fill && gh pr merge --squash --delete-branch
    git switch main && git pull
Phase 4

The database module

A Postgres instance in the private subnets that only the service's security group can reach, with a generated password stored in Secrets Manager and never written to disk or state as plain text.

  1. Declare the inputs in modules/database/variables.tf. The module takes the security group that is allowed to connect rather than a CIDR, so the rule follows the service wherever it runs.
    hcl
    variable "name" {
      type = string
    }
    
    variable "vpc_id" {
      type = string
    }
    
    variable "subnet_ids" {
      type = list(string)
    }
    
    variable "allowed_security_group_id" {
      description = "Security group of the workload that may connect on 5432"
      type        = string
    }
    
    variable "instance_class" {
      type    = string
      default = "db.t4g.micro"
    }
    
    variable "db_name" {
      type    = string
      default = "app"
    }
  2. Write modules/database/main.tf. random_password generates the password inside Terraform, the secret stores it, and RDS reads it from the resource — nobody types it and it never appears in a tfvars file.
    hcl
    resource "aws_db_subnet_group" "this" {
      name       = var.name
      subnet_ids = var.subnet_ids
    }
    
    resource "aws_security_group" "db" {
      name        = "${var.name}-db"
      description = "Postgres, reachable only from the service"
      vpc_id      = var.vpc_id
    
      ingress {
        from_port       = 5432
        to_port         = 5432
        protocol        = "tcp"
        security_groups = [var.allowed_security_group_id]
      }
    
      egress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }
    
    resource "random_password" "db" {
      length           = 32
      special          = true
      override_special = "!#$%^&*()-_=+"
    }
    
    resource "aws_secretsmanager_secret" "db" {
      name                    = "${var.name}/database"
      recovery_window_in_days = 0
    }
    
    resource "aws_db_instance" "this" {
      identifier             = var.name
      engine                 = "postgres"
      engine_version         = "16"
      instance_class         = var.instance_class
      allocated_storage      = 20
      storage_type           = "gp3"
      db_name                = var.db_name
      username               = "app"
      password               = random_password.db.result
      db_subnet_group_name   = aws_db_subnet_group.this.name
      vpc_security_group_ids = [aws_security_group.db.id]
      publicly_accessible    = false
      multi_az               = false
      backup_retention_period = 1
      skip_final_snapshot    = true
      deletion_protection    = false
      apply_immediately      = true
    }
    
    resource "aws_secretsmanager_secret_version" "db" {
      secret_id = aws_secretsmanager_secret.db.id
      secret_string = jsonencode({
        username = aws_db_instance.this.username
        password = random_password.db.result
        host     = aws_db_instance.this.address
        port     = aws_db_instance.this.port
        dbname   = aws_db_instance.this.db_name
        url      = "postgresql://${aws_db_instance.this.username}:${random_password.db.result}@${aws_db_instance.this.address}:${aws_db_instance.this.port}/${aws_db_instance.this.db_name}"
      })
    }
    skip_final_snapshot, deletion_protection = false and a zero-day secret recovery window make destroy clean for a learning environment. In production all three flip the other way — that is the difference between an environment you can recreate and one you must protect. The password is still in the state file, which is why the state bucket is encrypted and private.
  3. Export the connection details and the secret's ARN in modules/database/outputs.tf. The service will inject the secret by ARN, so the URL itself never appears in a task definition.
    hcl
    output "endpoint" {
      value = aws_db_instance.this.address
    }
    
    output "secret_arn" {
      value = aws_secretsmanager_secret.db.arn
    }
    
    output "security_group_id" {
      value = aws_security_group.db.id
    }
  4. The database module needs the service's security group, and the service needs the database's secret — a cycle if both are created inside their modules. Break it by creating the service security group in envs/dev/main.tf, where both modules can see it, then add the database module.
    hcl
    resource "aws_security_group" "service" {
      name        = "${var.name}-service"
      description = "ECS tasks"
      vpc_id      = module.network.vpc_id
    
      egress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }
    
    module "database" {
      source                    = "../../modules/database"
      name                      = var.name
      vpc_id                    = module.network.vpc_id
      subnet_ids                = module.network.private_subnet_ids
      allowed_security_group_id = aws_security_group.service.id
      instance_class            = var.db_instance_class
    }
    This is a common Terraform design decision: the resource that two modules share is owned by neither of them. Ingress to the service group is added in the next phase, by the ALB.
  5. Apply on a branch. RDS takes five to ten minutes to create, so this is a good moment to read ahead. Then confirm the secret exists without printing it.
    bash
    git switch -c feat/database
    cd envs/dev && terraform fmt -recursive .. && terraform validate
    terraform plan -var-file=dev.tfvars -out=tfplan && terraform apply tfplan
    aws secretsmanager describe-secret --secret-id "aws-platform-dev/database" --query '{name:Name,created:CreatedDate}'
    aws rds describe-db-instances --db-instance-identifier aws-platform-dev --query 'DBInstances[0].{status:DBInstanceStatus,public:PubliclyAccessible,az:AvailabilityZone}'
  6. Commit and merge.
    bash
    cd ../..
    git add modules/database envs/dev
    git commit -m "feat(database): private RDS Postgres with generated password in Secrets Manager"
    git push -u origin feat/database
    gh pr create --fill && gh pr merge --squash --delete-branch
    git switch main && git pull
Phase 5

The service module: ALB and ECS Fargate

An Application Load Balancer in the public subnets forwarding to two Fargate tasks in the private subnets, with the database URL injected from Secrets Manager and logs in CloudWatch.

  1. Declare the inputs in modules/service/variables.tf.
    hcl
    variable "name" {
      type = string
    }
    
    variable "vpc_id" {
      type = string
    }
    
    variable "public_subnet_ids" {
      type = list(string)
    }
    
    variable "private_subnet_ids" {
      type = list(string)
    }
    
    variable "service_security_group_id" {
      type = string
    }
    
    variable "image" {
      type = string
    }
    
    variable "container_port" {
      type    = number
      default = 8000
    }
    
    variable "desired_count" {
      type    = number
      default = 2
    }
    
    variable "cpu" {
      type    = number
      default = 256
    }
    
    variable "memory" {
      type    = number
      default = 512
    }
    
    variable "db_secret_arn" {
      type = string
    }
    
    variable "environment" {
      description = "Plain (non-secret) environment variables"
      type        = map(string)
      default     = {}
    }
  2. Write the load balancer half of modules/service/main.tf: the ALB's own security group open on 80, a target group that health-checks /health, and a listener. The ingress rule on the service's group is created here too, allowing traffic only from the ALB.
    hcl
    resource "aws_security_group" "alb" {
      name        = "${var.name}-alb"
      description = "Public HTTP in"
      vpc_id      = var.vpc_id
    
      ingress {
        from_port   = 80
        to_port     = 80
        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"]
      }
    }
    
    resource "aws_vpc_security_group_ingress_rule" "service_from_alb" {
      security_group_id            = var.service_security_group_id
      referenced_security_group_id = aws_security_group.alb.id
      from_port                    = var.container_port
      to_port                      = var.container_port
      ip_protocol                  = "tcp"
    }
    
    resource "aws_lb" "this" {
      name               = var.name
      load_balancer_type = "application"
      security_groups    = [aws_security_group.alb.id]
      subnets            = var.public_subnet_ids
    }
    
    resource "aws_lb_target_group" "this" {
      name        = var.name
      port        = var.container_port
      protocol    = "HTTP"
      target_type = "ip"
      vpc_id      = var.vpc_id
    
      health_check {
        path                = "/health"
        matcher             = "200"
        interval            = 15
        healthy_threshold   = 2
        unhealthy_threshold = 3
      }
    
      deregistration_delay = 10
    }
    
    resource "aws_lb_listener" "http" {
      load_balancer_arn = aws_lb.this.arn
      port              = 80
      protocol          = "HTTP"
    
      default_action {
        type             = "forward"
        target_group_arn = aws_lb_target_group.this.arn
      }
    }
    target_type = "ip" is required for Fargate: tasks have no instance, only an ENI with an address. HTTPS is left for the next list — it needs a domain you own and an ACM certificate.
  3. Now the compute half, appended to the same file: a log group, the execution role (what ECS itself needs: pull images, write logs, read the secret), the task definition and the service. The secrets block is what turns the Secrets Manager entry into DATABASE_URL inside the container.
    hcl
    resource "aws_cloudwatch_log_group" "this" {
      name              = "/ecs/${var.name}"
      retention_in_days = 7
    }
    
    data "aws_iam_policy_document" "ecs_assume" {
      statement {
        actions = ["sts:AssumeRole"]
        principals {
          type        = "Service"
          identifiers = ["ecs-tasks.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "execution" {
      name               = "${var.name}-execution"
      assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "execution" {
      role       = aws_iam_role.execution.name
      policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
    }
    
    resource "aws_iam_role_policy" "read_secret" {
      name = "read-db-secret"
      role = aws_iam_role.execution.id
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["secretsmanager:GetSecretValue"]
          Resource = var.db_secret_arn
        }]
      })
    }
    
    resource "aws_ecs_cluster" "this" {
      name = var.name
    }
    
    resource "aws_ecs_task_definition" "this" {
      family                   = var.name
      network_mode             = "awsvpc"
      requires_compatibilities = ["FARGATE"]
      cpu                      = var.cpu
      memory                   = var.memory
      execution_role_arn       = aws_iam_role.execution.arn
    
      runtime_platform {
        cpu_architecture        = "X86_64"
        operating_system_family = "LINUX"
      }
    
      container_definitions = jsonencode([{
        name      = "app"
        image     = var.image
        essential = true
        portMappings = [{
          containerPort = var.container_port
          protocol      = "tcp"
        }]
        environment = [for k, v in var.environment : { name = k, value = v }]
        secrets = [{
          name      = "DATABASE_URL"
          valueFrom = "${var.db_secret_arn}:url::"
        }]
        logConfiguration = {
          logDriver = "awslogs"
          options = {
            "awslogs-group"         = aws_cloudwatch_log_group.this.name
            "awslogs-region"        = data.aws_region.current.name
            "awslogs-stream-prefix" = "app"
          }
        }
      }])
    }
    
    data "aws_region" "current" {}
    
    resource "aws_ecs_service" "this" {
      name            = var.name
      cluster         = aws_ecs_cluster.this.id
      task_definition = aws_ecs_task_definition.this.arn
      desired_count   = var.desired_count
      launch_type     = "FARGATE"
    
      network_configuration {
        subnets          = var.private_subnet_ids
        security_groups  = [var.service_security_group_id]
        assign_public_ip = false
      }
    
      load_balancer {
        target_group_arn = aws_lb_target_group.this.arn
        container_name   = "app"
        container_port   = var.container_port
      }
    
      deployment_minimum_healthy_percent = 50
      deployment_maximum_percent         = 200
    
      depends_on = [aws_lb_listener.http]
    }
    valueFrom = "<secret-arn>:url::" picks the url key out of the JSON secret. The trailing :: means 'latest version, no version stage'. If your zero-to-prod image was built on an Apple Silicon Mac without --platform linux/amd64, change cpu_architecture to ARM64 — Fargate supports both, but the image and the setting must agree.
  4. Export the ALB address and the identifiers you will want for the CLI in modules/service/outputs.tf.
    hcl
    output "alb_dns_name" {
      value = aws_lb.this.dns_name
    }
    
    output "cluster_name" {
      value = aws_ecs_cluster.this.name
    }
    
    output "service_name" {
      value = aws_ecs_service.this.name
    }
    
    output "log_group" {
      value = aws_cloudwatch_log_group.this.name
    }
  5. Add the module to envs/dev/main.tf and the environment outputs to envs/dev/outputs.tf.
    hcl
    # envs/dev/main.tf (append)
    module "service" {
      source                    = "../../modules/service"
      name                      = var.name
      vpc_id                    = module.network.vpc_id
      public_subnet_ids         = module.network.public_subnet_ids
      private_subnet_ids        = module.network.private_subnet_ids
      service_security_group_id = aws_security_group.service.id
      image                     = var.image
      desired_count             = var.desired_count
      db_secret_arn             = module.database.secret_arn
      environment = {
        GREETING = "hello from ECS Fargate"
      }
    }
    
    # envs/dev/outputs.tf
    output "url" {
      value = "http://${module.service.alb_dns_name}/"
    }
    
    output "db_endpoint" {
      value = module.database.endpoint
    }
    
    output "cluster" {
      value = module.service.cluster_name
    }
  6. Make the image pullable. Fargate pulls ghcr.io/… anonymously only if the package is public; check it, and flip it if needed (Packages → your package → Package settings → Change visibility).
    bash
    gh api "/users/$(gh api user -q .login)/packages/container/zero-to-prod" -q .visibility
    # 'public' -> fine. 'private' -> make it public in the package settings, or use
    # repositoryCredentials in the task definition with a Secrets Manager entry holding a GHCR token.
  7. Apply, then watch the service reach a steady state and the targets turn healthy. The first deployment takes two or three minutes: pull, start, pass two health checks.
    bash
    git switch -c feat/service
    cd envs/dev && terraform fmt -recursive .. && terraform validate
    terraform plan -var-file=dev.tfvars -out=tfplan && terraform apply tfplan
    aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev
    TG=$(aws elbv2 describe-target-groups --names aws-platform-dev --query 'TargetGroups[0].TargetGroupArn' --output text)
    aws elbv2 describe-target-health --target-group-arn "$TG" --query 'TargetHealthDescriptions[].{ip:Target.Id,state:TargetHealth.State}'
    curl -s "$(terraform output -raw url)"
    curl -s "$(terraform output -raw url)health"
    services-stable waits up to ten minutes and exits non-zero if tasks keep dying; if it does, go straight to the CloudWatch logs in the troubleshooting section rather than re-applying.
Phase 6

Prove the database connection

The running service actually reaches Postgres through the private network using the injected secret, and you can see it happen in the logs.

  1. The zero-to-prod app does not talk to a database yet. Add a /db endpoint to it — in the zero-to-prod repository, on a branch — that opens a connection with DATABASE_URL and returns the server version. Add psycopg[binary] to its requirements.
    python
    # app/main.py (zero-to-prod repo) — add below the existing routes
    import psycopg
    
    
    @app.get("/db")
    def db():
        url = os.environ.get("DATABASE_URL")
        if not url:
            return {"database": "not configured"}
        with psycopg.connect(url, connect_timeout=3) as conn:
            version = conn.execute("select version()").fetchone()[0]
        return {"database": "ok", "version": version.split(",")[0]}
    Keep os imported at the top as it already is. psycopg[binary]==3.2.* in requirements.txt is enough; no build tools needed in the image.
  2. Add a test that the endpoint degrades gracefully without a database, then push the branch, let CI pass, merge, and wait for the release workflow to publish a new :latest image.
    python
    # tests/test_main.py (zero-to-prod repo) — append
    def test_db_reports_not_configured_without_url(monkeypatch):
        monkeypatch.delenv("DATABASE_URL", raising=False)
        assert client.get("/db").json() == {"database": "not configured"}
  3. Back in aws-platform, force a new deployment so the service pulls the new image. Because the tag is still :latest, Terraform sees no change — this is the moment you learn why teams deploy by digest or immutable tag, which the pipeline phase fixes.
    bash
    aws ecs update-service --cluster aws-platform-dev --service aws-platform-dev --force-new-deployment --query 'service.deployments[].{status:status,running:runningCount,taskDef:taskDefinition}'
    aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev
    curl -s "$(cd envs/dev && terraform output -raw url)db"
    Expected: {"database":"ok","version":"PostgreSQL 16.x"}. The request went ALB → private task → RDS, and the password came from Secrets Manager at task start, never from your laptop.
  4. Look at the logs the way you will during an incident: tail the log group and make a few requests. Then confirm the database is unreachable from the internet, which is the point of the private subnet.
    bash
    aws logs tail /ecs/aws-platform-dev --follow --since 5m &
    for i in 1 2 3; do curl -s "$(cd envs/dev && terraform output -raw url)db" > /dev/null; done
    sleep 5; kill %1
    DB=$(cd envs/dev && terraform output -raw db_endpoint)
    timeout 5 psql "postgresql://app@$DB:5432/app" -c 'select 1' || echo "unreachable from here — correct"
  5. Commit the environment changes and merge.
    bash
    git add modules/service envs/dev
    git commit -m "feat(service): ALB + ECS Fargate service with DATABASE_URL from Secrets Manager"
    git push -u origin feat/service
    gh pr create --fill && gh pr merge --squash --delete-branch
    git switch main && git pull
Phase 7

Let GitHub Actions own the environment

Every pull request that touches HCL gets fmt, validate, tflint and a plan posted as a comment; merging to main applies. Credentials are short-lived OIDC tokens, and image tags become immutable so a deploy is a reviewable diff.

  1. Create an IAM role for this repository. The OIDC provider already exists from zero-to-prod; the trust policy is scoped to this repo's main branch and pull requests.
    bash
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    USER=$(gh api user -q .login)
    cat > trust.json <<EOF
    {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/token.actions.githubusercontent.com"},
        "Action": "sts:AssumeRoleWithWebIdentity",
        "Condition": {
          "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
          "StringLike": {"token.actions.githubusercontent.com:sub": "repo:$USER/aws-platform:*"}
        }
      }]
    }
    EOF
    aws iam create-role --role-name github-aws-platform --assume-role-policy-document file://trust.json
    rm trust.json
    If create-open-id-connect-provider was never run in this account, do it now: aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com.
  2. Attach permissions. For a learning account the managed policies below are acceptable; the next list points at scoping them down. The state bucket policy is the one you should write by hand even here.
    bash
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    for p in AmazonVPCFullAccess AmazonECS_FullAccess AmazonRDSFullAccess ElasticLoadBalancingFullAccess CloudWatchLogsFullAccess SecretsManagerReadWrite IAMFullAccess; do
      aws iam attach-role-policy --role-name github-aws-platform --policy-arn "arn:aws:iam::aws:policy/$p"
    done
    cat > state.json <<EOF
    {"Version": "2012-10-17", "Statement": [
      {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::tfstate-aws-platform-$ACCOUNT"},
      {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::tfstate-aws-platform-$ACCOUNT/envs/*"}
    ]}
    EOF
    aws iam put-role-policy --role-name github-aws-platform --policy-name tfstate --policy-document file://state.json
    rm state.json
  3. Store the role ARN and region as repository variables — they are not secrets, and variables show up in logs, which helps debugging.
    bash
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    gh variable set AWS_ROLE_ARN --body "arn:aws:iam::$ACCOUNT:role/github-aws-platform"
    gh variable set AWS_REGION --body "eu-west-1"
    gh variable list
  4. Add a TFLint configuration at the repo root. The AWS ruleset knows which instance classes exist and which arguments are deprecated — errors that validate cannot see because they need the provider's knowledge.
    hcl
    # .tflint.hcl
    plugin "terraform" {
      enabled = true
      preset  = "recommended"
    }
    
    plugin "aws" {
      enabled = true
      version = "0.38.0"
      source  = "github.com/terraform-linters/tflint-ruleset-aws"
    }
  5. Write .github/workflows/infra.yml. The plan job runs on pull requests and posts the plan; the apply job runs only on main and only after a fresh plan, so what applies is what was reviewed. TF_VAR_image overrides the tfvars image so the pipeline can pin a tag.
    yaml
    name: infra
    
    on:
      pull_request:
        paths: ["modules/**", "envs/**", ".tflint.hcl"]
      push:
        branches: [main]
        paths: ["modules/**", "envs/**"]
    
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    
    concurrency:
      group: infra-dev
      cancel-in-progress: false
    
    defaults:
      run:
        working-directory: envs/dev
    
    jobs:
      check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: hashicorp/setup-terraform@v3
            with:
              terraform_version: "1.10.5"
          - uses: terraform-linters/setup-tflint@v4
          - run: terraform fmt -check -recursive ..
          - run: terraform init -backend=false
          - run: terraform validate
          - run: tflint --init && tflint --recursive --chdir=..
    
      plan:
        needs: check
        if: github.event_name == 'pull_request'
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: ${{ vars.AWS_ROLE_ARN }}
              aws-region: ${{ vars.AWS_REGION }}
          - uses: hashicorp/setup-terraform@v3
            with:
              terraform_version: "1.10.5"
          - run: terraform init
          - id: plan
            run: terraform plan -var-file=dev.tfvars -no-color -out=tfplan 2>&1 | tee plan.txt
          - uses: actions/github-script@v7
            with:
              script: |
                const fs = require('fs');
                const plan = fs.readFileSync('envs/dev/plan.txt', 'utf8').slice(-60000);
                await github.rest.issues.createComment({
                  owner: context.repo.owner, repo: context.repo.repo,
                  issue_number: context.issue.number,
                  body: '### Terraform plan (envs/dev)\n```\n' + plan + '\n```'
                });
    
      apply:
        needs: check
        if: github.event_name == 'push'
        runs-on: ubuntu-latest
        environment: dev
        steps:
          - uses: actions/checkout@v4
          - uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: ${{ vars.AWS_ROLE_ARN }}
              aws-region: ${{ vars.AWS_REGION }}
          - uses: hashicorp/setup-terraform@v3
            with:
              terraform_version: "1.10.5"
          - run: terraform init
          - run: terraform plan -var-file=dev.tfvars -out=tfplan
          - run: terraform apply -auto-approve tfplan
    concurrency with cancel-in-progress: false queues applies instead of running two at once; the S3 lockfile is the second line of defence. The environment: dev line lets you add a required reviewer under Settings → Environments later, which turns apply-on-merge into apply-on-approval without touching the workflow.
  6. Switch the image from :latest to an immutable tag, so that changing the image is a one-line diff Terraform can see. Your zero-to-prod release workflow also tags images with the commit SHA; find the newest one.
    bash
    git switch -c ci/infra-pipeline
    USER=$(gh api user -q .login)
    TAG=$(gh api "/users/$USER/packages/container/zero-to-prod/versions" -q '.[0].metadata.container.tags[] | select(. != "latest")' | head -1)
    echo "$TAG"
    sed -i.bak "s#zero-to-prod:latest#zero-to-prod:$TAG#" envs/dev/dev.tfvars && rm envs/dev/dev.tfvars.bak
    cat envs/dev/dev.tfvars
    If your release workflow only pushes :latest, add type=sha to its docker/metadata-action tags first — the zero-to-prod project's release.yml shows where. From now on a deploy is: change the tag, open a PR, read the plan (one task definition revision), merge.
  7. Open the pull request and read the plan comment. It should show one change: a new task definition revision with the new image, and the service pointing at it.
    bash
    git add .github .tflint.hcl envs/dev/dev.tfvars
    git commit -m "ci: plan on PR, apply on merge with OIDC; pin image tag"
    git push -u origin ci/infra-pipeline
    gh pr create --fill
    gh pr checks --watch
    gh pr view --comments | tail -40
  8. Merge and watch the apply. Then confirm the service is running the pinned tag.
    bash
    gh pr merge --squash --delete-branch
    git switch main && git pull
    gh run watch
    aws ecs describe-services --cluster aws-platform-dev --services aws-platform-dev --query 'services[0].taskDefinition' --output text | xargs -I{} aws ecs describe-task-definition --task-definition {} --query 'taskDefinition.containerDefinitions[0].image'
Phase 8

Operate it, then destroy it

Scale, deploy and roll back through pull requests; check what it costs; tear the whole environment down and prove you can bring it back.

  1. Scale through a PR. Change desired_count to 3, read the plan (one attribute on one resource), merge, and watch the third task appear behind the ALB.
    bash
    git switch -c ops/scale-3
    sed -i.bak 's/desired_count = 2/desired_count = 3/' envs/dev/dev.tfvars && rm envs/dev/dev.tfvars.bak
    git commit -am "ops: scale service to 3 tasks" && git push -u origin ops/scale-3
    gh pr create --fill && gh pr checks --watch && gh pr merge --squash --delete-branch
    git switch main && git pull && gh run watch
    aws ecs describe-services --cluster aws-platform-dev --services aws-platform-dev --query 'services[0].{desired:desiredCount,running:runningCount}'
  2. Roll back a deploy the same way you roll forward: revert the commit that changed the image tag. The plan shows the old task definition revision coming back; ECS drains new tasks behind the ALB with no downtime because deployment_minimum_healthy_percent keeps half of them serving.
    bash
    git log --oneline -5 -- envs/dev/dev.tfvars
    # pick the commit that changed the image tag and put its SHA here:
    SHA=abc1234
    git switch -c ops/rollback && git revert --no-edit "$SHA"
    git push -u origin ops/rollback && gh pr create --fill
    # read the plan comment, merge, watch — then revert the revert to move forward again
  3. Look at what the environment has cost so far, by the project tag that default_tags applied to every resource. Cost Explorer data lags about a day, so run this tomorrow as well.
    bash
    aws ce get-cost-and-usage --time-period Start=$(date -v-7d +%F 2>/dev/null || date -d '7 days ago' +%F),End=$(date +%F) \
      --granularity DAILY --metrics UnblendedCost \
      --filter '{"Tags":{"Key":"project","Values":["aws-platform"]}}' \
      --query 'ResultsByTime[].{day:TimePeriod.Start,usd:Total.UnblendedCost.Amount}' --output table
    Cost allocation tags must be activated once in Billing → Cost allocation tags before Cost Explorer can filter on them; the activation takes up to 24 hours.
  4. Destroy everything. Because nothing was created by hand except the state bucket, one command removes it all; the plan lists every resource it will delete — read it before typing yes.
    bash
    cd envs/dev
    terraform plan -destroy -var-file=dev.tfvars
    terraform destroy -var-file=dev.tfvars
    aws ecs list-clusters; aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier'; aws ec2 describe-nat-gateways --filter Name=state,Values=available --query 'NatGateways[].NatGatewayId'
    RDS deletion takes several minutes; the NAT gateway a couple more. The three checks at the end should print empty lists. The state bucket stays — it is tiny and it holds the history.
  5. Rebuild it from nothing to prove the point, then destroy it again. If the second apply needs any manual step you did not write down, that step belongs in the README or in Terraform.
    bash
    terraform apply -var-file=dev.tfvars
    aws ecs wait services-stable --cluster aws-platform-dev --services aws-platform-dev
    curl -s "$(terraform output -raw url)db"
    terraform destroy -var-file=dev.tfvars
  6. Write the README: what the stack is, the bootstrap steps (bucket, OIDC role), how a deploy and a rollback work, the hourly cost, and the one-line destroy. Commit it and you are done.
    bash
    cd ../..
    git switch -c docs/readme
    cat > README.md <<'EOF'
    # aws-platform
    
    Terraform for a dev environment: VPC (2 AZ, public/private, 1 NAT), ALB, ECS Fargate service, RDS Postgres, Secrets Manager.
    
    ## Bootstrap (once)
    1. State bucket: `tfstate-aws-platform-<account>` (versioned, encrypted, private).
    2. IAM role `github-aws-platform` trusted by this repo via GitHub OIDC.
    
    ## Deploy
    Change `image` in `envs/dev/dev.tfvars`, open a PR, read the plan comment, merge. Rollback = revert the commit.
    
    ## Cost
    About $0.10/hour while up (NAT, ALB, RDS db.t4g.micro, 2 x 0.25 vCPU Fargate). `terraform destroy` when idle.
    EOF
    git add README.md && git commit -m "docs: bootstrap, deploy, rollback, cost" && git push -u origin docs/readme
    gh pr create --fill && gh pr merge --squash --delete-branch
Help

Troubleshooting

services-stable times out; tasks start and stop every minute
Read the stopped reason: aws ecs list-tasks --cluster aws-platform-dev --desired-status STOPPED then describe-tasks … --query 'tasks[].stoppedReason'. CannotPullContainerError means the image is private or the private subnets have no NAT route; ResourceInitializationError … secretsmanager means the execution role cannot read the secret or the ARN in valueFrom is wrong; exec format error in the logs means the image architecture and cpu_architecture disagree.
Targets stay unhealthy although tasks are running
The ALB reaches the task on the container port through the service security group; check that the ingress rule from the ALB's group exists (aws ec2 describe-security-group-rules --filters Name=group-id,Values=<service-sg>), and that /health returns 200 on port 8000 — curl it from inside a task with ECS Exec if you enable it, or check the app logs for the health-check requests.
Error acquiring the state lock in the pipeline with no other run in progress
A previous run was cancelled mid-apply and left terraform.tfstate.tflock in the bucket. Confirm nothing is running, then terraform force-unlock <ID> from your laptop (the ID is in the error). Never delete the lock object by hand while a run might be active.
/db returns a connection timeout
The database security group only admits the service group on 5432; if you changed the service group's name or replaced it, the rule points at the old group. Also check the task is in a private subnet whose route table has the NAT route — RDS is inside the VPC, but DNS resolution for the endpoint still needs enable_dns_support.
destroy fails on the security group or the subnet with DependencyViolation
ENIs from the ALB or from tasks that are still draining hold the group. Wait a minute and run destroy again; if it persists, find the ENI with aws ec2 describe-network-interfaces --filters Name=group-id,Values=<sg> and check what still owns it.
The plan comment on the PR is empty or truncated
The script takes the last 60,000 characters of plan.txt; a very large plan is cut from the top. Look at the job log for the full plan, and consider terraform show -no-color tfplan piped through a summariser action as a next step.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →