Cutting ECS costs: Fargate to EC2 with immutable, zero-downtime host rollouts
Summary
Moving production ECS services from Fargate to EC2 and spot capacity cut compute cost per unit of workload by about 44%, and application teams changed nothing.
Fargate charges a premium so that nobody manages hosts. We kept most of that convenience on cheaper capacity: hosts are immutable and replaced automatically with zero downtime, spot interruptions drain gracefully, and every task keeps its own network interface and security group.
The savings are smaller than the raw price gap between Fargate and EC2, and this case study explains why.
The challenge
Production ECS services ran on Fargate, and the compute bill grew with every new service, to about $1,700 a month.
Fargate's price includes host management: no AMIs to patch, no instances to size, no capacity to plan. In Frankfurt (eu-central-1) that costs $0.04656 per vCPU-hour and $0.00511 per GB-hour. EC2, and spot in particular, is far cheaper for the same compute, but only if you take back the work Fargate was doing.
So the real question was how much of that work we could automate away, so the savings would not be spent on operations.
Constraints
- Fargate parity. Application teams should notice nothing: the same networking model, the same deployment flow, no new runbooks.
- No snowflakes. Everything lives in Terraform on the existing terraform-aws-modules stack (VPC, ALB, ECS, Auto Scaling). No host is ever configured by hand.
- Build up incrementally. We started from a minimal VPC, ALB, EC2-backed cluster and an nginx service, then added production features one at a time, validating each before moving on.
Immutable hosts: replace, never patch
On Fargate, AWS patches the hosts. On EC2 that job became ours, so we turned it into a replacement rather than a maintenance task: no host is ever patched in place.
A new ECS-optimized AMI means a new launch template version, which triggers an Auto Scaling instance refresh that replaces every host. Containers move without downtime because each host drains before it terminates:
- Terraform publishes a launch template version with the new AMI, and the instance refresh starts.
- Auto Scaling picks old hosts to replace, in batches bounded by its minimum healthy percentage.
- Each picked host goes through ECS managed draining. ECS reschedules its tasks elsewhere within each service's minimum healthy percent, and the ALB deregisters the old targets.
- The host terminates only once it runs no tasks, and new hosts on the new AMI take its place.
The trap: scale-in protection. Managed termination protection marks every host running tasks as protected, which stops scale-in from killing tasks. Instance refresh has a dedicated preference for protected instances, and each wrong setting fails differently. By default, the refresh skips protected hosts and can finish with old AMIs still serving. Wait stalls, because ECS lifts protection only from an empty host. Replacing protected hosts without managed draining terminates them with tasks still running.
We set ScaleInProtectedInstances = Refresh and rely on managed draining: protected hosts are still replaced, but only after ECS has moved their tasks off.
This loop is what replaces the patching Fargate used to do for us, and it is the heart of Fargate parity.
On-demand base plus spot
The cluster runs one stable on-demand host and puts everything else on spot. There were two ways to build that, and only one guarantees where critical tasks land.
| Option A: one ASG, mixed instances policy | Option B: two capacity providers | |
|---|---|---|
| Structure | One Auto Scaling group mixing on-demand and spot | One on-demand group and one spot group, each with its own capacity provider |
| On-demand base counts | Instances | Tasks (base in the strategy) |
| ECS can tell on-demand from spot | No | Yes |
| Critical tasks guaranteed on on-demand | No | Yes |
We chose Option B. With one mixed group, ECS sees a single pool of hosts, so a critical task can land on spot. With two capacity providers, each service's capacity provider strategy places its first base tasks on on-demand and splits the rest by weight.
The detail that trips people up: base counts tasks, not instances. A base of 2 means two tasks on on-demand capacity, which may share one host.
Each service's strategy fills on-demand up to its base, then sends the remaining tasks to spot.
Keeping the Fargate feel
Networking. Every service stays in awsvpc mode, so each task gets its own ENI and security group, exactly as on Fargate. The cost on EC2 is density: each task uses an ENI, and each instance type caps how many it can attach. ENI trunking raises that cap, but only on supported instance types, which narrows the spot pool. We run xlarge hosts (4 vCPU, 16 GB) with trunking enabled, and each carries 6 to 8 tasks, so CPU, not ENIs, sets the limit.
Spot interruptions. Managed draining on the capacity providers, plus ECS_ENABLE_SPOT_INSTANCE_DRAINING=true in the agent config, turns a two-minute spot interruption notice into a graceful drain. ECS reschedules the tasks and the ALB deregisters them before the host disappears.
Headroom. Managed scaling's target_capacity below 100% keeps spare room on the cluster, so new tasks start at once instead of waiting in PROVISIONING for a host. We set it to 90%, trading idle capacity for scale-out latency.
Spot resilience. The spot group spreads across four instance types (m5.xlarge, m5d.xlarge, m6i.xlarge, m6id.xlarge) and three Availability Zones, so reclamation in one pool never takes a whole service down. It uses the price-capacity-optimized allocation strategy, and capacity rebalancing replaces at-risk hosts before they are reclaimed.
Measuring scale-out
A new spot host takes about 170 seconds, just under three minutes, from task request to a healthy task behind the ALB. "How slow is a cold host?" was the biggest worry about leaving Fargate, so we measured it rather than argue about it.
The benchmark was a small Next.js app with server-side rendering, scaled until ECS had to request a new spot host.
| Phase | Time (s) |
|---|---|
| Task requested to host launched | ~75 |
| Host launched to registered with the cluster | ~50 |
| Registered to task RUNNING | ~25 |
| RUNNING to healthy in the ALB target group | ~20 |
| Total | ~170 |
That number set the headroom: the slower a cold host, the more spare capacity target_capacity has to hold.
Results
Per unit of workload, compute cost fell from $0.05678 to about $0.032 per task-hour, about 44% lower: roughly $1,720 a month on Fargate against $970 on EC2 and spot. The baseline is a 1 vCPU, 2 GB task at Frankfurt Fargate rates ($0.04656 + 2 × $0.00511).
| Metric | Fargate | EC2 + spot |
|---|---|---|
| Monthly compute cost, same workload | ~$1,720 | ~$970 |
| Cost per task-hour, 1 vCPU / 2 GB | $0.05678 | ~$0.032 |
| Cluster CPU / memory reservation | Billed per task | ~74% / ~46% |
| Full AMI rollout, 14 hosts | Managed by AWS | ~35 min, 0 failed ALB requests |
| Spot interruptions per month | None | 1 to 3, none visible to users |
To keep the comparison honest, the EC2 figure is the whole cluster's cost, EBS included, divided by the vCPU-hours and GB-hours that tasks actually reserved. Idle headroom, bin-packing waste and host overhead are all inside that number.
What we gave up
The savings are real but smaller than the raw price gap, for four reasons:
- Bin-packing waste. We now pay for host capacity no task is using, which Fargate never charged for.
- Host overhead. The ECS agent and any daemons take a slice of every instance.
- Ownership. AMI cadence and the replacement pipeline are now our job, not AWS's.
- Interruptions. Spot reclamation is a normal event, and every service has to tolerate it.
We would rather state these up front than let a headline percentage imply a free lunch.
What we'd do differently
- Graviton instances, for a lower price per vCPU on both on-demand and spot.
- A Compute Savings Plan covering the on-demand base, which is stable enough to commit to.
- An automated AMI pipeline that starts an instance refresh as soon as AWS publishes a new ECS-optimized AMI, so patch cadence no longer depends on someone noticing.
