Introduction
Migrating containerized workloads in production is one of those tasks that sounds straightforward on paper but can quickly unravel into dropped connections, failed health checks, and 3 AM alerts. When the goal is zero downtime, the margin for error shrinks to nearly nothing.
In this guide, we'll walk through a battle-tested approach for migrating legacy Docker containers to AWS Elastic Container Service (ECS) without dropping a single client request. Whether you're moving from a bare-metal Docker host, an older ECS configuration, or another container orchestrator, the principles here apply.
Why Zero-Downtime Migrations Are Hard
Before diving into the how, it's worth understanding the why — specifically, why naïve approaches fail:
- Abrupt container stops terminate in-flight requests
- DNS propagation delays cause clients to hit dead endpoints
- Health check misconfigurations drain traffic before new containers are ready
- Database schema mismatches between old and new container versions
- State stored in-container (logs, sessions, uploads) that doesn't survive a restart
ECS with Application Load Balancer (ALB) solves most of these problems — but only if configured correctly.
Prerequisites
Before starting, make sure you have:
- An existing Docker application with a working
Dockerfile - AWS CLI configured with appropriate IAM permissions
- An Application Load Balancer (ALB) set up in your VPC
- Amazon ECR repository to host your container images
- Basic familiarity with ECS task definitions and services
Step 1 — Containerize and Push to ECR
If your application already runs in Docker, this step may be mostly done. The key is ensuring your image is production-hardened:
# Use a specific version tag — never 'latest' in production
FROM node:20.11-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Run as non-root
USER node
EXPOSE 3000
CMD ["node", "server.js"]Tag and push to ECR:
# Authenticate
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com
# Build, tag, push
docker build -t my-app .
docker tag my-app:latest <account-id>.dkr.ecr.us-east-1.amazonaws.com/my-app:v1.2.0
docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/my-app:v1.2.0Always use immutable image tags (e.g.,v1.2.0or a Git SHA) — neverlatest. This makes rollbacks deterministic.
Step 2 — Define Your ECS Task Definition
The task definition is the blueprint for your container. Pay close attention to the health check and stop timeout settings:
{
"family": "my-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"containerDefinitions": [
{
"name": "my-app",
"image": "<account-id>.dkr.ecr.us-east-1.amazonaws.com/my-app:v1.2.0",
"portMappings": [{ "containerPort": 3000 }],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
"interval": 10,
"timeout": 5,
"retries": 3,
"startPeriod": 30
},
"stopTimeout": 60,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}Key settings to highlight:
| Setting | Value | Why It Matters |
|---|---|---|
startPeriod | 30s | Gives app time to boot before health checks begin |
stopTimeout | 60s | Allows in-flight requests to complete before shutdown |
interval | 10s | Catches failures quickly without overwhelming the app |
Step 3 — Configure ALB Target Group with Connection Draining
The ALB target group is where zero-downtime magic actually happens. Deregistration delay (connection draining) tells the load balancer to wait before fully removing an instance, giving it time to finish serving active requests.
aws elbv2 modify-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:... \
--attributes Key=deregistration_delay.timeout_seconds,Value=60Also ensure your ALB health check path matches your app's actual health endpoint:
aws elbv2 modify-target-group \
--target-group-arn arn:aws:elasticloadbalancing:... \
--health-check-path /health \
--health-check-interval-seconds 10 \
--healthy-threshold-count 2Step 4 — Deploy With a Rolling Update Strategy
ECS supports rolling deployments natively. The key parameters are minimumHealthyPercent and maximumPercent:
aws ecs update-service \
--cluster my-cluster \
--service my-app-service \
--task-definition my-app:2 \
--deployment-configuration \
minimumHealthyPercent=100,maximumPercent=200minimumHealthyPercent=100— ECS will never go below 100% capacity, meaning new tasks must become healthy before old ones are stoppedmaximumPercent=200— ECS can spin up to 2× the desired count during the rollout, ensuring new tasks start before old ones drain
This guarantees at least one healthy instance is always serving traffic.
Step 5 — Handle Graceful Shutdown in Your Application
ECS sends a SIGTERM signal before stopping a container. Your application must handle this signal and finish processing in-flight requests before exiting.
Here's an example in Node.js:
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received. Closing HTTP server...');
server.close(() => {
console.log('All connections closed. Exiting.');
process.exit(0);
});
// Force exit after stopTimeout if connections linger
setTimeout(() => process.exit(1), 55000);
});Set your forced exit timeout slightly under the stopTimeout value in your task definition to avoid being killed mid-cleanup.Step 6 — Validate the Migration
Before treating the migration as complete, verify the following:
# Watch ECS service events in real time
watch -n 5 'aws ecs describe-services \
--cluster my-cluster \
--services my-app-service \
--query "services[0].events[:5]" \
--output table'Step 7 — Plan for Rollback
Even with careful preparation, you need a fast rollback path. With immutable image tags, rolling back is a single command:
aws ecs update-service \
--cluster my-cluster \
--service my-app-service \
--task-definition my-app:1ECS will perform the same rolling deployment in reverse. Since you used a pinned image tag for the previous version, the rollback is deterministic and fast.
Common Pitfalls
Don't skip the startPeriod — Without it, ECS may mark containers as unhealthy before they've finished booting, causing a failed deployment loop.
Don't use latest as your image tag — When ECS scales or replaces a task, it may pull a different image than you intended, making debugging nearly impossible.
Don't forget stateful data — Any logs, uploads, or session state stored inside the container will be lost when it stops. Use S3, EFS, or an external session store (e.g., Redis) instead.
Summary
Zero-downtime Docker migrations on AWS ECS come down to a handful of coordinated configurations:
- Immutable image tags for predictable deployments and rollbacks
- Container health checks with a sufficient
startPeriod stopTimeout+ graceful SIGTERM handling to drain in-flight requests- ALB deregistration delay to hold traffic during shutdown
- Rolling deployment with
minimumHealthyPercent=100to never drop below capacity
Get these five things right, and your migration — and every future deployment — will be invisible to your users.
Have questions or ran into a different edge case? Drop a comment or reach out on the community forum.