Skip to main content

Infrastructure as Code Starter: Terraform + GitHub Actions for AWS

·2797 words·14 mins
Author
Maksim P.
DevOps Engineer / SRE

TL;DR
#

  • Terraform modules for VPC, ECS Fargate and RDS PostgreSQL
  • GitHub Actions pipeline: plan on PRs, apply on merge to main, authenticated with OIDC rather than static keys
  • Remote state in S3 with native S3 locking (no DynamoDB table needed since Terraform 1.10)
  • Sensible defaults for a small team, easy to extend
  • Realistic infra cost for a small app: ~$90-110/month, and the NAT Gateway is the largest single line

This is a skeleton to adapt, not a repository to clone and run. The modules below are complete enough to show the shape and the wiring; you will be filling in your own container image, domain and certificate before anything serves traffic.

Who this stack is for
#

You’re running on AWS and managing infrastructure by clicking around the console. You want to move to infrastructure as code but don’t want to spend a week designing a module structure. You want something that works today and can grow with you.

Repository structure
#

terraform/
  environments/
    production/
      main.tf          # Root module — wires everything together
      variables.tf     # Environment-specific variables
      terraform.tfvars # Variable values (DO NOT commit secrets)
      backend.tf       # Remote state configuration
      outputs.tf       # Useful outputs
  modules/
    vpc/
      main.tf
      variables.tf
      outputs.tf
    ecs/
      main.tf
      variables.tf
      outputs.tf
    rds/
      main.tf
      variables.tf
      outputs.tf

Two levels: environments for per-env config, modules for reusable components. Start with one environment. Add staging/ when you actually need it, not before.

Remote state setup
#

Run this once manually to bootstrap the state backend:

# Create S3 bucket for state
aws s3api create-bucket \
  --bucket your-company-tf-state \
  --region us-east-1

# Enable versioning (so you can recover from bad applies)
aws s3api put-bucket-versioning \
  --bucket your-company-tf-state \
  --versioning-configuration Status=Enabled

# Block public access while you're here
aws s3api put-public-access-block \
  --bucket your-company-tf-state \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

That’s the whole bootstrap. No DynamoDB table — Terraform 1.10 added native S3 state locking via a lock file in the bucket itself, and 1.11 deprecated the dynamodb_table argument. On current Terraform, creating that table gets you an extra resource to manage and a deprecation warning on every init.

Backend configuration
#

environments/production/backend.tf:

terraform {
  # use_lockfile requires 1.10+
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket       = "your-company-tf-state"
    key          = "production/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Project     = var.project_name
    }
  }
}

VPC module
#

modules/vpc/main.tf:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = { Name = "${var.project_name}-vpc" }
}

resource "aws_subnet" "public" {
  count                   = length(var.availability_zones)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 4, count.index)
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = { Name = "${var.project_name}-public-${count.index}" }
}

resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 4, count.index + length(var.availability_zones))
  availability_zone = var.availability_zones[count.index]

  tags = { Name = "${var.project_name}-private-${count.index}" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project_name}-igw" }
}

resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "${var.project_name}-nat-eip" }
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id
  tags          = { Name = "${var.project_name}-nat" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project_name}-public-rt" }
}

resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

resource "aws_route_table_association" "public" {
  count          = length(aws_subnet.public)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project_name}-private-rt" }
}

resource "aws_route" "private_nat" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main.id
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

modules/vpc/variables.tf:

variable "project_name" {
  type = string
}

variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "availability_zones" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}

modules/vpc/outputs.tf:

output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

ECS Fargate module
#

modules/ecs/main.tf:

resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${var.project_name}"
  retention_in_days = 30
}

resource "aws_security_group" "ecs_tasks" {
  name_prefix = "${var.project_name}-ecs-"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "alb" {
  name_prefix = "${var.project_name}-alb-"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  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_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnet_ids
}

resource "aws_lb_target_group" "app" {
  name        = "${var.project_name}-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_iam_role" "ecs_execution" {
  name = "${var.project_name}-ecs-execution"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_execution" {
  role       = aws_iam_role.ecs_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# The HTTPS listener the port-80 redirect above points at. Without this,
# the ALB answers :80 with a 301 to a port nobody is listening on.
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = var.project_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.task_cpu
  memory                   = var.task_memory
  execution_role_arn       = aws_iam_role.ecs_execution.arn

  container_definitions = jsonencode([{
    name      = var.project_name
    image     = var.container_image
    essential = true

    portMappings = [{
      containerPort = var.container_port
      protocol      = "tcp"
    }]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.ecs.name
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "ecs"
      }
    }
  }])
}

resource "aws_ecs_service" "app" {
  name            = var.project_name
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = var.project_name
    container_port   = var.container_port
  }

  # CI deploys new task definition revisions; don't let Terraform revert them
  lifecycle {
    ignore_changes = [task_definition, desired_count]
  }

  depends_on = [aws_lb_listener.https]
}

