Deploying a Fullstack App on AWS — ECS Fargate Capstone
A capstone exercise weaving all the services from Chapters 1 ~ 31 into one. It deploys modern-react's Next.js app and modern-python's FastAPI app on one account with ECS Fargate + RDS + S3 + CloudFront + ALB + Secrets Manager + Terraform, and lays out the step-by-step Terraform code, the 13-step PR flow, a minimal-cost setup at about $10/month, and a comparison with the EKS deployment in the Kubernetes book.
This is where the book arrives. The services we learned separately in Chapters 1 ~ 31 — IAM · VPC · S3 · RDS · ALB · CloudFront · ECS Fargate · ECR · Secrets Manager · CloudWatch · Terraform — come together in this chapter into one working system. The domain is the same fullstack Todo app we built in the Modern Python book and the React book.
This capstone pairs with Part 6 of the Kubernetes book. We deploy the same app with the same domain, but the Kubernetes book goes the EKS route, and this book goes the ECS Fargate route. Reading the two capstones side by side makes the operational difference between “managed containers vs Kubernetes” clear at the code level.
What We Deploy #
| Component | Source | AWS placement |
|---|---|---|
| Frontend | the Next.js app from React | ECS Fargate (SSR) + ALB, static assets on S3 + CloudFront |
| Backend API | the FastAPI app from Modern Python | ECS Fargate + ALB |
| Database | — | RDS PostgreSQL (Aurora Serverless v2) |
| Secrets | — | Secrets Manager |
| Domain / TLS | — | Route 53 + ACM |
Target Architecture #
Route 53 (domain)
│
┌────────┴─────────┐
▼ ▼
CloudFront ALB (HTTPS, ACM)
(static assets/S3) ╱ ╲
▼ ▼
ECS Fargate ECS Fargate
(Next.js SSR) (FastAPI API)
private subnet (2 AZ)
│
▼
RDS PostgreSQL
(Aurora Serverless v2)
isolated subnet
▲
Secrets Manager (DB credentials)We put it on top of the 3-tier subnets from Chapter 28 VPC in Depth. ALB and CloudFront are in the public tier, the Fargate Tasks in private, and RDS in the isolated tier.
Repository Structure #
All the infrastructure is codified with the module structure from Chapter 25 Intro to Terraform. There are no console clicks.
infra/
├── backend.tf # Step 1: state store
├── providers.tf # provider + region
├── vpc.tf # Step 2: 3-tier VPC
├── ecr.tf # Step 3: image repositories
├── rds.tf # Steps 4·5: Aurora Serverless v2 + secret
├── alb.tf # Step 6: ALB + ACM + target groups
├── ecs-api.tf # Steps 7·8: FastAPI Task/Service
├── ecs-web.tf # Step 9: Next.js Task/Service
├── cdn.tf # Step 10: S3 + CloudFront
├── dns.tf # Step 11: Route 53
├── monitoring.tf # Step 13: CloudWatch alarms
└── variables.tfStep 1 — Terraform Backend #
Put state in S3 rather than locally, and lock concurrent runs with DynamoDB. This is the prerequisite for a team to handle the same infrastructure safely.
terraform {
required_version = ">= 1.9"
backend "s3" {
bucket = "myapp-tfstate"
key = "capstone/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "myapp-tflock"
encrypt = true
}
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}Step 2 — VPC 3-tier #
We bring up the 3-tier (public / private / isolated) design from Chapter 28 as a module. database_subnets is the isolated tier.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "myapp"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
public_subnets = ["10.0.0.0/20", "10.0.128.0/20"] # ALB, NAT
private_subnets = ["10.0.16.0/20", "10.0.144.0/20"] # Fargate
database_subnets = ["10.0.32.0/20", "10.0.160.0/20"] # RDS (isolated)
enable_nat_gateway = true
single_nat_gateway = false # NAT per AZ (recommended in Chapter 28)
}
# Route S3 / ECR traffic through Endpoints rather than NAT (cost savings)
module "vpc_endpoints" {
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
version = "~> 5.0"
vpc_id = module.vpc.vpc_id
endpoints = {
s3 = { service = "s3", service_type = "Gateway", route_table_ids = module.vpc.private_route_table_ids }
ecr_api = { service = "ecr.api", subnet_ids = module.vpc.private_subnets }
ecr_dkr = { service = "ecr.dkr", subnet_ids = module.vpc.private_subnets }
}
}Step 3 — ECR Repositories #
The Chapter 16 ECR repositories to hold the two apps’ images.
resource "aws_ecr_repository" "api" {
name = "myapp-api"
image_tag_mutability = "MUTABLE"
image_scanning_configuration { scan_on_push = true }
}
resource "aws_ecr_repository" "web" {
name = "myapp-web"
image_scanning_configuration { scan_on_push = true }
}Steps 4·5 — Aurora Serverless v2 and the Secret #
We put the Aurora Serverless v2 from Chapter 11 RDS in an isolated subnet. With manage_master_user_password = true, RDS creates and rotates the password directly in Secrets Manager (Chapter 20). We never touch a plaintext password.
resource "aws_rds_cluster" "main" {
cluster_identifier = "myapp"
engine = "aurora-postgresql"
engine_version = "16.4"
database_name = "myapp"
master_username = "myapp"
manage_master_user_password = true # → auto-created in Secrets Manager
db_subnet_group_name = module.vpc.database_subnet_group_name
vpc_security_group_ids = [aws_security_group.rds.id]
serverlessv2_scaling_configuration {
min_capacity = 0 # 0 ACU auto-pause (zero compute billing when idle)
max_capacity = 4.0
}
skip_final_snapshot = true # learning environment only
}
resource "aws_rds_cluster_instance" "main" {
cluster_identifier = aws_rds_cluster.main.id
instance_class = "db.serverless"
engine = aws_rds_cluster.main.engine
}
# The secret ARN created by RDS — the ECS Task references this
output "db_secret_arn" {
value = aws_rds_cluster.main.master_user_secret[0].secret_arn
}Step 6 — ALB and ACM #
The HTTPS termination from Chapter 13 ALB / NLB and ACM. By the host header, api.example.com goes to the API target group and everything else to the Next.js target group.
resource "aws_lb" "main" {
name = "myapp-alb"
load_balancer_type = "application"
subnets = module.vpc.public_subnets
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.main.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn # default = Next.js
}
}
resource "aws_lb_listener_rule" "api" {
listener_arn = aws_lb_listener.https.arn
priority = 10
action { type = "forward", target_group_arn = aws_lb_target_group.api.arn }
condition { host_header { values = ["api.example.com"] } }
}Steps 7·8 — FastAPI Task and Migration #
The Task definition from Chapter 15 ECS / Fargate · Chapter 22 Infrastructure Skeleton. Because we inject the DB secret via secrets, there’s no plaintext in the environment variables.
resource "aws_ecs_task_definition" "api" {
family = "myapp-api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512"
memory = "1024"
execution_role_arn = aws_iam_role.ecs_exec.arn # ECR pull, secret read
task_role_arn = aws_iam_role.app.arn # permissions the app uses
container_definitions = jsonencode([{
name = "api"
image = "${aws_ecr_repository.api.repository_url}:latest"
portMappings = [{ containerPort = 8000 }]
secrets = [{
name = "DB_SECRET" # the secret RDS created (host·password JSON)
valueFrom = aws_rds_cluster.main.master_user_secret[0].secret_arn
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/myapp-api"
"awslogs-region" = "ap-northeast-2"
"awslogs-stream-prefix" = "api"
}
}
}])
}
resource "aws_ecs_service" "api" {
name = "myapp-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2 # 2 AZ
launch_type = "FARGATE"
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.api.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 8000
}
}The DB migration (Chapter 23) runs as a one-off Task, not as a service. Same image, just a different command.
aws ecs run-task \
--cluster myapp \
--task-definition myapp-api \
--launch-type FARGATE \
--overrides '{"containerOverrides":[{"name":"api","command":["alembic","upgrade","head"]}]}' \
--network-configuration "awsvpcConfiguration={subnets=[subnet-...],securityGroups=[sg-...]}"Steps 9·10·11 — Next.js · Static Assets · Domain #
Next.js SSR is brought up as a Fargate service the same way as the API (ecs-web.tf). The built static assets are served via Chapter 10 S3 + Chapter 14 CloudFront, and the domain is connected to ALB / CloudFront as an Alias via Chapter 12 Route 53.
resource "aws_route53_record" "web" {
zone_id = data.aws_route53_zone.main.zone_id
name = "example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.web.domain_name
zone_id = aws_cloudfront_distribution.web.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.main.zone_id
name = "api.example.com"
type = "A"
alias {
name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true
}
}Step 12 — CI/CD #
With the Chapter 24 CI/CD GitHub Actions, push the image to ECR and rolling-update ECS.
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-2
- uses: aws-actions/amazon-ecr-login@v2
- run: |
docker build -t $ECR/myapp-api:$GITHUB_SHA ./api
docker push $ECR/myapp-api:$GITHUB_SHA
- run: |
aws ecs update-service --cluster myapp --service myapp-api \
--force-new-deploymentInstead of long-lived credentials in GitHub, we use OIDC role assumption (role-to-assume) (the least-privilege principle from Chapter 6 Security Basics).
Step 13 — Monitoring·Alarms #
After setting the alarms from Chapter 26 Monitoring — CloudWatch Alarms and X-Ray, we watch ALB 5xx and Fargate CPU.
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "myapp-alb-5xx"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
statistic = "Sum"
period = 60
evaluation_periods = 5
threshold = 10
comparison_operator = "GreaterThanThreshold"
alarm_actions = [aws_sns_topic.alerts.arn] # alert via Chapter 19 SNS
dimensions = { LoadBalancer = aws_lb.main.arn_suffix }
}The alarm is sent to the SNS topic from Chapter 19 EventBridge / SQS / SNS and received via Slack·email.
The 13 Steps at a Glance #
| # | Step | File | Related chapter |
|---|---|---|---|
| 1 | Terraform backend | backend.tf | Chapter 25 |
| 2 | VPC 3-tier | vpc.tf | Chapter 28 |
| 3 | ECR | ecr.tf | Chapter 16 |
| 4·5 | Aurora + secret | rds.tf | Chapter 11 · Chapter 20 |
| 6 | ALB + ACM | alb.tf | Chapter 13 |
| 7·8 | FastAPI + migration | ecs-api.tf | Chapter 15 · Chapter 22 · Chapter 23 |
| 9 | Next.js | ecs-web.tf | Chapter 15 |
| 10·11 | S3/CloudFront · Route 53 | cdn.tf · dns.tf | Chapter 10 · Chapter 14 · Chapter 12 |
| 12 | CI/CD | deploy.yml | Chapter 24 |
| 13 | Monitoring·alarms | monitoring.tf | Chapter 26 · Chapter 19 |
Thanks to this codification, the Pilot Light DR of Chapter 30 Disaster Recovery simplifies to “apply the same code in another region.”
Cost — Following Along at About $10/month #
A setup that minimizes cost while following all the way through for learning.
- Aurora Serverless v2 at min 0 ACU (auto-pause) so compute billing is zero when idle (storage only). The first connection adds about a 15-second resume, so in production set it to 0.5+ instead of 0.
- Bring up Tasks with Fargate Spot to lower the unit price (learning only; production mixes with regular Fargate).
- VPC Endpoints instead of NAT — handle ECR / S3 / Secrets Manager via Endpoints (Chapter 28) to avoid NAT processing costs.
- When the exercise is done, clean up immediately with
terraform destroy.
This setup runs around $10/month. ALB · NAT · Aurora are billed as long as they’re on, so be sure to clean up when the exercise is done, and set up the billing alerts from Chapter 27 Cost Optimization in advance.
terraform destroyComparison with the modern-kubernetes Book #
Part 6 of the Kubernetes book deploys the same Todo system on EKS. Here are the differences when the same domain is implemented on two platforms.
| Aspect | This book (ECS Fargate) | Kubernetes book (EKS) |
|---|---|---|
| Orchestration | ECS Task definitions | k8s Deployment / Service |
| Deployment unit | Terraform + ECS rolling | Helm + ArgoCD GitOps |
| Control-plane cost | none (Fargate) | EKS control plane hourly |
| Learning curve | low | high |
| Portability | AWS-locked | multi-cloud possible |
| Fit | small team, single domain, fast launch | multi-domain, GitOps, multi-cloud option |
Decision rule: for a small team + single domain, ECS Fargate is efficient — lower control-plane cost and lower learning curve. Once you need multi-domain + GitOps + multi-cloud possibility, that’s when you consider EKS. That’s why the Part 6 of both books shows the same app on two routes.
Exercises #
- Write one paragraph on why you split
terraform applyinto small PRs rather than doing all 13 steps in one go. Explain what gets blocked if you apply ECS (step 7) without the VPC (step 2), using the dependencies in §“Repository Structure.” - Write, as one flow, the path by which this capstone’s DB password never remains as plaintext anywhere in the code · image · Terraform state, based on
manage_master_user_passwordand the Task’ssecretsinjection (steps 4·5·7). - Decide whether to deploy your service on ECS Fargate or EKS using the criteria in the §“Comparison with the modern-kubernetes Book” table, and write the rationale in one paragraph.
In short: The Part 6 capstone deploys modern-react’s Next.js and modern-python’s FastAPI on one account with ECS Fargate + Aurora Serverless v2 + S3 + CloudFront + ALB + Secrets Manager, on top of Chapter 28’s 3-tier VPC, in 13 steps. Every step is codified as a Terraform file (backend / vpc / ecr / rds / alb / ecs / cdn / dns / monitoring) so there are no console clicks, and this codification becomes the prerequisite for Chapter 30’s Pilot Light DR. The DB password is created by RDS in Secrets Manager via
manage_master_user_passwordand injected through the task’s secrets, so it never remains as plaintext anywhere, and CI/CD deploys without long-lived keys via OIDC role assumption. Follow along at about $10/month with min-ACU Aurora + Fargate Spot + VPC Endpoints, and destroy when done. Compared with Part 6 of the Kubernetes book, which deploys the same app on EKS, the split is ECS Fargate for a small team and single domain, and EKS for multi-domain, GitOps, and multi-cloud.
Next Chapter #
The body ends here. Finally, in Appendix A CLF-C02 Certification Bridge, we map where this book’s 27 chapters overlap with — and where they leave gaps against — the AWS Cloud Practitioner (CLF-C02) exam scope. It’s a bridge for those who want to connect what they learned in practice to the certification track.