Fargate is Costing You 3x More: Switch to ECS on EC2 and Save Thousands with Terraform 💰

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! ⚡

Leave a Reply