This website uses cookies

Read our Privacy policy and Terms of use for more information.

Amazon Elastic Container Service is the AWS answer to a narrow question: you have a container image, you want copies of it running, staying up, and getting replaced safely when you push a new version. That is the whole job. ECS is a control plane that watches a desired state you declare and does the work to make reality match it. It does not run your containers on hardware it invented. It runs them on EC2 instances you own, or on Fargate, which is EC2 you do not see. What ECS adds on top is scheduling, health tracking, load balancer wiring, and the deploy logic that swaps old tasks for new ones without dropping traffic.

If you already read the EC2 encyclopedia entry (SL#101), think of ECS as the first real customer of everything EC2 exposes. And if you read the Lambda entry (SL#104), ECS is the other side of the compute coin: Lambda hides the whole machine and charges per invocation, ECS keeps the long-running process model and charges for the time it is up. Most teams end up running both.

What it actually is

ECS is an orchestrator built around four nouns, and the confusion people carry into their first ECS project is almost always a fuzzy line between them.

A cluster is a namespace. That is genuinely all it is: a logical boundary you group things under. Your account starts with a default cluster and you can make more. A cluster is not a set of machines you paid for, and creating one costs nothing. People coming from Kubernetes expect a cluster to be a fleet of nodes with a bill attached. In ECS, the cluster is just the folder.

A task definition is the blueprint. It is a JSON document that says which container images to run, how much CPU and memory each needs, what ports they expose, which IAM role they assume, where logs go, and how the network is wired. It is versioned: every time you register one you get a new revision, my-app:1, my-app:2, and old revisions stick around forever so you can roll back to an exact past configuration. The task definition is immutable data. It runs nothing by itself.

A task is a running instance of a task definition. One task is one or more containers scheduled together on the same host, sharing a network namespace. If your task definition asks for two containers, an app and a log shipper, they land together and can talk over localhost. A task is the unit ECS actually schedules and the unit you pay for on Fargate.

A service is the supervisor. You hand it a task definition and a desired count, "keep three of these running," and the ECS service scheduler makes it so. If a task dies, the service starts another. If you register a new task definition revision and update the service, the service performs a deployment, replacing old tasks with new ones under a policy you control. A service also owns the integration with an Application Load Balancer, registering and draining targets as tasks come and go. The service is where the orchestration lives.

Cluster holds services. Services manage tasks. Tasks are instances of task definitions. Keep those four straight and ECS stops feeling arbitrary.

The model

ECS is a declarative control loop. You declare desired state, the service scheduler observes actual state, and it issues the API calls to close the gap. This is the same shape as a Kubernetes controller, minus the nodes, the etcd, and the YAML dialect you have to learn.

The most important architectural fork is the launch type, which answers "whose machine runs the task." With the EC2 launch type, you register EC2 instances into the cluster by running the ECS agent on them, and ECS bin-packs tasks onto that capacity. You own the instances, the patching, the scaling of the fleet, and the idle capacity when tasks do not fill a box. With Fargate, there is no fleet. You ask for a task with 1 vCPU and 2 GB, and AWS conjures a right-sized, isolated microVM, runs your task, and bills you for the seconds it lived. There is also an External launch type (ECS Anywhere) for running the agent on your own on-prem hardware, and as of 2026 an ECS Managed Instances option where AWS provisions and operates the EC2 capacity for you, a middle ground between raw EC2 and Fargate.

Networking for Fargate is always awsvpc mode: every task gets its own elastic network interface with its own private IP inside your VPC, as if it were a tiny EC2 instance. This is clean (per-task security groups, no port conflicts) and it has a cost: ENIs are a finite resource per subnet, and a task in a private subnet needs a NAT gateway or VPC endpoints to pull its image and reach other services.

The deploy model is the part that changed. Every service has a deployment controller, and for years the practical choices were the built-in rolling update or handing blue/green off to AWS CodeDeploy with its own config language. In July 2025 AWS shipped built-in blue/green deployments directly in the ECS service, with bake time, Lambda-backed lifecycle hooks, and near-instant rollback, no CodeDeploy required. In October 2025 they added linear and canary variants on top of it. So the current menu of deploymentController types is ECS (which now covers both rolling update and native blue/green), CODE_DEPLOY (the older external blue/green), and EXTERNAL (bring your own). For a new service in 2026, you rarely need CodeDeploy anymore.

When to use it, when not to

ECS is the right tool when you have containerized, long-running services and you want orchestration without operating a control plane. It is the wrong tool when your workload is event-shaped, or when you have committed to the Kubernetes ecosystem and its portability.

Here is the honest comparison against the three services people weigh it against:

Question

Amazon ECS

Amazon EKS

AWS Lambda

AWS App Runner

What you operate

Task defs and services

Cluster add-ons, upgrades, YAML

Just the function code

Almost nothing

Control plane cost

Free

~$73/mo per cluster

None

None

Best for

Long-running containers, AWS-native shops

Teams standardized on k8s / needing portability

Bursty, event-driven, sub-15-min work

A single web service, git-push simple

Learning curve

Low

High

Low

Lowest

Escape hatch

Move to EKS later

Already portable

Repackage as a container

Graduate to ECS

Pick ECS over EKS when you do not need Kubernetes portability and do not want to pay the operational and per-cluster tax for it. Pick ECS over Lambda when your process is long-lived, needs more than 15 minutes, holds connections, or wants a full container rather than a function. Pick ECS over App Runner when you have outgrown "one service, one command" and need multiple services, fine-grained networking, and real deploy control. App Runner, worth noting, runs on Fargate under the hood; ECS is what it grows into.

What it costs

For the EC2 launch type, ECS itself is free. There is no orchestration fee. You pay for the EC2 instances, the EBS volumes, and the public IPv4 addresses you provision, exactly as if you ran the containers by hand. The cost you cannot see on the invoice is idle capacity: an instance that is 40% packed is 60% wasted, and that waste is yours to reclaim.

For Fargate, you pay per task for vCPU and memory, per second, from the moment image download starts until the task terminates, with a one-minute minimum. In US East (N. Virginia), Linux on x86 runs about $0.04048 per vCPU-hour and $0.004446 per GB-hour. A task with 0.25 vCPU and 0.5 GB, the smallest size, costs roughly $0.0123 per hour to run, a bit over $9 a month if it runs continuously. Each task gets 20 GB of ephemeral storage free; beyond that you pay per GB-second. Two levers cut this meaningfully: Fargate Spot takes up to 70% off for interruption-tolerant tasks, and ARM/Graviton pricing runs noticeably cheaper than x86 for the same shape. Compute Savings Plans knock up to 50% off steady Fargate usage.

The dimension that surprises people is not compute. It is the surrounding tax. Every Fargate task with a public IP now incurs the public IPv4 charge (about $0.005 per hour per address). Tasks in private subnets route through a NAT gateway, which bills per hour and per GB processed and quietly becomes a top-three line item on container bills. And CloudWatch Logs ingestion from chatty containers can outrun the compute cost of the containers producing them. Model the network and the logs, not just the vCPU.

Task sizes now run from 0.25 vCPU all the way to 32 vCPU with 244 GiB of memory (added June 2026), so Fargate is no longer just for small web tiers; large data-processing and inference tasks fit too.

The limits that bite

The first limit is a deploy trap, not a quota. A rolling update is governed by two numbers on the service: minimumHealthyPercent (how few tasks may be running mid-deploy, as a percent of desired) and maximumPercent (how many may run at once). The defaults are 100 and 200, meaning ECS spins up new tasks before killing old ones and never dips below full capacity. That is safe but it requires headroom for a full extra copy of the service. On EC2 capacity that headroom has to physically exist or the deploy stalls. And if you tune these values into a corner, say minimum 100% and maximum 100%, the scheduler can neither start nor stop a task and the deployment jams. AWS emits a specific service event for exactly this, but it is an easy self-inflicted wound.

The second is the silent-failure problem, which ECS now has an answer for. A rolling deploy of a broken image will happily replace healthy tasks with crashing ones unless you turn on protection. The deployment circuit breaker watches for tasks that cannot reach a steady state and fails the deployment (and optionally rolls it back) when too many launches fail. Pair it with a CloudWatch alarm on your error rate or latency and you also catch the deploys that come up "healthy" but are actually serving 500s. Neither is on by default on older services. Turn them on.

The third bites at scale. When a service uses Service Connect or service discovery, it registers into AWS Cloud Map, and Cloud Map caps you at 1,000 instances per service, which becomes a hard ceiling on tasks per service regardless of the higher ECS quota. And because Fargate uses awsvpc, every task consumes an ENI and an IP in its subnet; size your subnets for peak task count plus the extra copies a deploy spins up, or you will hit "insufficient IP addresses" mid-rollout.

Build it

We will ship a containerized web app to ECS on Fargate, watch a rolling deploy replace it, and clean up. Everything is AWS CLI so it is copy-pasteable and diff-able.

Prerequisites: the latest AWS CLI configured with credentials, a VPC with at least one subnet, and a security group allowing inbound TCP 80. The default VPC that ships with every account works. For IAM, your caller needs ECS permissions (the managed AmazonECS_FullAccess policy covers the tutorial); ECS auto-creates its service-linked role on first use. Estimated cost to follow along: under $0.10, mostly the public IPv4 and a few task-hours if you leave it running. Delete it in the last step and the bill rounds to nothing.

First, create a cluster. It is just a namespace, so this is instant and free.

aws ecs create-cluster --cluster-name sl-ecs-demo

Now register a task definition. This is the blueprint: one container running the public httpd image, 0.25 vCPU and 0.5 GB, awsvpc networking (required for Fargate), port 80 exposed. Save this as task-def.json.

{
  "family": "sl-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "public.ecr.aws/docker/library/httpd:2.4",
      "essential": true,
      "portMappings": [{ "containerPort": 80, "protocol": "tcp" }]
    }
  ]
}

