When we decided to deploy our Flask web application, we had several options: EC2, Elastic Beanstalk, App Runner, or ECS Fargate. We chose ECS Fargate with AWS CDK, and this post explains why and how we set it up with cost optimization in mind.
Why ECS Fargate?
Fargate eliminates the need to manage servers while giving us full control over our container configuration. Key benefits for our use case:
- No server management: AWS handles the underlying infrastructure
- Fine-grained control: Unlike App Runner, we control networking, load balancing, and scaling
- Cost predictability: Pay only for the resources your containers use
- Spot instances: Up to 70% savings with Fargate Spot
The Architecture
Our ECS deployment consists of:
- VPC: Public subnets only (no NAT Gateway needed)
- ALB: Application Load Balancer for traffic distribution
- ECS Cluster: Fargate tasks running our Flask app
- ECR: Container registry for our Docker images
- CloudFront: CDN for global distribution and caching
Cost Optimization Strategies
Running containers in AWS can get expensive quickly. Here's how we keep costs down:
1. No NAT Gateway
NAT Gateways cost ~$32/month minimum. By using public subnets with public IPs for our Fargate tasks, we eliminate this cost entirely.
2. Fargate Spot
We run 2/3 of our tasks on Fargate Spot at ~70% discount. The remaining 1/3 runs on-demand for availability.
capacity_provider_strategies=[
ecs.CapacityProviderStrategy(
capacity_provider="FARGATE_SPOT",
weight=2, # 2/3 on Spot
),
ecs.CapacityProviderStrategy(
capacity_provider="FARGATE",
weight=1, # 1/3 on-demand
),
]
3. Right-sized Tasks
We started with the smallest Fargate configuration and scaled up only when needed:
| Resource | Configuration | Monthly Cost |
|---|---|---|
| CPU | 0.25 vCPU | ~$3 |
| Memory | 512 MB | ~$1 |
| Tasks | 2 (min) | ~$8 total |
CDK Implementation
We use a modular approach with reusable CDK constructs. Each infrastructure component is a separate stack:
# Stack composition in services/web_service.py
self.vpc_stack = VpcStack(...)
self.alb_stack = AlbStack(..., vpc=self.vpc_stack.vpc)
self.ecs_stack = EcsStack(...,
vpc=self.vpc_stack.vpc,
alb=self.alb_stack.alb
)
self.cloudfront_stack = CloudFrontStack(...)
Deployment Pipeline
Our deployment process:
- Push code to GitHub
- GitHub Actions builds Docker image (linux/amd64)
- Push to ECR with git SHA tag
- Force new ECS deployment
- Wait for service stabilization
# Force new deployment
aws ecs update-service \
--cluster primersky-cluster \
--service primersky-web-service \
--force-new-deployment
Lessons Learned
-
Target type matters: Fargate requires
TargetType.IP, not INSTANCE. This caused us hours of debugging. - Health checks need tuning: Start with generous intervals and tighten later.
- Logging is essential: CloudWatch Logs integration is worth the small cost.
- Cross-region certificates: CloudFront requires certificates in us-east-1, while ALB needs them in the same region.
Current Monthly Cost
| Service | Cost |
|---|---|
| ECS Fargate (Spot mix) | ~$12 |
| Application Load Balancer | ~$16 |
| CloudFront | ~$1 |
| ECR Storage | ~$1 |
| Total | ~$30/month |
For a production Flask application with high availability, HTTPS, CDN, and auto-scaling, $30/month is excellent value. The key is making smart architectural decisions from the start.