Zero-downtime isn't a buzzword; it's an Application Load Balancer + graceful shutdown config I tweaked for 2 weeks.
The Reality of 15 Deployments a Day
When your team deploys 15 times a day, every second of downtime multiplies fast. A 5-second blip becomes 75 seconds of cumulative errors. A 30-second rolling restart becomes 7.5 minutes of degraded service. Every. Single. Day.
We run Next.js on ECS Fargate, and achieving true zero-downtime took me two weeks of late nights, CloudWatch deep-dives, and a love-hate relationship with AWS load balancer health checks.
Here's exactly what worked—and what didn't.
The Architecture Snapshot
text
Internet → ALB (port 443) → Target Group → ECS Fargate (Next.js)
↓
Container Health Checks
↓
Graceful Shutdown (SIGTERM)
Stack:
Next.js 14 (App Router, standalone output)
AWS ECS Fargate (capacity provider: FARGATE_SPOT)
Application Load Balancer (not Classic, not NLB)
Docker images ~450MB (yes, we're working on it)
Phase 1: What "Zero-Downtime" Actually Means Here
Let's kill the buzzword. For us, zero-downtime means:
No 5xx errors during deployment (ALB 504s? Gone.)
No dropped connections for in-flight requests
Sub-100ms p99 latency increase during rolling updates
Complete rollback capability in under 90 seconds
Phase 2: The ALB Setup That Finally Worked
Target Group Health Check Settings
This is where I spent most of my two weeks. The defaults are wrong for Next.js.
yaml
HealthCheckProtocol: HTTP
HealthCheckPath: /api/health # NOT / (more on this)
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
HealthCheckTimeoutSeconds: 5
HealthCheckIntervalSeconds: 15
Why /api/health over /?
Next.js / does SSR, which can be slow and fetch from APIs/databases
/api/health is a lightweight, serverless function that returns { status: 'ok' }
Your health check should test the runtime, not the app logic
Deregistration Delay (Draining)
This is the silent killer of zero-downtime:
text
DeregistrationDelay: 120 seconds
Why 120 seconds? Our average request duration is ~200ms, p99 is ~4.5 seconds. The default 300 seconds is too long (keeps unhealthy containers alive), but 60 seconds is too short for long-running Next.js server actions. 120 was our Goldilocks number.
Phase 3: The Container Health Check (The Real MVP)
Your Docker health check isn't optional. It's the difference between ECS leaving a broken container running for 3 minutes and killing it in 15 seconds.
dockerfile
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
Pro tip: The start-period is critical. Next.js takes ~18 seconds to boot on Fargate (node_modules, compilation). Without this, ECS will kill your container before it even starts serving traffic.
Phase 4: Next.js Graceful Shutdown (The Hard Part)
This was the hardest piece to nail. Next.js doesn't handle SIGTERM gracefully by default—it just dies.
The Solution: Custom Server with Signal Handling
We migrated from next start to a custom server:
javascript
// server.js
const { createServer } = require('http');
const next = require('next');
const app = next({ dev: false });
const handle = app.getRequestHandler();
let server;
app.prepare().then(() => {
server = createServer((req, res) => handle(req, res));
server.listen(3000, () => {
console.log('Next.js ready on 3000');
});
// --- GRACEFUL SHUTDOWN ---
const shutdown = () => {
console.log('Received SIGTERM, starting graceful shutdown...');
// Stop accepting new connections
server.close(() => {
console.log('Closed all connections, exiting.');
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error('Force exiting after timeout.');
process.exit(1);
}, 30000); // ALB draining timeout - 10s buffer
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
});
Dockerfile Entrypoint
dockerfile
ENTRYPOINT ["node", "server.js"]
NOT npm start or next start. We need process-level control.
Phase 5: ECS Task Definition (The Final Config)
json
{
"family": "nextjs-app",
"networkMode": "awsvpc",
"containerDefinitions": [{
"name": "nextjs",
"image": "...",
"essential": true,
"portMappings": [{
"containerPort": 3000,
"hostPort": 3000,
"protocol": "tcp"
}],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"],
"interval": 10,
"timeout": 3,
"retries": 3,
"startPeriod": 30
},
"linuxParameters": {
"capabilities": {
"add": ["NET_ADMIN"] // For container-level networking
}
}
}],
"requiresCompatibilities": ["FARGATE"],
"executionRoleArn": "...",
"taskRoleArn": "..."
}
Phase 6: The Deployment Flow That Actually Works
text
- Build Next.js (standalone output)
- Docker build (multi-stage, ~450MB → 180MB with standalone)
- Push to ECR
- ECS update-service (force-new-deployment)
- ALB gracefully drains old tasks (120s deregistration)
- ECS starts new tasks with health check (30s start-period)
- ALB begins routing after 2 successful health checks (20s)
- Old tasks drain and terminate Total impact: ~3 seconds of elevated latency during the handover. No errors. No dropped connections.
Phase 7: The Monitoring That Validates It
We monitor every deployment with these CloudWatch alarms:
yaml
- TargetGroup.ResponseTime.p99 > 500ms (degraded performance)
- TargetGroup.UnHealthyHostCount > 0 (stuck)
- ECS.Service.utilization.memory > 85% (container strain)
- LoadBalancer.HTTPCode_ELB_5XX > 0 (the big red flag) Deployment dashboards: We overlay deployment timestamps on latency graphs. No spikes = success.
Phase 8: What Almost Broke Us
The Node.js Event Loop Issue: Next.js 14's server components can block the event loop during boot. Our health check timed out because the server was busy compiling. Solved by:
javascript
// next.config.js
module.exports = {
experimental: {
optimizeServerReact: true,
serverMinification: true
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production'
}
}
The ALB Sticky Sessions Problem: We use session affinity. During deployments, the ALB kept routing users to dying containers. Fixed by setting:
text
StickinessEnabled: true
StickinessType: lb_cookie
CookieExpirationPeriod: 300 # 5 minutes
This gave old containers enough time to drain existing sessions while new ones handled fresh connections.
The Numbers After 2 Weeks
Metric Before After
Deployment errors (5xx) 3-8 per deploy 0
Deployment latency spike 2-8 seconds <100ms
P99 response time during rollout ~3 seconds ~250ms
Failed deployments ~10% <1%
Rollback time ~3 minutes ~90 seconds
The "I Told You So" Moment
Our VP of Engineering watched a deployment during peak traffic. No alerts. No errors. Just a Slack message: "Deploy #12,873 completed."
He looked at me and said, "So it just... works now?"
That was worth the two weeks.
Key Takeaways for Your Setup
Health checks matter more than you think. Use a lightweight endpoint, not the homepage.
Deregistration delay is a balancing act. Too short = dropped requests. Too long = zombie containers.
Custom server with SIGTERM handling is non-negotiable. Next.js's default isn't production-grade for high-frequency deploys.
Start-period in Docker health checks saves you. Next.js needs warm-up time.
Monitor everything. You can't fix what you can't measure.
What's Next for Us
CDN + Next.js ISR: Offload some traffic to reduce container load
GitOps with ArgoCD: Automated rollbacks based on error budgets
Canary deployments: 10% traffic to new version before full rollout
Warm containers: Pre-warm a container before ECS kills the old one
Final thought: Zero-downtime isn't a checkbox. It's a continuous, iterative process that requires understanding your application at the container, network, and framework level. Two weeks of tweaking ALB settings and health checks turned our deployment anxiety into deployment confidence.
And now, 15 times a day, we deploy without a single user noticing.
That's the win.
Have you wrestled with ECS + Next.js deployments? Drop your war stories in the comments—I want to hear what broke for you.
Top comments (0)