modules/ecs/variables.tf:

variable "project_name" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "public_subnet_ids" {
  type = list(string)
}

variable "private_subnet_ids" {
  type = list(string)
}

variable "container_port" {
  type    = number
  default = 3000
}

variable "container_image" {
  type        = string
  description = "Full ECR image URI including tag"
}

variable "certificate_arn" {
  type        = string
  description = "ACM certificate for the HTTPS listener"
}

variable "aws_region" {
  type = string
}

variable "task_cpu" {
  type    = string
  default = "256"
}

variable "task_memory" {
  type    = string
  default = "512"
}

variable "desired_count" {
  type    = number
  default = 2
}

modules/ecs/outputs.tf:

output "cluster_name" {
  value = aws_ecs_cluster.main.name
}

output "alb_dns_name" {
  value = aws_lb.main.dns_name
}

output "execution_role_arn" {
  value = aws_iam_role.ecs_execution.arn
}

# Needed by the RDS module so the database can allow traffic from tasks.
# Without this output there is no way to wire the two modules together.
output "tasks_security_group_id" {
  value = aws_security_group.ecs_tasks.id
}

RDS PostgreSQL module
#

modules/rds/main.tf:

resource "aws_security_group" "rds" {
  name_prefix = "${var.project_name}-rds-"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = var.app_security_group_ids
  }
}

resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet"
  subnet_ids = var.private_subnet_ids
}

resource "aws_db_instance" "main" {
  identifier     = "${var.project_name}-db"
  engine = "postgres"
  # Major version only. Pinning a minor puts you in permanent drift against
  # auto_minor_version_upgrade, which AWS applies on its own schedule.
  engine_version             = "16"
  auto_minor_version_upgrade = true
  instance_class             = var.instance_class

  allocated_storage     = 20
  max_allocated_storage = 100
  storage_type          = "gp3"
  storage_encrypted     = true

  db_name  = var.database_name
  username = var.database_username

  # Let AWS generate and rotate the password into Secrets Manager instead of
  # passing one in. A `password` argument ends up in plaintext in your state
  # file, which is the one place you least want it.
  manage_master_user_password = true

  multi_az               = false  # Set true for production when budget allows
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  skip_final_snapshot       = false
  final_snapshot_identifier = "${var.project_name}-db-final"
  deletion_protection       = true

  # Performance Insights is NOT available on burstable micro/small classes
  # (db.t3.micro/small, db.t4g.micro/small) — enabling it there fails the
  # apply outright. Turn it on when you move to db.t4g.medium or larger.
  performance_insights_enabled = var.enable_performance_insights
}

modules/rds/variables.tf:

variable "project_name" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "private_subnet_ids" {
  type = list(string)
}

variable "app_security_group_ids" {
  type = list(string)
}

variable "instance_class" {
  type    = string
  default = "db.t4g.micro"  # ~$12/month, good enough to start
}

variable "database_name" {
  type    = string
  default = "app"
}

variable "database_username" {
  type    = string
  default = "app"
}

variable "enable_performance_insights" {
  type        = bool
  default     = false
  description = "Unsupported on db.t3/t4g micro and small — leave false there"
}

modules/rds/outputs.tf:

output "endpoint" {
  value = aws_db_instance.main.endpoint
}

output "database_name" {
  value = aws_db_instance.main.db_name
}

# ARN of the Secrets Manager secret AWS created for the master password.
# Grant your task role read access to this and fetch it at runtime.
output "master_user_secret_arn" {
  value = aws_db_instance.main.master_user_secret[0].secret_arn
}

Root module
#

environments/production/main.tf:

module "vpc" {
  source       = "../../modules/vpc"
  project_name = var.project_name
}

module "ecs" {
  source             = "../../modules/ecs"
  project_name       = var.project_name
  aws_region         = var.aws_region
  vpc_id             = module.vpc.vpc_id
  public_subnet_ids  = module.vpc.public_subnet_ids
  private_subnet_ids = module.vpc.private_subnet_ids
  container_image    = var.container_image
  certificate_arn    = var.certificate_arn
}

module "rds" {
  source             = "../../modules/rds"
  project_name       = var.project_name
  vpc_id             = module.vpc.vpc_id
  private_subnet_ids = module.vpc.private_subnet_ids

  # Wire the modules directly. The earlier version of this article passed an
  # empty list here with a note to "add the ID after the first apply" — that
  # never works: a security group rule with no source is rejected by the AWS
  # API, so the first apply fails, and the ECS module exposed no output to
  # copy from anyway.
  app_security_group_ids = [module.ecs.tasks_security_group_id]
}

Terraform resolves the dependency graph from those references, so module.ecs is created before the RDS security group rule that points at it. There is no ordering for you to manage and no two-pass apply.

environments/production/variables.tf:

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "environment" {
  type    = string
  default = "production"
}

variable "project_name" {
  type    = string
  default = "your-app-name"
}

variable "container_image" {
  type = string
}