Register it. The response echoes the family and a revision number; note that sl-web:1 is now a permanent, immutable artifact you can always roll back to.

aws ecs register-task-definition --cli-input-json file://task-def.json

Create a service that keeps two copies running on Fargate. Substitute your subnet and security group IDs. assignPublicIp=ENABLED lets the task pull its image and be reachable without a NAT gateway. Turning on the deployment circuit breaker here is the single most valuable flag in this whole build.

aws ecs create-service \
  --cluster sl-ecs-demo \
  --service-name sl-web-svc \
  --task-definition sl-web:1 \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-xxxx],securityGroups=[sg-xxxx],assignPublicIp=ENABLED}" \
  --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true},minimumHealthyPercent=100,maximumPercent=200"

Give it a minute, then trigger a rolling deploy. Register a second revision (change the image tag to 2.4-alpine so it is a real, pullable difference), then point the service at it.

# after editing image to public.ecr.aws/docker/library/httpd:2.4-alpine in task-def.json
aws ecs register-task-definition --cli-input-json file://task-def.json
aws ecs update-service --cluster sl-ecs-demo --service sl-web-svc --task-definition sl-web:2

With minimumHealthyPercent=100 and maximumPercent=200, ECS starts new tasks on revision 2, waits for them to pass health checks, shifts to them, and only then drains the revision-1 tasks. At no point does capacity drop below the desired two. That is the rolling update in one sentence.

