This is a complete, production-grade reference implementation demonstrating a real-world AWS deployment architecture with infrastructure-as-code, CI/CD automation, and containerized multi-tier services.
Reference Implementation: This repository contains example application code and placeholder AWS values. Customize all configuration values for your production environment.
┌─────────────────────────────────────────────────────────────────┐
│ AWS Account │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Route53 + ACM │ │
│ │ • Hosted Zone: staging.example.com │ │
│ │ • Wildcard ACM Certificate: *.staging.example.com │ │
│ │ • DNS A Records: api.staging, staging.example.com │ │
│ └──────────────────┬───────────────────────────────────────┘ │
│ │ HTTPS (443) │
│ ┌──────────────────▼───────────────────────────────────────┐ │
│ │ Application Load Balancer (ALB) │ │
│ │ • Public Internet Facing │ │
│ │ • HTTP→HTTPS Redirect │ │
│ │ • Host-based routing (API vs Frontend) │ │
│ └──────────┬─────────────────────────────┬────────────────┘ │
│ │ │ │
│ ┌────────▼────────┐ ┌──────────▼──────────┐ │
│ │ API Target │ │ Frontend Target │ │
│ │ Group (TCP │ │ Group (TCP 3000) │ │
│ │ 8000) │ │ │ │
│ └────────┬────────┘ └──────────┬──────────┘ │
│ │ │ │
│ ┌──────────▼──────────────────────────────▼──────────┐ │
│ │ ECS Cluster (Fargate) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ API Service (FastAPI) │ │ │
│ │ │ • Fargate Launch Type │ │ │
│ │ │ • Desired Count: 2 (HA) │ │ │
│ │ │ • CPU: 512, Memory: 1024 MiB │ │ │
│ │ │ • Auto Scaling Policy │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Worker Service (Celery) │ │ │
│ │ │ • Fargate Launch Type │ │ │
│ │ │ • Desired Count: 1 │ │ │
│ │ │ • CPU: 512, Memory: 1024 MiB │ │ │
│ │ │ • Background Job Processing │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Frontend Service (Express) │ │ │
│ │ │ • Fargate Launch Type │ │ │
│ │ │ • Desired Count: 1 │ │ │
│ │ │ • CPU: 512, Memory: 1024 MiB │ │ │
│ │ │ • Static Web Serving │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────┬──────────────────┬──────────────────┘ │
│ │ │ │
│ ┌───────────▼──┐ ┌────────▼────────┐ │
│ │ ECR Repos │ │ CloudWatch Logs │ │
│ │ • API │ │ • /ecs/api │ │
│ │ • Worker │ │ • /ecs/worker │ │
│ │ • Frontend │ │ • /ecs/frontend │ │
│ └──────────────┘ └─────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Private Subnets (Data Tier) │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ RDS PostgreSQL │ │ ElastiCache Redis │ │ │
│ │ │ • db.t4g.micro │ │ • cache.t4g.micro │ │ │
│ │ │ • Multi-AZ capable │ │ • Single node (dev) │ │ │
│ │ │ • 20 GiB storage │ │ • Port 6379 │ │ │
│ │ │ • Backups: 7 days │ │ • Broker + Cache │ │ │
│ │ └─────────────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ VPC Security Groups │ │ │
│ │ │ • ALB SG: 80/443 from Internet │ │ │
│ │ │ • ECS SG: 8000/3000 from ALB │ │ │
│ │ │ • RDS SG: 5432 from ECS │ │ │
│ │ │ • Redis SG: 6379 from ECS │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
aws-ecs-reference/
├── backend/ # Python FastAPI + Celery services
│ ├── app/
│ │ ├── main.py # FastAPI application
│ │ ├── celery_app.py # Celery task queue
│ │ └── tasks.py # Background tasks
│ ├── tests/
│ │ └── test_health.py # API tests
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile # Dev container
│ ├── Dockerfile.prod # Production API container
│ └── Dockerfile.celery.prod # Production worker container
│
├── frontend/ # Express.js web service
│ ├── src/
│ │ └── server.js # Express server
│ ├── package.json # Node.js dependencies
│ ├── Dockerfile # Dev container
│ └── Dockerfile.prod # Production container
│
├── infra/terraform/ # AWS Infrastructure as Code
│ ├── main.tf # Core ECS, ALB, RDS, ElastiCache
│ ├── variables.tf # Terraform variables
│ ├── outputs.tf # Terraform outputs
│ ├── versions.tf # Provider versions
│ ├── terraform.staging.tfvars.example # Staging variables
│ ├── terraform.prod.tfvars.example # Production variables
│ ├── backend-staging.hcl.example # Staging S3 backend
│ └── backend-prod.hcl.example # Production S3 backend
│
├── deploy/task-definitions/ # ECS Task Definition Templates
│ ├── api.json.tmpl # API task definition
│ ├── worker.json.tmpl # Worker task definition
│ └── frontend.json.tmpl # Frontend task definition
│
├── scripts/ # Deployment automation
│ ├── deploy-ecs-local.sh # ECS deployment script
│ ├── .env.deploy.staging.example # Staging deployment env
│ └── .env.deploy.prod.example # Production deployment env
│
├── .github/workflows/ # GitHub Actions CI/CD (manual-only)
│ ├── lint.yml # Code linting
│ ├── test.yml # Unit tests
│ └── deploy.yml # ECS deployment
│
├── docker-compose.yaml # Local development stack
├── .env.backend.example # Backend env template
├── .env.frontend.example # Frontend env template
├── README.md # This file
├── AWS_DEPLOYMENT.md # AWS deployment guide
└── .gitignore # Git ignore rules
- Docker and Docker Compose
- Python 3.13+ (for local testing)
- Node.js 22+ (for frontend development)
-
Clone and navigate:
git clone <repo-url> aws-ecs-ref cd aws-ecs-ref
-
Copy environment templates:
cp .env.backend.example .env.backend cp .env.frontend.example .env.frontend
-
Start all services (Docker Compose starts: PostgreSQL, Redis, API, Worker, Frontend):
docker compose up --build
-
Access services:
- Frontend: http://localhost:3000
- API health check: http://localhost:8000/health/
- API docs (if implemented): http://localhost:8000/docs
-
Stop services:
docker compose down
| Service | Port | Tech Stack | Purpose |
|---|---|---|---|
| PostgreSQL | 5432 | PostgreSQL 17 | Primary relational database |
| Redis | 6379 | Redis 7 | Celery broker and cache |
| API | 8000 | FastAPI + Uvicorn | REST API backend |
| Worker | — | Celery (background) | Async job processing |
| Frontend | 3000 | Express.js | Web application |
- AWS Account with appropriate permissions (VPC, RDS, ElastiCache, ECS, ECR, IAM)
- Existing VPC and Subnets (public and private subnets in at least 2 AZs)
- AWS CLI configured with credentials
- Terraform >= 1.6.0
- Docker with Buildx support for multi-platform builds
- GitHub Repository with OIDC role configured (for GitHub Actions CI/CD)
-
Copy Terraform staging variables:
cd infra/terraform cp terraform.staging.tfvars.example terraform.staging.tfvars -
Fill in your AWS details in
terraform.staging.tfvars:aws_region = "us-east-1" vpc_id = "vpc-your-vpc-id" public_subnet_ids = ["subnet-public-1", "subnet-public-2"] private_subnet_ids = ["subnet-private-1", "subnet-private-2"] domain_name = "staging.your-domain.com" # Your actual domain db_password = "your-secure-password" create_route53_zone = false # Set to true if Route53 zone doesn't exist
-
Repeat for production:
cp terraform.prod.tfvars.example terraform.prod.tfvars # Edit with production domain, AWS details
-
Create S3 bucket and DynamoDB table for Terraform state (run once per AWS account):
aws s3 mb s3://myapp-terraform-state-staging --region us-east-1 aws dynamodb create-table \ --table-name myapp-terraform-locks \ --attribute-definitions AttributeName=LockID,AttributeType=S \ --key-schema AttributeName=LockID,KeyType=HASH \ --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ --region us-east-1
-
Copy and configure backend state:
cp backend-staging.hcl.example backend-staging.hcl # Edit backend-staging.hcl with your S3 bucket and DynamoDB table names -
Initialize Terraform:
terraform init -backend-config=backend-staging.hcl -reconfigure
-
Plan the deployment:
terraform plan -var-file=terraform.staging.tfvars
-
Apply infrastructure:
terraform apply -var-file=terraform.staging.tfvars
-
Capture outputs for deployment script:
terraform output -json > /tmp/staging-outputs.jsonKey outputs you'll need:
ecr_api_repository_urlecr_worker_repository_urlecr_frontend_repository_urlecs_cluster_nameecs_api_service_nameecs_worker_service_nameecs_frontend_service_namerds_endpointredis_endpoint- Relevant ARNs and security group IDs
-
Copy and configure deployment environment:
cd ../.. cp scripts/.env.deploy.staging.example scripts/.env.deploy.staging -
Fill in values from Terraform outputs:
# Edit scripts/.env.deploy.staging with: export AWS_REGION="us-east-1" export ECR_API_REPOSITORY_URI="<from terraform output>" export ECS_CLUSTER="<from terraform output>" # ... etc (see .env.deploy.staging.example for all variables)
-
Source the environment and deploy:
source scripts/.env.deploy.staging ./scripts/deploy-ecs-local.shThe script will:
- Build and push Docker images to ECR
- Register ECS task definitions
- Run database migrations
- Update ECS services
-
Verify deployment:
# Check service status aws ecs describe-services \ --cluster "$ECS_CLUSTER" \ --services "$ECS_API_SERVICE" "$ECS_WORKER_SERVICE" "$ECS_FRONTEND_SERVICE" \ --region us-east-1 # Check logs aws logs tail "/ecs/myapp-staging-api" --follow --region us-east-1
Load Balancer (ALB):
- Internet-facing Application Load Balancer
- HTTP 80 → HTTPS 301 redirect
- Host-based routing rules:
api.staging.example.com→ API target groupstaging.example.com→ Frontend target group
Containers (ECS Fargate):
- API Service: 2 tasks (HA), 512 CPU, 1 GiB memory
- Worker Service: 1 task, 512 CPU, 1 GiB memory
- Frontend Service: 1 task, 512 CPU, 1 GiB memory
Database (RDS PostgreSQL):
- db.t4g.micro instance (free tier eligible)
- 20 GiB storage, 7-day backups
- Private subnet (no public access)
- Security group allows only ECS access
Cache (ElastiCache Redis):
- cache.t4g.micro node (free tier eligible)
- Single node for development
- Celery broker and session store
- Private subnet, security group allows only ECS access
DNS & TLS:
- Route53 hosted zone (created or existing)
- ACM certificate for staging domain and wildcard
- DNS validation via Route53 records
- HTTPS listener on ALB
Logging:
- CloudWatch Log Groups for each service (30-day retention)
- Structured logs with Docker JSON driver
ECR Image Registries:
- Private registries for API, Worker, Frontend images
- Automatic image cleanup (old images deleted)
All workflows are manual-only (no automatic triggers on push/PR):
-
Lint Workflow (
lint.yml)- Static code analysis (Ruff for Python)
- Frontend linting
- Trigger: Manual via GitHub Actions UI
-
Test Workflow (
test.yml)- Runs API tests against temporary PostgreSQL and Redis
- Trigger: Manual via GitHub Actions UI
-
Deploy Workflow (
deploy.yml)- Builds and pushes Docker images to ECR
- Registers ECS task definitions
- Updates ECS services
- Requires GitHub OIDC role for AWS credentials
- Trigger: Manual via GitHub Actions UI (select staging or prod)
-
Create GitHub OIDC Identity Provider in AWS:
aws iam create-open-id-connect-provider \ --url https://token.actions.githubusercontent.com \ --client-id-list sts.amazonaws.com \ --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
-
Create GitHub Actions IAM Role:
# Create trust policy (trust-policy.json) cat > trust-policy.json <<EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:your-github-org/aws-ecs-reference:ref:refs/heads/main" } } } ] } EOF # Create role aws iam create-role \ --role-name github-actions-myapp-deploy \ --assume-role-policy-document file://trust-policy.json
-
Attach deployment policy to role:
# Create policy with permissions for ECR, ECS, CloudWatch cat > deploy-policy.json <<EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ], "Resource": "*" }, { "Effect": "Allow", "Action": [ "ecs:RegisterTaskDefinition", "ecs:UpdateService", "ecs:DescribeServices" ], "Resource": "*" }, { "Effect": "Allow", "Action": "iam:PassRole", "Resource": [ "arn:aws:iam::ACCOUNT_ID:role/myapp-*-ecs-task-execution", "arn:aws:iam::ACCOUNT_ID:role/myapp-*-ecs-task" ] } ] } EOF aws iam put-role-policy \ --role-name github-actions-myapp-deploy \ --policy-name myapp-deploy-policy \ --policy-document file://deploy-policy.json
-
Add GitHub repository secrets (in GitHub UI):
AWS_DEPLOY_ROLE_ARN = arn:aws:iam::ACCOUNT_ID:role/github-actions-myapp-deploy -
Add GitHub repository variables (in GitHub UI):
AWS_REGION = us-east-1
.env.backend:
APP_NAME: Application nameDEBUG: Enable debug mode (true for local)DATABASE_HOST: db (Docker Compose service name)REDIS_URL: redis://redis:6379/0
.env.frontend:
NODE_ENV: developmentPORT: 3000API_URL: http://api:8000 (Docker Compose service name)
scripts/.env.deploy.staging:
- AWS ECR repository URIs (from Terraform outputs)
- ECS cluster and service names (from Terraform outputs)
- RDS endpoint and credentials
- ElastiCache Redis endpoint
- Domain names for APIs (staging.example.com, api.staging.example.com)
scripts/.env.deploy.prod:
- Same structure as staging
- Production domain names
- Production ECR registry IDs
- Production RDS and Redis endpoints
| Aspect | Staging | Production |
|---|---|---|
| Domain | staging.example.com | example.com |
| API Endpoint | https://api.staging.example.com | https://api.example.com |
| ACM Certificate | Wildcard: *.staging.example.com | Wildcard: *.example.com |
| ECS Desired Count (API) | 2 (HA) | 2-4 (can scale) |
| RDS Instance | db.t4g.micro | db.t3.small+ |
| Redis Node | cache.t4g.micro | cache.t3.small+ |
| Log Retention | 30 days | 90 days |
| Deployment | Via scripts/.env.deploy.staging | Via scripts/.env.deploy.prod |
Before deploying to AWS, customize all of the following placeholder values:
domain_name: Change fromstaging.example.comto your actual domainvpc_id: Replacevpc-0123456789abcdefwith your VPC IDpublic_subnet_ids/private_subnet_ids: Replace with your actual subnet IDsdb_password: Replace with a secure password- AWS account ID in examples:
111122223333→ your actual AWS account ID
- ECR Repository URIs
- RDS endpoint and credentials
- Redis endpoint
- ECS cluster/service names
- Domain names and URLs
- AWS IAM role ARNs
AWS_DEPLOY_ROLE_ARN: Update with your actual role ARN
NEXTAUTH_SECRET(or your auth token)- API keys for external services
- Stripe, SendGrid, Cloudinary credentials (if used)
- Never commit filled environment files – Only commit
.exampletemplates - Use AWS Secrets Manager for production secrets instead of env files
- Enable MFA on AWS account and GitHub
- Restrict RDS to ECS security group only
- Rotate database passwords regularly
- Use IAM roles instead of hardcoded credentials
- Enable CloudTrail for AWS API audit logging
- Enable VPC Flow Logs for network troubleshooting
Port already in use:
# Kill process on port 8000
lsof -ti:8000 | xargs kill -9
# Or change ports in docker-compose.yamlPostgreSQL connection refused:
# Wait for PostgreSQL to become healthy
docker compose logs dbRedis connection timeout:
# Check Redis is running
docker compose logs redisECS task fails to start:
# Check CloudWatch logs
aws logs tail "/ecs/myapp-staging-api" --follow
# Check task details
aws ecs describe-tasks --cluster myapp-staging-cluster --tasks <task-arn> --region us-east-1ALB health check fails:
- Verify API is responding on 8000:
curl http://localhost:8000/health/ - Check security group allows ALB → ECS traffic
- Verify
ALLOWED_HOSTSincludes the domain
ACM certificate pending validation:
- Ensure Route53 records are created
- Run
terraform applyagain to create validation records - Wait for DNS propagation (up to 15 minutes)
This reference implementation is provided as-is for use as a template for your own deployments.