Skip to main content
Back to Blog
Node.jsDockerNginxGitHub ActionsAWS EC2DevOpsZero-Downtime DeploymentCI/CD

How to Automate Zero-Downtime Deployments for Node.js using Docker, Nginx, and GitHub Actions on AWS EC2

A production-focused guide to building a fully automated blue-green deployment pipeline for Node.js on AWS EC2 using Docker, Nginx, and GitHub Actions — with zero dropped requests and zero terminated WebSocket connections.

September 8, 202615 min readNiraj Kumar

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:

  1. Your Node.js app currently runs as container app-blue, and Nginx proxies all traffic to it.
  2. GitHub Actions builds a new image and starts it as app-green, on a different internal port.
  3. A health check hits app-green's /health endpoint until it responds 200 OK.
  4. Nginx's upstream configuration is atomically swapped to point to app-green.
  5. Nginx is reloaded (not restarted) — existing connections drain gracefully.
  6. app-blue is 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 -t validates config syntax before reload — a typo can't take down production.
  • The old container is stopped last, with a grace period, giving SIGTERM handling 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

SecretPurpose
EC2_HOSTPublic IP or DNS of your EC2 instance
EC2_SSH_PRIVATE_KEYThe private key matching the deployer user's authorized_keys
GITHUB_TOKENAuto-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_timeout and proxy_send_timeout must 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 clean 1000/1001 close 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:

  1. A merge to main triggers the GitHub Actions workflow automatically.
  2. The new image builds in about 40 seconds and pushes to GHCR.
  3. deploy.sh starts app-green on port 3002 while app-blue (port 3001) keeps serving every active chat socket.
  4. Health checks pass in under 10 seconds — the app connects to Redis and Postgres before reporting healthy.
  5. Nginx reloads, and new socket connections start landing on app-green. Existing sockets on app-blue stay alive.
  6. Fifteen seconds later, app-blue receives SIGTERM, emits a reconnect event to any remaining sockets, and exits cleanly.
  7. 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 -t before 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-file or AWS Secrets Manager, never COPY .env into 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 nginx drops 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 SIGTERM in the app. Without it, Docker waits out the full grace period and then SIGKILLs 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.1 only.
  • Forgetting to prune old images. Left unchecked, docker system df will eventually show a disk full of unused blue/green image layers — schedule docker 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 deployer user's sudo access to only what deploy.sh needs (nginx -s reload, nginx -t), via a tightly scoped sudoers entry.

🚀 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.sh into 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.sh script itself in the repo (under scripts/deploy.sh) and scp it 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 /health on 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.

References

Discussion

All Articles
Node.jsDockerNginxGitHub ActionsAWS EC2DevOpsZero-Downtime DeploymentCI/CD

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.