variable "certificate_arn" {
  type = string
}

environments/production/terraform.tfvars:

project_name    = "your-app-name"
aws_region      = "us-east-1"
environment     = "production"
container_image = "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/your-app-name:latest"
certificate_arn = "arn:aws:acm:us-east-1:YOUR_ACCOUNT_ID:certificate/..."

No database password anywhere — RDS generates it into Secrets Manager, and the application reads it from there at runtime.

GitHub Actions pipeline
#

Save as .github/workflows/terraform.yml:

name: Terraform

on:
  pull_request:
    paths:
      - 'terraform/**'
  push:
    branches: [main]
    paths:
      - 'terraform/**'

env:
  TF_WORKING_DIR: terraform/environments/production
  AWS_REGION: us-east-1

# OIDC instead of long-lived keys. Without id-token: write the role
# assumption fails; without pull-requests: write the plan comment gets a
# 403, since GITHUB_TOKEN has been read-only by default since Feb 2023.
permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.13'  # >= 1.10 for use_lockfile

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-terraform
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform init

      - name: Terraform Format Check
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform fmt -check -recursive

      - name: Terraform Validate
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform validate

      - name: Terraform Plan
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform plan -no-color -out=tfplan

      # Hand the exact plan to the apply job. Re-planning there would apply
      # something nobody reviewed.
      - name: Upload plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: ${{ env.TF_WORKING_DIR }}/tfplan
          retention-days: 5

      - name: Post plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const plan = execSync(
              `cd ${{ env.TF_WORKING_DIR }} && terraform show -no-color tfplan`
            ).toString().slice(0, 60000);

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `### Terraform Plan\n\`\`\`\n${plan}\n\`\`\``
            });

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.13'

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-terraform
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform init

      - name: Download plan
        uses: actions/download-artifact@v4
        with:
          name: tfplan
          path: ${{ env.TF_WORKING_DIR }}

      - name: Terraform Apply
        working-directory: ${{ env.TF_WORKING_DIR }}
        # The saved plan file, not a fresh one. This is what makes the
        # approval meaningful.
        run: terraform apply tfplan

Set up the production environment in GitHub repo settings with required reviewers for the manual approval gate on applies. Note that required reviewers on a private repository need a Team or Enterprise plan — on the free plan the environment exists but the gate doesn’t.

What this costs
#

Worth being explicit, because the NAT Gateway surprises people:

Item Monthly
NAT Gateway (one AZ) ~$33 + data processing
Application Load Balancer ~$16 + LCU
RDS db.t4g.micro + 20GB gp3 ~$14
Fargate, 2 × (0.25 vCPU / 0.5 GB), always on ~$18
CloudWatch logs and metrics ~$5
Total, fixed ~$86

Data processing on the NAT Gateway ($0.045/GB) and ALB capacity units are usage-driven and sit on top; for a small app they add single-digit to low-double-digit dollars, so budget $90-110 in practice.

Note the Fargate line covers two tasks, because desired_count defaults to 2. One task halves it and removes your redundancy — a real choice, not an oversight to fix.

The NAT Gateway is the largest single line — over a third of the fixed cost, and more once data processing is counted. If your tasks don’t need outbound internet — or can reach what they need through VPC endpoints — dropping it is the single biggest saving available here.

Getting started checklist
#

  • Bootstrap the S3 state bucket (no DynamoDB table needed)
  • Create the GitHub OIDC provider and the deploy role in AWS
  • Copy the module structure into your repo
  • Update terraform.tfvars with your project name, image URI and certificate ARN
  • Create the production environment in GitHub with required reviewers
  • Open a PR with the Terraform code to see the plan
  • Merge to apply

At the end of that list you have a running ALB → ECS service and an RDS instance. You do not yet have an application that can reach the database, and the gap is deliberate rather than hidden: how the credentials get into your container depends on your runtime, so writing it here would be guessing at your setup.

Three pieces are missing, and they belong together:

  1. A task role. The module defines an execution role — the one ECS uses to pull the image and write logs — but no task role, which is the identity your application code runs as. They are different roles and only the second one can read a secret at runtime.
  2. A policy on that role granting secretsmanager:GetSecretValue on the ARN from the master_user_secret_arn output, and nothing else.
  3. A secrets block in the container definition mapping that ARN to an environment variable, plus a plain environment entry for the database endpoint. ECS resolves the secret at task start; the value never appears in the task definition.

The reason this is a footnote rather than a code block: the wiring depends on your runtime, your image and where your IAM boundaries sit. Terraform that looks right and has never been applied is how starter templates earn their reputation, so this stops where the verified part stops.

When to outgrow this stack
#

  • Multiple environments: Copy the production/ directory to staging/, change the backend key and tfvars
  • Multiple services: Add more modules, or split into separate state files per service
  • Team grows past 10: Consider Terragrunt for DRY multi-environment configs, or move to Terraform Cloud for collaboration features

Related reads #

Reply by Email