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! โก
Top comments (0)