Amazon ECS is AWS’s own container orchestrator. With Fargate as the launch type you do not manage servers at all: you describe CPU, memory and a container image, and AWS runs it. It is the simplest way to run a web service or worker on AWS when you do not need the Kubernetes ecosystem.
Versions in use on this date. Fargate platform version 1.4.0 (the default for Linux tasks), which supports ECS Exec, up to 200 GiB of ephemeral storage and EFS volumes, plus ARM64 (Graviton) tasks and Fargate Spot. The deployment circuit breaker has been available since late 2020.
Architecture
flowchart TB
DEV[CI pipeline] -->|docker push| ECR[(Amazon ECR repository)]
DEV -->|register task definition and update service| ECS
U[Users] -->|HTTPS 443| ALB[Application Load Balancer<br/>public subnets]
ALB -->|HTTP 8080 health check /healthz| TG[Target group type ip]
subgraph PRIV[Private subnets in two AZs]
TG --> T1[Fargate task 1<br/>awsvpc ENI, app-sg]
TG --> T2[Fargate task 2]
end
ECS[ECS service desired count 2<br/>circuit breaker, autoscaling] --> T1
ECS --> T2
T1 -->|execution role| ECR
T1 -->|awslogs| CWL[CloudWatch Logs]
T1 -->|task role| DDB[(DynamoDB, S3, Secrets Manager)]
1. Push the image to ECR
aws ecr create-repository --repository-name app --image-scanning-configuration scanOnPush=true \
--image-tag-mutability IMMUTABLE --region ap-south-1
aws ecr get-login-password --region ap-south-1 | docker login --username AWS \
--password-stdin 111122223333.dkr.ecr.ap-south-1.amazonaws.com
docker build -t app:1.0 .
docker tag app:1.0 111122223333.dkr.ecr.ap-south-1.amazonaws.com/app:1.0
docker push 111122223333.dkr.ecr.ap-south-1.amazonaws.com/app:1.0
Immutable tags stop someone overwriting 1.0 after it was tested. Build for the architecture you will run (--platform linux/arm64 for Graviton).
2. Task definition
{
"family": "app",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "512",
"memory": "1024",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::111122223333:role/appTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::111122223333:role/appTaskRole",
"containerDefinitions": [{
"name": "app",
"image": "111122223333.dkr.ecr.ap-south-1.amazonaws.com/app:1.0",
"essential": true,
"portMappings": [{ "containerPort": 8080 }],
"readonlyRootFilesystem": true,
"environment": [{ "name": "APP_ENV", "value": "prod" }],
"secrets": [{ "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:ap-south-1:111122223333:secret:prod/shop/db-AbCdEf:password::" }],
"healthCheck": { "command": ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"], "interval": 30, "timeout": 5, "retries": 3, "startPeriod": 20 },
"logConfiguration": {
"logDriver": "awslogs",
"options": { "awslogs-group": "/ecs/app", "awslogs-region": "ap-south-1", "awslogs-stream-prefix": "app" }
}
}]
}
aws logs create-log-group --log-group-name /ecs/app && aws logs put-retention-policy --log-group-name /ecs/app --retention-in-days 30
aws ecs register-task-definition --cli-input-json file://taskdef.json
Valid Fargate CPU and memory pairs are fixed combinations (for example 0.5 vCPU with 1 to 4 GB); see the documentation table when you change them.
3. The two roles
| Role | Used by | Typical permissions |
|---|---|---|
| Task execution role | ECS agent, before your code starts | Pull the image from ECR, write logs, read the secrets referenced in secrets (managed policy AmazonECSTaskExecutionRolePolicy plus the specific secret) |
| Task role | Your application code | Only what the app calls: one DynamoDB table, one S3 prefix, and so on |
Mixing them up is the most common first-day failure: CannotPullContainerError or missing logs point to the execution role; AccessDenied from inside the app points to the task role.
4. Service, load balancer and rollback safety
aws ecs create-cluster --cluster-name prod
aws ecs create-service --cluster prod --service-name app --task-definition app \
--desired-count 2 --launch-type FARGATE --platform-version LATEST \
--network-configuration "awsvpcConfiguration={subnets=[subnet-0a1,subnet-0b2],securityGroups=[sg-0app],assignPublicIp=DISABLED}" \
--load-balancers targetGroupArn=arn:aws:elasticloadbalancing:ap-south-1:111122223333:targetgroup/app/abc123,containerName=app,containerPort=8080 \
--deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true},minimumHealthyPercent=100,maximumPercent=200" \
--enable-execute-command --health-check-grace-period-seconds 30
- The target group is of type
ip, because each task has its own network interface. The ALB security group may reach the task security group on 8080 only. - With
minimumHealthyPercent=100andmaximumPercent=200, new tasks start before old ones stop, so a deployment does not reduce capacity. - The deployment circuit breaker marks a deployment failed if new tasks keep failing to start or pass health checks, and rolls back to the last good task definition.
- Private subnets need a route to ECR and CloudWatch Logs: a NAT gateway, or VPC endpoints for
ecr.api,ecr.dkr,logsand an S3 gateway endpoint (cheaper and more private).
Autoscaling
aws application-autoscaling register-scalable-target --service-namespace ecs \
--resource-id service/prod/app --scalable-dimension ecs:service:DesiredCount --min-capacity 2 --max-capacity 10
aws application-autoscaling put-scaling-policy --service-namespace ecs --resource-id service/prod/app \
--scalable-dimension ecs:service:DesiredCount --policy-name cpu60 --policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{"TargetValue":60.0,"PredefinedMetricSpecification":{"PredefinedMetricType":"ECSServiceAverageCPUUtilization"},"ScaleInCooldown":120,"ScaleOutCooldown":60}'
For request-driven services, ALBRequestCountPerTarget is often a better signal than CPU. Use Fargate Spot capacity providers for fault-tolerant workers at a discount, never for the only copy of a critical service.
Debugging
aws ecs describe-services --cluster prod --services app --query 'services[0].events[:5]'
aws ecs list-tasks --cluster prod --service-name app --desired-status STOPPED
aws ecs describe-tasks --cluster prod --tasks <task-arn> --query 'tasks[0].{stop:stoppedReason,containers:containers[].reason}'
aws ecs execute-command --cluster prod --task <task-arn> --container app --interactive --command "/bin/sh"
| Symptom | Likely cause |
|---|---|
| Task stops at once, exit code 1 or 137 | App crash on start, or out of memory (raise memory) |
CannotPullContainerError |
Execution role, wrong tag, or no route to ECR |
Targets unhealthy in the ALB |
Wrong health check path or port, or security group blocks the ALB |
| Deployment stuck, then rolls back | Failed health checks; read the stopped task’s reason and logs |
Compare with EKS
ECS has a smaller surface: no control plane to upgrade, no cluster add-ons, simple IAM integration. EKS gives the Kubernetes API, portability, Helm charts and a huge ecosystem, at the cost of more operations. Choose ECS for a handful of services on AWS; choose EKS when you need Kubernetes tooling or multi-cloud consistency.