TL;DR #
- Terraform modules for VPC, ECS Fargate and RDS PostgreSQL
- GitHub Actions pipeline:
planon PRs,applyon 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.tfTwo 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 tfplanSet 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.tfvarswith your project name, image URI and certificate ARN - Create the
productionenvironment 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:
- 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.
- A policy on that role granting
secretsmanager:GetSecretValueon the ARN from themaster_user_secret_arnoutput, and nothing else. - A
secretsblock in the container definition mapping that ARN to an environment variable, plus a plainenvironmententry 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 tostaging/, 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 #
- Terraform for Small Teams — principles behind this setup
- Secrets Management for Small Teams — how to handle that database password properly
- CI/CD for Small Teams — the app deployment pipeline that pairs with this infra