Contents
Part 6: Capstone
  1. 32.Deploying a Fullstack App on AWS — ECS Fargate Capstone
32 Chapter

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 #

ComponentSourceAWS placement
Frontendthe Next.js app from ReactECS Fargate (SSR) + ALB, static assets on S3 + CloudFront
Backend APIthe FastAPI app from Modern PythonECS Fargate + ALB
DatabaseRDS PostgreSQL (Aurora Serverless v2)
SecretsSecrets Manager
Domain / TLSRoute 53 + ACM

Target Architecture #

ECS Fargate fullstack 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.

terraform directory
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.tf

Step 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.

backend.tf
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.

vpc.tf
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.

ecr.tf
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.

rds.tf
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.

alb.tf (excerpt)
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.

ecs-api.tf (excerpt)
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.

Run the Alembic migration as a one-off Fargate Task
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.

dns.tf (excerpt)
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.

.github/workflows/deploy.yml (excerpt)
- 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-deployment

Instead 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.

monitoring.tf (excerpt)
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 #

#StepFileRelated chapter
1Terraform backendbackend.tfChapter 25
2VPC 3-tiervpc.tfChapter 28
3ECRecr.tfChapter 16
4·5Aurora + secretrds.tfChapter 11 · Chapter 20
6ALB + ACMalb.tfChapter 13
7·8FastAPI + migrationecs-api.tfChapter 15 · Chapter 22 · Chapter 23
9Next.jsecs-web.tfChapter 15
10·11S3/CloudFront · Route 53cdn.tf · dns.tfChapter 10 · Chapter 14 · Chapter 12
12CI/CDdeploy.ymlChapter 24
13Monitoring·alarmsmonitoring.tfChapter 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.

Cleanup at the end of the exercise
terraform destroy

Comparison 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.

AspectThis book (ECS Fargate)Kubernetes book (EKS)
OrchestrationECS Task definitionsk8s Deployment / Service
Deployment unitTerraform + ECS rollingHelm + ArgoCD GitOps
Control-plane costnone (Fargate)EKS control plane hourly
Learning curvelowhigh
PortabilityAWS-lockedmulti-cloud possible
Fitsmall team, single domain, fast launchmulti-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 #

  1. Write one paragraph on why you split terraform apply into 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.”
  2. 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_password and the Task’s secrets injection (steps 4·5·7).
  3. 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_password and 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.

X