Introduction
If you've ever deployed a Node.js app by SSH-ing into a server, running git pull, killing the old process, and praying nothing breaks — you already know the problem. That gap between "container stopped" and "container started" is where dropped API requests, broken WebSocket sessions, and 502 errors live. It might only last three or four seconds, but at 2 a.m. during a traffic spike, three seconds is an eternity.
This guide walks through a deployment pipeline that most teams eventually converge on before they're ready to invest in Kubernetes or a managed container service: Docker + Nginx + GitHub Actions on a single (or a handful of) EC2 instances, using a blue-green deployment strategy. It's simple enough to reason about, cheap enough to run on a $10/month EC2 instance, and robust enough to serve real production traffic without a single dropped connection.
By the end of this article, you'll have:
- A Node.js app containerized correctly for graceful shutdowns
- An Nginx reverse proxy configured to hot-swap upstream containers
- A deploy script that performs health checks before cutting over traffic
- A GitHub Actions workflow that ties it all together on every push to
main - An understanding of exactly why this setup protects WebSocket and long-polling connections
Let's get into it.
What Is Zero-Downtime Deployment (and Why Blue-Green Works)
Zero-downtime deployment means your users never see an interruption in service while you ship new code — no failed requests, no connection resets, no "site can't be reached" flashes.
There are a few common strategies:
- Rolling deployments — replace instances/containers one at a time behind a load balancer. Common in Kubernetes and ECS.
- Blue-green deployments — run two identical environments ("blue" and "green"). Traffic points to one while you deploy and verify the other, then you flip a switch.
- Canary deployments — gradually shift a small percentage of traffic to the new version before a full rollout.
For a single EC2 instance (or a small fleet), blue-green is the sweet spot. You don't need a load balancer service, service mesh, or orchestrator — just two Docker containers and an Nginx config that decides which one receives traffic.
Here's the core idea:
- Your Node.js app currently runs as container
app-blue, and Nginx proxies all traffic to it. - GitHub Actions builds a new image and starts it as
app-green, on a different internal port. - A health check hits
app-green's/healthendpoint until it responds200 OK. - Nginx's upstream configuration is atomically swapped to point to
app-green. - Nginx is reloaded (not restarted) — existing connections drain gracefully.
app-blueis stopped only after traffic has fully moved off it.
The next deployment simply reverses the colors. This "swap direction" approach means you always have a known-good container running while the new one boots and proves itself healthy.
Architecture Overview
Here's what we're building:
GitHub Repo (push to main)
│
▼
GitHub Actions Workflow
├─ Build Docker image
├─ Push to registry (GHCR or Docker Hub)
└─ SSH into EC2 → run deploy.sh
│
▼
EC2 Instance
┌───────────────────────────────┐
│ Nginx (port 80/443) │
│ └── upstream → active color │
│ │
│ Docker: app-blue (port 3001)│
│ Docker: app-green (port 3002)│
└───────────────────────────────┘
Nginx is the only process that ever binds to ports 80/443. The two application containers run on internal ports that are never exposed to the public internet — only Nginx talks to them, over the Docker bridge network or localhost.
Prerequisites
Before you start, make sure you have:
- An AWS EC2 instance (Ubuntu 24.04 LTS works well) with Docker and Docker Compose installed
- A domain or Elastic IP pointed at the instance, with security group rules allowing ports 22, 80, and 443
- Nginx installed directly on the host (not containerized — this makes reloads cheaper and more reliable)
- A GitHub repository with Actions enabled
- An SSH key pair dedicated to deployments (never reuse your personal key)
Step 1: Provision and Harden Your EC2 Instance
Start with a minimal, locked-down instance. Disable password authentication, and create a dedicated deploy user instead of using root or ubuntu directly for CI.
# On the EC2 instance
sudo adduser deployer
sudo usermod -aG docker deployer
# Generate a deploy-specific SSH key pair locally (not on the server)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh_deploy_key
# Add the PUBLIC key to the server
cat gh_deploy_key.pub | ssh ubuntu@your-ec2-ip \
"sudo -u deployer mkdir -p /home/deployer/.ssh && \
sudo tee -a /home/deployer/.ssh/authorized_keys"
The private key (gh_deploy_key) goes into GitHub Secrets — never onto the server, never into your repo.
Install Docker and Nginx if they aren't already present:
sudo apt update && sudo apt install -y docker.io docker-compose-plugin nginx
sudo systemctl enable docker nginx
Step 2: Containerize the Node.js Application
A good production Dockerfile for Node.js is small, uses a non-root user, and supports graceful shutdown signals.
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app /app
USER appuser
EXPOSE 3000
# tini ensures SIGTERM is forwarded correctly to the Node process
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
That tini entrypoint matters more than it looks. Without an init process, Node.js running as PID 1 inside a container doesn't always forward SIGTERM correctly, which means Docker eventually force-kills it with SIGKILL — dropping every in-flight request instantly. tini fixes this signal-forwarding gap.
On the application side, your Node.js server needs to listen for shutdown signals and drain connections before exiting:
// server.js
const server = app.listen(3000, () => {
console.log("Server listening on port 3000");
});
let isShuttingDown = false;
process.on("SIGTERM", () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log("SIGTERM received, closing server gracefully...");
server.close(() => {
console.log("All connections closed. Exiting.");
process.exit(0);
});
// Force-exit if connections don't close in time
setTimeout(() => {
console.warn("Forcing shutdown after timeout");
process.exit(1);
}, 10_000).unref();
});
app.get("/health", (req, res) => {
if (isShuttingDown) return res.status(503).send("shutting down");
res.status(200).send("ok");
});
Note the /health endpoint reports 503 the instant a shutdown begins — this is what stops Nginx (or your deploy script) from routing new traffic to a container that's already draining.
Step 3: Configure Nginx as a Zero-Downtime Reverse Proxy
The trick to hot-swapping traffic with Nginx is keeping the upstream target in a separate, tiny config file that the deploy script overwrites and Nginx reloads — never restarts.
# /etc/nginx/conf.d/upstream.conf
# This file is rewritten by deploy.sh on every release
upstream app_backend {
server 127.0.0.1:3001; # currently active color (blue or green)
}
# /etc/nginx/sites-available/app.conf
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Give long-lived connections room to breathe
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Retry on the next upstream if the connection fails mid-request
proxy_next_upstream error timeout;
}
}
nginx -s reload (not restart) spawns new worker processes with the updated config while old workers finish serving their existing connections before exiting. This single detail is the backbone of the entire zero-downtime strategy — restarting Nginx tears down every connection immediately; reloading does not.
Step 4: Build the Blue-Green Deploy Script
This script lives on the EC2 instance and does the actual color-swapping. GitHub Actions will trigger it remotely.
#!/usr/bin/env bash
# /home/deployer/deploy.sh
set -euo pipefail
IMAGE="$1" # e.g. ghcr.io/you/app:sha-abc123
UPSTREAM_CONF="/etc/nginx/conf.d/upstream.conf"
CURRENT_PORT=$(grep -oP '127.0.0.1:\K[0-9]+' "$UPSTREAM_CONF")
if [ "$CURRENT_PORT" == "3001" ]; then
NEW_COLOR="green"; NEW_PORT="3002"
else
NEW_COLOR="blue"; NEW_PORT="3001"
fi
echo "Current port: $CURRENT_PORT → deploying $NEW_COLOR on $NEW_PORT"
docker pull "$IMAGE"
docker rm -f "app-$NEW_COLOR" 2>/dev/null || true
docker run -d \
--name "app-$NEW_COLOR" \
--restart unless-stopped \
-p "127.0.0.1:${NEW_PORT}:3000" \
--env-file /home/deployer/.env.production \
"$IMAGE"
echo "Waiting for $NEW_COLOR to become healthy..."
for i in $(seq 1 15); do
if curl -fs "http://127.0.0.1:${NEW_PORT}/health" > /dev/null; then
echo "Health check passed."
break
fi
if [ "$i" -eq 15 ]; then
echo "Health check FAILED. Aborting deployment, old container untouched."
docker rm -f "app-$NEW_COLOR"
exit 1
fi
sleep 2
done
echo "Cutting over traffic to $NEW_COLOR ($NEW_PORT)..."
echo "upstream app_backend { server 127.0.0.1:${NEW_PORT}; }" | sudo tee "$UPSTREAM_CONF" > /dev/null
sudo nginx -t
sudo nginx -s reload
echo "Draining old container gracefully..."
sleep 5
OLD_COLOR=$([ "$NEW_COLOR" == "green" ] && echo "blue" || echo "green")
docker stop -t 15 "app-$OLD_COLOR" 2>/dev/null || true
echo "Deployment complete. Live color: $NEW_COLOR"
Three things make this script safe:
- It never touches the live container until the new one is proven healthy.
nginx -tvalidates config syntax before reload — a typo can't take down production.- The old container is stopped last, with a grace period, giving
SIGTERMhandling in your Node.js app time to drain existing requests.
Step 5: Automate Everything With GitHub Actions
Now wire it together. This workflow builds the image, pushes it to GitHub Container Registry, and triggers the deploy script over SSH.
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.IMAGE_NAME }}:latest
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.EC2_HOST }}
username: deployer
key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
script: |
/home/deployer/deploy.sh ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Notify on failure
if: failure()
run: echo "Deployment failed — check logs and verify live container manually."
Required GitHub Secrets
| Secret | Purpose |
|---|---|
EC2_HOST | Public IP or DNS of your EC2 instance |
EC2_SSH_PRIVATE_KEY | The private key matching the deployer user's authorized_keys |
GITHUB_TOKEN | Auto-provided by GitHub Actions for GHCR auth |
Set these under Repo → Settings → Secrets and variables → Actions. Never hardcode credentials in the workflow file, and restrict the EC2 security group's port 22 to GitHub Actions' published IP ranges if you want to go a step further, or better yet, tunnel through AWS Systems Manager (SSM) instead of exposing SSH publicly at all.
Handling WebSockets and Long-Lived Connections Gracefully
This is the part most tutorials skip, and it's exactly what the job description promises to solve.
When Nginx reloads, it doesn't kill existing connections — it stops handing new connections to old worker processes and lets them finish naturally. For a typical HTTP request lasting milliseconds, you'd never even notice a reload happened. But WebSockets and Server-Sent Events stay open for minutes or hours, so a few things need to align:
proxy_read_timeoutandproxy_send_timeoutmust be generous (we set 3600s above) so Nginx doesn't prematurely close idle-but-alive WebSocket connections.- Old workers keep serving existing WebSocket clients even after the upstream config points elsewhere — because the connection was already established through the old worker process, which persists until the client disconnects or the container stops.
- The container drain delay (
docker stop -t 15) gives your app time to emit a close event to connected clients rather than dropping the TCP connection abruptly. On the client side, implement reconnect logic that treats a clean1000/1001close code as "reconnect to get the latest server," not an error. - If your app uses sticky sessions or in-memory session state, blue-green deploys will break that assumption the moment a client reconnects to the new color. Move session state to Redis or a similar external store before adopting this pattern.
Real-World Example: Deploying an Express + Socket.IO API
Imagine a chat API built with Express and Socket.IO, deployed nightly. Before this pipeline, every deploy dropped active chat sessions and required someone to manually reconnect test clients. After adopting blue-green:
- A merge to
maintriggers the GitHub Actions workflow automatically. - The new image builds in about 40 seconds and pushes to GHCR.
deploy.shstartsapp-greenon port 3002 whileapp-blue(port 3001) keeps serving every active chat socket.- Health checks pass in under 10 seconds — the app connects to Redis and Postgres before reporting healthy.
- Nginx reloads, and new socket connections start landing on
app-green. Existing sockets onapp-bluestay alive. - Fifteen seconds later,
app-bluereceivesSIGTERM, emits areconnectevent to any remaining sockets, and exits cleanly. - Total deploy time: under a minute. Dropped messages: zero.
That's the entire value proposition of this architecture — deploys become boring, which is exactly what you want in production.
Best Practices
- Always validate Nginx config with
nginx -tbefore reloading — a single syntax error in a config that's never tested can silently break production. - Tag Docker images with the Git SHA, not just
latest, so you can roll back to an exact, reproducible build instantly. - Keep environment secrets out of the image — mount them via
--env-fileor AWS Secrets Manager, neverCOPY .envinto a Dockerfile. - Externalize session state (Redis, DynamoDB) so either color can serve any client at any time.
- Set resource limits (
--memory,--cpus) on containers so a runaway green deployment can't starve the still-live blue one. - Log deploy events (start, health check result, cutover, old container stop) to CloudWatch or a similar sink for auditability.
- Automate rollback: if health checks fail, the script should exit non-zero and leave the previous container fully intact and serving traffic — which the script above already does.
Common Mistakes
- Restarting Nginx instead of reloading it.
systemctl restart nginxdrops every open connection instantly, defeating the entire point of this setup. - Skipping the health check delay. Cutting traffic over immediately after
docker run, before the app has finished connecting to its database, causes a wave of 502s. - Not handling
SIGTERMin the app. Without it, Docker waits out the full grace period and thenSIGKILLs the process, hard-dropping any in-flight requests. - Exposing both container ports publicly. Only Nginx should be reachable from outside; bind app containers to
127.0.0.1only. - Forgetting to prune old images. Left unchecked,
docker system dfwill eventually show a disk full of unused blue/green image layers — scheduledocker image prune -af --filter "until=168h"in a cron job. - Using long-lived, broadly-scoped SSH keys for CI. Rotate deploy keys periodically and scope the
deployeruser'ssudoaccess to only whatdeploy.shneeds (nginx -s reload,nginx -t), via a tightly scopedsudoersentry.
🚀 Pro Tips
- Use AWS Systems Manager Session Manager instead of opening port 22 to the internet — GitHub Actions can tunnel through SSM, eliminating a whole class of brute-force risk.
- Add a Slack or Discord webhook step to your workflow so the team gets a notification with the deployed commit SHA, not just a green checkmark in GitHub.
- For multi-instance setups, extend
deploy.shinto a loop that SSHes into each EC2 instance behind an Application Load Balancer, deploying one at a time — a rolling blue-green hybrid. - Store the
deploy.shscript itself in the repo (underscripts/deploy.sh) andscpit to the server as part of the workflow, so deployment logic is version-controlled alongside the app it deploys. - Add a canary health window: after cutover, keep polling
/healthon the new color for 60 seconds and auto-rollback (swap the upstream back) if it starts failing — this catches issues that only appear under real traffic.
📌 Key Takeaways
- Zero-downtime deployment isn't magic — it's the disciplined combination of graceful shutdown handling, Nginx config reloads (not restarts), and health-checked container swaps.
- Blue-green deployment on a single EC2 instance is a legitimate, production-ready pattern — you don't need Kubernetes to stop dropping requests during deploys.
- GitHub Actions can safely own the entire release process, from build to health check to cutover to rollback, with no manual SSH sessions required.
- Protecting WebSocket connections comes down to generous Nginx timeouts, a container drain delay, and client-side reconnect logic — not any single "WebSocket mode" setting.
Conclusion
Zero-downtime deployment often sounds like an enterprise-only concern reserved for teams running large Kubernetes clusters, but as this guide shows, the fundamentals are accessible on a single EC2 instance with tools you likely already know: Docker, Nginx, and GitHub Actions. The real engineering lies in the details — forwarding SIGTERM correctly, reloading instead of restarting Nginx, and health-checking before you ever touch live traffic.
Once this pipeline is in place, deploying stops being an event people brace for and becomes something that just happens, quietly, dozens of times a week if you want it to. That's the actual goal of automation: not speed for its own sake, but the confidence to ship without fear.
From here, natural next steps include extending this pattern across multiple EC2 instances behind an Application Load Balancer, adding automated smoke tests post-deploy, or migrating to ECS/Fargate once your team outgrows manual instance management — but you'll be migrating a pattern that already works, not solving zero-downtime for the first time under pressure.