Skip to content

Project: Deploy a Web App on AWS With Terraform

The rest of the Terraform section teaches concepts one at a time. This project puts them together: you deploy a real web application to AWS, from an empty account to a load-balanced, autoscaling service with a database, HTTPS, and a deployment pipeline. Every line of Terraform is on these pages, and you apply it in stages so you can see each layer work before adding the next.

What You'll Build

flowchart TB
    U["Users"] -->|HTTPS| ALB
    subgraph VPC["VPC 10.20.0.0/16, 2 Availability Zones"]
        subgraph Public["Public subnets"]
            ALB["Application Load Balancer"]
            NAT["NAT gateway"]
        end
        subgraph Private["Private subnets"]
            T1["ECS Fargate task"]
            T2["ECS Fargate task"]
            DB[("RDS PostgreSQL")]
        end
    end
    ALB --> T1
    ALB --> T2
    T1 --> DB
    T2 --> DB
    T1 -.->|"image, logs, secrets"| NAT
    NAT -.-> AWS["ECR, CloudWatch Logs, Secrets Manager"]
    GH["GitHub Actions (OIDC)"] -->|"push image, terraform apply"| AWS
Layer AWS service Page
State, image registry, CI roles S3, ECR, IAM OIDC 1. Bootstrap
The application A Python web app in a container 2. App and image
Network and firewall VPC, subnets, NAT, security groups 3. Network
Data RDS PostgreSQL, Secrets Manager 4. Database
Compute and traffic ECS Fargate, ALB, autoscaling, CloudWatch 5. Load balancer and ECS
Custom domain ACM, Route 53 6. HTTPS
Delivery GitHub Actions, terraform test 7. CI/CD
Day 2 Deploys, scaling, drift, teardown 8. Operate and destroy

The application is deliberately small: a page counter that writes to PostgreSQL and shows which task served the request. Small enough to read in a minute, real enough to need a network, a database, secrets, health checks, and rolling deploys.

Design Decisions

Decision Why
ECS Fargate, not EC2 or Kubernetes No servers to patch. The Kubernetes section covers EKS when you need it.
Private subnets for tasks and the database Nothing but the load balancer is reachable from the internet.
Security groups that reference each other The database accepts traffic from the app's security group, not from an IP range.
RDS-managed master password The password lives in Secrets Manager and never appears in code, state, or the task definition.
ARM64 (Graviton) Cheaper per vCPU on Fargate, and Apple silicon laptops build ARM64 images natively.
Plain resources, no community modules You see every resource. The last page shows how to split them into modules.
S3 state with native locking Terraform 1.10 and later lock state with a lock file in the bucket, so no DynamoDB table is needed.

Cost

This deploys billable resources. Approximate us-east-1 on-demand prices for the dev configuration:

Resource Approx. per hour
NAT gateway $0.045, plus $0.045 per GB processed
Application Load Balancer $0.0225, plus capacity units
2 Fargate tasks, 0.25 vCPU and 0.5 GB, ARM64 $0.02
RDS db.t4g.micro, 20 GB gp3 $0.02
3 public IPv4 addresses (ALB and NAT) $0.015
Total About $0.12 per hour, roughly $3 a day

Run it for an afternoon, then destroy it (page 8). Leaving it up for a month costs around $90. Check current prices in the AWS Pricing Calculator, and set a budget alert before you start.

Prerequisites

  • An AWS account you can experiment in. A separate sandbox account in an AWS Organization is ideal. Don't use an account that runs production.
  • AWS CLI v2, signed in: aws sts get-caller-identity should print your account and role.
  • Terraform 1.10 or later. These pages were tested with Terraform 1.13 and AWS provider 6.71.
  • Docker, to build the image.
  • Comfort with the Terraform fundamentals, state and backends, and the AWS networking basics.

Repository Layout

By the end, your repository looks like this:

webapp/
├── app/                      # the web application
│   ├── app.py
│   ├── requirements.txt
│   ├── Dockerfile
│   └── compose.yaml          # run it locally with PostgreSQL
├── bootstrap/                # one-time: state bucket, ECR, CI roles (local state)
│   └── main.tf
├── infra/                    # the application stack (remote state)
│   ├── versions.tf
│   ├── variables.tf
│   ├── main.tf
│   ├── network.tf
│   ├── security.tf
│   ├── database.tf
│   ├── alb.tf
│   ├── ecs.tf
│   ├── outputs.tf
│   ├── backend.hcl
│   ├── dev.tfvars
│   └── tests/stack.tftest.hcl
├── .github/workflows/deploy.yml
└── .gitignore

Next

Start with Step 1: Bootstrap State, Registry, and CI Access.