AWS Fargate is convenient but expensive at scale. Here’s how to migrate to ECS on EC2 with Terraform and cut container costs by 60-70% without losing automation.
Fargate is amazing. Serverless containers, no infrastructure management, automatic scaling. It’s AWS at its finest.
Fargate is also expensive. Like, really expensive.
Here’s the math that’ll make you rethink your container strategy:
Running 10 containers (1 vCPU, 2GB RAM each):
Fargate cost: $511/month
EC2 cost: $164/month
Difference: $347/month = $4,164/year
You're paying 3x more for convenience.
For small workloads? Worth it. For production at scale? You’re hemorrhaging money.
Let me show you how to get Fargate’s automation on EC2’s pricing with Terraform.
💸 The Fargate Tax
Fargate pricing per task (1 vCPU, 2GB RAM):
- vCPU: $0.04048/hour
- Memory: 2GB × $0.004445/GB-hour = $0.00889/hour
- Total: $0.04937/hour = $36/month per task
10 tasks running 24/7:
- Cost: 10 × $36 = $360/month
- Annual: $4,320
💰 The EC2 Alternative
t3.xlarge instance (4 vCPU, 16GB RAM):
- On-demand: $0.1664/hour = $121/month
- 1-year Reserved: $0.1027/hour = $75/month
- Capacity: Can run 10-12 containers easily
Running same 10 tasks on 2 × t3.xlarge:
- Cost: 2 × $75 = $150/month (Reserved Instances)
- Annual: $1,800
Savings: $2,520/year (58% reduction) 🎉
And that’s conservative. With Spot Instances? $40/month (89% savings!).
📊 When to Use What
Stick with Fargate:
✅ Prototype/MVP stage (< 5 containers)
✅ Unpredictable, bursty workloads
✅ Team has zero DevOps capacity
✅ Running <10 containers total
✅ Cost isn’t a primary concern
Switch to ECS on EC2:
✅ Running 10+ containers consistently
✅ Predictable traffic patterns
✅ Cost optimization is a priority
✅ You have basic infrastructure skills
✅ Production workloads at scale
Break-even point: Around 8-10 containers running 24/7.
🛠️ Terraform Implementation
Complete ECS on EC2 Setup
# modules/ecs-ec2-cluster/main.tf
# ECS Cluster
resource "aws_ecs_cluster" "main" {
name = "production-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
tags = {
Name = "production-ecs-cluster"
}
}
# Launch template for ECS instances
resource "aws_launch_template" "ecs" {
name_prefix = "ecs-instance-"
image_id = data.aws_ami.ecs_optimized.id
instance_type = var.instance_type # t3.xlarge
iam_instance_profile {
arn = aws_iam_instance_profile.ecs_instance.arn
}
vpc_security_group_ids = [aws_security_group.ecs_instances.id]
user_data = base64encode(<<-EOF
#!/bin/bash
echo ECS_CLUSTER=${aws_ecs_cluster.main.name} >> /etc/ecs/ecs.config
echo ECS_ENABLE_TASK_IAM_ROLE=true >> /etc/ecs/ecs.config
echo ECS_ENABLE_CONTAINER_METADATA=true >> /etc/ecs/ecs.config
EOF
)
monitoring {
enabled = true
}
tag_specifications {
resource_type = "instance"
tags = {
Name = "ecs-instance"
}
}
}
# Get latest ECS-optimized AMI
data "aws_ami" "ecs_optimized" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-ecs-hvm-*-x86_64-ebs"]
}
}
# Auto Scaling Group
resource "aws_autoscaling_group" "ecs" {
name = "ecs-asg"
vpc_zone_identifier = var.private_subnet_ids
min_size = var.min_instances
max_size = var.max_instances
desired_capacity = var.desired_instances
launch_template {
id = aws_launch_template.ecs.id
version = "$Latest"
}
health_check_type = "EC2"
health_check_grace_period = 300
tag {
key = "Name"
value = "ecs-instance"
propagate_at_launch = true
}
tag {
key = "AmazonECSManaged"
value = "true"
propagate_at_launch = true
}
}
# Capacity provider for auto-scaling
resource "aws_ecs_capacity_provider" "main" {
name = "capacity-provider"
auto_scaling_group_provider {
auto_scaling_group_arn = aws_autoscaling_group.ecs.arn
managed_termination_protection = "ENABLED"
managed_scaling {
maximum_scaling_step_size = 10
minimum_scaling_step_size = 1
status = "ENABLED"
target_capacity = 80 # Keep cluster at 80% utilization
}
}
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = [aws_ecs_capacity_provider.main.name]
default_capacity_provider_strategy {
capacity_provider = aws_ecs_capacity_provider.main.name
weight = 100
}
}
# IAM roles
resource "aws_iam_role" "ecs_instance" {
name = "ecs-instance-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ecs_instance" {
role = aws_iam_role.ecs_instance.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}
resource "aws_iam_instance_profile" "ecs_instance" {
name = "ecs-instance-profile"
role = aws_iam_role.ecs_instance.name
}
# Security group
resource "aws_security_group" "ecs_instances" {
name_prefix = "ecs-instances-"
vpc_id = var.vpc_id
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
security_groups = [var.alb_security_group_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "ecs-instances-sg"
}
}
output "cluster_name" {
value = aws_ecs_cluster.main.name
}
output "cluster_arn" {
value = aws_ecs_cluster.main.arn
}
Deploy a Service
# ecs-service.tf
resource "aws_ecs_task_definition" "app" {
family = "my-app"
network_mode = "bridge" # Use bridge mode for EC2
requires_compatibilities = ["EC2"]
cpu = "512" # Per task
memory = "1024" # Per task
container_definitions = jsonencode([{
name = "app"
image = "myapp:latest"
portMappings = [{
containerPort = 3000
hostPort = 0 # Dynamic port mapping
protocol = "tcp"
}]
environment = [
{ name = "NODE_ENV", value = "production" }
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "app"
}
}
}])
}
resource "aws_ecs_service" "app" {
name = "my-app-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 10
capacity_provider_strategy {
capacity_provider = aws_ecs_capacity_provider.main.name
weight = 100
}
load_balancer {
target_group_arn = var.target_group_arn
container_name = "app"
container_port = 3000
}
depends_on = [var.alb_listener]
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/my-app"
retention_in_days = 7
}
Add Spot Instances for Max Savings
# spot-instances.tf
resource "aws_launch_template" "ecs_spot" {
name_prefix = "ecs-spot-"
image_id = data.aws_ami.ecs_optimized.id
instance_type = "t3.xlarge"
iam_instance_profile {
arn = aws_iam_instance_profile.ecs_instance.arn
}
vpc_security_group_ids = [aws_security_group.ecs_instances.id]
# Request Spot instances
instance_market_options {
market_type = "spot"
spot_options {
max_price = "0.05" # ~70% discount vs on-demand
}
}
user_data = base64encode(<<-EOF
#!/bin/bash
echo ECS_CLUSTER=${aws_ecs_cluster.main.name} >> /etc/ecs/ecs.config
echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config
EOF
)
}
resource "aws_autoscaling_group" "ecs_spot" {
name = "ecs-spot-asg"
vpc_zone_identifier = var.private_subnet_ids
min_size = 1
max_size = 5
desired_capacity = 2
launch_template {
id = aws_launch_template.ecs_spot.id
version = "$Latest"
}
tag {
key = "Name"
value = "ecs-spot-instance"
propagate_at_launch = true
}
}
# Mix of on-demand and spot
resource "aws_ecs_capacity_provider" "spot" {
name = "spot-capacity-provider"
auto_scaling_group_provider {
auto_scaling_group_arn = aws_autoscaling_group.ecs_spot.arn
managed_scaling {
status = "ENABLED"
target_capacity = 90
}
}
}
resource "aws_ecs_cluster_capacity_providers" "mixed" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = [
aws_ecs_capacity_provider.main.name, # On-demand
aws_ecs_capacity_provider.spot.name, # Spot
]
# 70% spot, 30% on-demand for reliability
default_capacity_provider_strategy {
capacity_provider = aws_ecs_capacity_provider.spot.name
weight = 70
}
default_capacity_provider_strategy {
capacity_provider = aws_ecs_capacity_provider.main.name
weight = 30
base = 2 # Always keep 2 on-demand instances
}
}
💰 Complete Cost Breakdown
Fargate (10 tasks, 1 vCPU, 2GB each)
Per task: $36/month
10 tasks: $360/month
Annual: $4,320
ECS on EC2 – On-Demand (2 × t3.xlarge)
2 × $121/month = $242/month
Annual: $2,904
Savings: $1,416/year (33%)
ECS on EC2 – Reserved Instances (2 × t3.xlarge)
2 × $75/month = $150/month
Annual: $1,800
Savings: $2,520/year (58%)
ECS on EC2 – Spot Instances (2 × t3.xlarge)
2 × $40/month = $80/month
Annual: $960
Savings: $3,360/year (78%)
ECS on EC2 – Mixed (70% Spot, 30% RI)
1.4 × Spot ($40) + 0.6 × RI ($75) = $101/month
Annual: $1,212
Savings: $3,108/year (72%) 🎉
🎯 Migration Strategy
Step 1: Deploy ECS Cluster (No Risk)
terraform apply -target=module.ecs_cluster
# Just creates the cluster, no migration yet
Step 2: Migrate One Service
# Deploy task definition for EC2
terraform apply -target=aws_ecs_task_definition.app_ec2
# Create service with 0 tasks
terraform apply -target=aws_ecs_service.app_ec2
# Gradually shift traffic
# - Start EC2 service with 1 task
# - Test thoroughly
# - Scale up EC2, scale down Fargate
# - Complete cutover
Step 3: Monitor Cost Impact
# Watch AWS Cost Explorer
# Compare week-over-week ECS costs
# Should see 50-70% reduction
Step 4: Migrate Remaining Services
# Repeat for other services
# Keep mission-critical services on Fargate if needed
⚡ Quick Comparison Table
| Factor | Fargate | ECS on EC2 (RI) | ECS on EC2 (Spot) |
|---|---|---|---|
| 10 tasks cost | $360/mo | $150/mo | $80/mo |
| Setup complexity | Low | Medium | Medium |
| Management overhead | None | Low | Low-Medium |
| Auto-scaling | Built-in | Manual setup | Manual setup |
| Cold start | None | None | None |
| Reliability | Very High | High | Medium-High |
| Best for | <10 containers | Steady workloads | Cost-critical |
💡 Pro Tips
1. Use ECS Capacity Provider Auto-Scaling
Let AWS manage your EC2 fleet automatically:
managed_scaling {
status = "ENABLED"
target_capacity = 80 # Keep 80% utilized
}
This gives you Fargate-like hands-off experience on EC2.
2. Mix Instance Types
Use Spot for batch jobs, RI for critical services:
default_capacity_provider_strategy {
capacity_provider = "SPOT"
weight = 80
base = 0
}
default_capacity_provider_strategy {
capacity_provider = "ON_DEMAND"
weight = 20
base = 2 # Always 2 on-demand
}
3. Right-Size Your Instances
Don’t over-provision. Use CloudWatch to find optimal size:
# Check CPU utilization
aws cloudwatch get-metric-statistics
--namespace AWS/ECS
--metric-name CPUUtilization
--dimensions Name=ClusterName,Value=production-cluster
--start-time 2024-01-01T00:00:00Z
--end-time 2024-01-31T23:59:59Z
--period 86400
--statistics Average
If average CPU < 40%, downsize instances.
4. Enable Container Insights
Track per-service metrics even on EC2:
setting {
name = "containerInsights"
value = "enabled"
}
Worth the $2/month for visibility.
⚠️ When NOT to Migrate
Keep Fargate for:
- Prototyping – Speed over cost
- Bursty workloads – Scale to zero capability
- Small scale – <5 containers isn’t worth the complexity
- Compliance – Some regulations require no server management
- Ultra-critical – Fargate’s reliability is unmatched
🎓 Real-World Success Story
Startup running microservices:
Before (Fargate):
- 25 services × 2 tasks each = 50 tasks
- Average: 1 vCPU, 2GB per task
- Monthly cost: 50 × $36 = $1,800/month
After (ECS on EC2 with mixed Spot/RI):
- 4 × t3.xlarge instances (2 RI, 2 Spot)
- 2 × $75 (RI) + 2 × $40 (Spot) = $230/month
Annual savings: $18,840 💰
Migration time: 2 weeks
Team size: 2 engineers
Complexity added: Minimal (capacity provider handles scaling)
🎯 Summary
Fargate is amazing for prototypes and small workloads.
But once you’re running 10+ containers 24/7, you’re leaving money on the table.
The math is simple:
- Fargate: $360/month for 10 tasks
- ECS on EC2 (RI): $150/month
- ECS on EC2 (Spot): $80/month
Pick your savings level:
- Conservative (RI): 58% savings
- Aggressive (Spot): 78% savings
- Balanced (Mix): 72% savings
With Terraform and ECS capacity providers, you get 90% of Fargate’s convenience at 30% of the cost.
Stop overpaying for containers. Your CFO will thank you. 🚀
Migrated from Fargate to ECS on EC2? How much are you saving? Share in the comments! 💬
Follow for more AWS cost optimization with Terraform! ⚡
