Deploying on AWS ECS, and what you gain over plain EC2
I run several production workloads on ECS Fargate today: a public services platform, a production SaaS, internal tooling. Before that, like most people, I deployed the classic way: an EC2 instance, Docker or PM2 on top, an nginx in front, and a deploy script over SSH. This article is the comparison I wish I had read at the time: what ECS actually changes, how a real deployment looks, and the cases where a plain EC2 box is still the right call.
The EC2 baseline: what you really sign up for
Running your app on an EC2 instance looks simple on day one. It stops being simple the day you own it in production, because the instance is yours to operate:
- OS lifecycle: security patches, kernel updates, reboots, upgrades of Docker itself.
- Capacity: one instance is a single point of failure; two instances mean you now manage a load balancer, health checks, and some way to keep deployments in sync.
- Deploys: SSH scripts or a CI runner that connects to the box. Rollbacks are whatever you scripted them to be.
- Scaling: vertical first (stop, resize, start: downtime), then horizontal with an Auto Scaling Group, launch templates, and AMI baking.
- Isolation: every process on the box shares the same instance profile, so every app inherits the union of all permissions.
None of this is hard individually. The point is that it is your undifferentiated work, forever, for every box.
What ECS is, in four objects
ECS has a small mental model. Four objects cover almost everything:
- Cluster: a logical namespace for your services. With Fargate there are no machines in it to manage.
- Task definition: a versioned JSON document describing your container(s): image, CPU/memory, environment, secrets, log configuration. Think of it as the unit of "what to run".
- Task: a running copy of a task definition. Ephemeral by design.
- Service: the controller that keeps N tasks alive, registers them in a load balancer target group, replaces the ones that fail health checks, and orchestrates rolling deployments.
The launch type decides who owns the machines. With the EC2 launch type you still manage instances (you gain orchestration, not ops freedom). With Fargate, AWS runs the task on infrastructure you never see: no AMI, no patching, no SSH. Everything below assumes Fargate, because in my experience that is where the trade actually pays off.
A real deployment, end to end
Three steps: push the image, describe the task, create the service.
1. Build and push to ECR
# once: create the repository
aws ecr create-repository --repository-name myapp
# each deploy: build, tag, push
aws ecr get-login-password | docker login --username AWS \
--password-stdin 123456789012.dkr.ecr.eu-west-3.amazonaws.com
docker build -t myapp:1.4.2 .
docker tag myapp:1.4.2 123456789012.dkr.ecr.eu-west-3.amazonaws.com/myapp:1.4.2
docker push 123456789012.dkr.ecr.eu-west-3.amazonaws.com/myapp:1.4.2
2. The task definition
{
"family": "myapp",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "512", "memory": "1024",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::123456789012:role/myapp-task-role",
"containerDefinitions": [{
"name": "myapp",
"image": "123456789012.dkr.ecr.eu-west-3.amazonaws.com/myapp:1.4.2",
"portMappings": [{"containerPort": 8080}],
"secrets": [{"name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:...:secret:myapp/db"}],
"logConfiguration": {"logDriver": "awslogs", "options": {
"awslogs-group": "/ecs/myapp", "awslogs-region": "eu-west-3",
"awslogs-stream-prefix": "app"}}
}]
}
Two details in there do a lot of quiet work. taskRoleArn gives this app its own IAM permissions, not the whole machine. And secrets.valueFrom injects credentials from Secrets Manager at start time, so nothing sensitive lives in the definition itself.
3. The service, behind a load balancer
aws ecs create-service --cluster prod-cluster \
--service-name myapp-service \
--task-definition myapp \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-a,subnet-b],
securityGroups=[sg-myapp],assignPublicIp=DISABLED}" \
--load-balancers "targetGroupArn=arn:...:targetgroup/myapp/abc,
containerName=myapp,containerPort=8080" \
--deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}"
From then on, a deploy is one line in CI: register a new task definition revision with the new image tag, then aws ecs update-service --force-new-deployment. ECS starts the new tasks, waits for the ALB health checks to pass, shifts traffic, and drains the old tasks. If the new version never becomes healthy, the circuit breaker rolls back on its own. Nobody SSHes anywhere.
What you actually gain
| Concern | Plain EC2 | ECS Fargate |
|---|---|---|
| OS patching / AMIs | Yours, forever | Gone |
| Deploy & rollback | Custom scripts | Rolling update + automatic rollback, built in |
| Self-healing | Systemd / hope | Service replaces unhealthy tasks |
| Scaling | ASG + AMI baking | One number (or auto scaling on CPU/RPS) |
| Per-app IAM | Shared instance profile | IAM role per task |
| Secrets | .env files on disk | Injected from Secrets Manager / SSM |
| Logs | Files, logrotate | CloudWatch out of the box |
| Blast radius | The whole box | One task |
The one I underestimated most is self-healing plus circuit breaker. A bad release at 2am stops itself and rolls back before the monitoring even finishes waking you up. On an EC2 box, that same night ends with you inside an SSH session.
Where plain EC2 still wins
Honesty section. I still run and recommend EC2 for some workloads:
- Steady, high, predictable load: Fargate's per-vCPU-hour premium is real. A box that runs hot 24/7 is cheaper as a reserved EC2 instance, especially with the EC2 launch type of ECS on top if you still want orchestration.
- Stateful or specialized software: databases, anything needing a custom kernel, GPUs, or huge local disks.
- Long-lived daemons with node affinity: some pipeline tools (NiFi is a good example) assume a stable host and local state; forcing them into ephemeral tasks fights the tool.
- Very small projects: a single 5-dollar box with Docker Compose is legitimate. ECS earns its keep when uptime, rollbacks and team handoffs start to matter.
Pitfalls I hit so you don't have to
- Task stuck in PENDING: nine times out of ten it is networking: private subnets with no NAT (the task cannot pull the image from ECR), or a security group blocking egress. Add VPC endpoints for ECR/S3/CloudWatch if you run fully private.
- OOM-killed containers: memory in the task definition is a hard limit. Watch
MemoryUtilizationper service, not per cluster averages. - "latest" tags: pin image tags per release.
latestplusforce-new-deploymentmakes rollbacks a guessing game. - Crash loops cost money: a service that keeps replacing failing tasks bills you for every start. Alert on task restart counts; I run a small Lambda watchdog that stops a service after repeated failures in a window.
Closing
ECS is not the fancy choice (that reputation belongs to Kubernetes) and that is exactly why I like it for straightforward web workloads on AWS: the entire orchestration surface fits in four concepts, deploys are safe by default, and the platform work you delete is precisely the work that pages you at night. Start with Fargate, keep the task definitions in your IaC, and reach for EC2 only when the numbers or the workload demand it.
Questions, or want a second pair of eyes on your setup? Work with me.