When it breaks

Tasks failed to start with the task immediately going to STOPPED almost always means an image pull failure or a networking dead end. Run aws ecs describe-tasks --cluster sl-ecs-demo --tasks <task-arn> and read stoppedReason. CannotPullContainerError in a private subnet means no route to the registry: add a NAT gateway or VPC endpoints, or move to a public subnet with assignPublicIp=ENABLED, which is what the build above does deliberately.

The deployment hangs, stuck at "in progress" forever. This is the minimumHealthyPercent/maximumPercent trap. If you set both to 100, the scheduler cannot stop an old task or start a new one, and it says so in the service events: run aws ecs describe-services --cluster sl-ecs-demo --services sl-web-svc and read the events array. Fix the deployment configuration and the deploy unsticks.

The task starts but the load balancer never sends traffic. The target group health check is failing. The usual cause is a security group that does not allow the ALB to reach the task port, or a health check path that returns something other than 200. Check the target group health in the EC2 console; the reason string there is more specific than anything ECS shows.

ResourceInitializationError: unable to pull secrets or registry auth means the task execution role is missing. Fargate needs an execution role to pull from private ECR and write logs; the public image in this tutorial dodges that, but any real workload needs --execution-role-arn pointing at a role with AmazonECSTaskExecutionRolePolicy.

If new tasks crash-loop, the circuit breaker you enabled earns its keep: after enough failed launches it fails the deployment and rolls back to revision 1 automatically, so production keeps serving the last good version instead of your broken one.

Cleanup

Delete the service (the --force flag scales it to zero and removes it in one step), then the cluster. Task definitions cannot be deleted, only deregistered, and they cost nothing to keep, so leave them.

aws ecs delete-service --cluster sl-ecs-demo --service sl-web-svc --force
aws ecs delete-cluster --cluster sl-ecs-demo

Confirm nothing is still running with aws ecs list-tasks --cluster sl-ecs-demo (it should error that the cluster is gone) and glance at the ECS console to be sure no stray tasks are holding a public IP. Once the tasks are gone, the meter stops; Fargate bills only for running task-seconds, so a deleted service is a zero bill.

The mental model to keep: a cluster is a folder, a task definition is immutable data, a task is one running copy, and a service is the loop that keeps the right number of copies alive and swaps them safely. The deploy safety that used to require CodeDeploy now lives inside the service itself. Everything else in ECS is detail hanging off those four nouns.

Sources

Keep Reading