Skip to main content
Back to Blog
Node.jsDockerGitHub ActionsNginxCI/CDDevOps

Automating Node.js Docker Deployments with GitHub Actions and Nginx

Learn how to build a custom GitHub Action that automatically builds, pushes, and deploys a Dockerized Node.js app behind an Nginx reverse proxy for a fully automated CI/CD pipeline.

August 30, 202614 min readNiraj Kumar

Introduction

If you've ever deployed a Node.js application by SSH-ing into a server, pulling the latest code, running npm install, and praying nothing breaks — you already know why automation matters. It's slow, error-prone, and doesn't scale past "just me and my side project."

The good news? You don't need a massive platform team or an expensive managed PaaS to get a professional-grade deployment pipeline. With Docker, GitHub Actions, and Nginx, you can build a fully automated CI/CD workflow that builds your app, tests it, packages it into a container, pushes it to a registry, and deploys it to your server — all triggered by a simple git push.

In this guide, we're going a step further than most tutorials. Instead of just writing a workflow file, we'll build a custom, reusable GitHub Action that encapsulates the entire build-push-deploy logic. This means you can drop it into any Node.js project and have it running in minutes, without rewriting YAML every time.

By the end of this article, you'll understand:

  • How to containerize a Node.js app the right way
  • How Nginx fits into the picture as a reverse proxy
  • How to author a custom GitHub Action from scratch
  • How to wire everything into a complete CI/CD workflow
  • The mistakes that trip up most teams — and how to avoid them

Let's get into it.

Why Automate Your Node.js Docker Deployments?

Before diving into code, it's worth understanding why this setup is worth the upfront investment.

  • Consistency — Docker guarantees your app runs the same way in development, staging, and production. No more "works on my machine."
  • Speed — A well-designed pipeline can take you from git push to a live deployment in under two minutes.
  • Reliability — Automated pipelines remove human error from the deployment process. No more forgotten .env variables or half-applied migrations.
  • Auditability — Every deployment is tied to a commit, a workflow run, and a log. If something breaks, you know exactly what changed.
  • Reusability — A custom GitHub Action turns your deployment logic into a portable, versioned artifact you can share across teams and repositories.

This isn't just a "nice to have" anymore. In 2026, even small teams are expected to ship multiple times a day, and manual deployment simply can't keep up.

Architecture Overview

Here's the mental model we're building toward:

  1. A developer pushes code to the main branch.
  2. GitHub Actions triggers a workflow.
  3. The workflow runs tests and lints the code.
  4. A custom Action builds a Docker image using a multi-stage Dockerfile.
  5. The image is tagged and pushed to a container registry (GitHub Container Registry, in our case).
  6. The Action connects to the production server over SSH.
  7. The server pulls the new image, stops the old container, and starts the new one.
  8. Nginx, running as a reverse proxy in front of the Node.js container, seamlessly routes traffic to the new instance.

The Deployment Pipeline at a Glance

Developer Push
      │
      ▼
GitHub Actions Trigger
      │
      ▼
Lint & Test
      │
      ▼
Docker Build (multi-stage)
      │
      ▼
Push to GHCR
      │
      ▼
SSH Deploy to Server
      │
      ▼
Nginx Reverse Proxy ──▶ Node.js Container

This architecture keeps your app decoupled from your infrastructure. Nginx never cares what's running behind it — it just forwards traffic to a port. That decoupling is exactly what makes zero-downtime deployments possible.

Prerequisites

To follow along, you'll need:

  • A Node.js application with a package.json and a start script
  • A GitHub repository for the project
  • A Linux server (Ubuntu 24.04 works well) with Docker and Nginx installed
  • SSH access to that server
  • A container registry — we'll use GitHub Container Registry (GHCR) since it integrates natively with GitHub Actions

Step 1: Containerizing the Node.js Application

Writing a Production-Ready Dockerfile

The single biggest mistake teams make is using a "dev" Dockerfile in production — one giant node:latest image with all dev dependencies bundled in. Instead, we'll use a multi-stage build to keep the final image lean.

# ---- Build Stage ----
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---- Production Stage ----
FROM node:20-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist ./dist

# Run as a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]

A few things worth calling out here:

  • node:20-alpine keeps the image small — Alpine-based images shave off tens of megabytes compared to the default Debian-based images.
  • The builder stage installs all dependencies (including dev) so it can run your build step (TypeScript compilation, bundling, etc.).
  • The runner stage only copies the compiled output and installs production dependencies, dramatically reducing the final image size and attack surface.
  • Running as a non-root user is a small change that meaningfully improves container security.
  • The HEALTHCHECK instruction is critical — Docker (and your deployment script) can use this to know whether the container is actually ready to serve traffic, not just "started."

docker-compose for Local Testing

Before you ever push to CI, you should be able to reproduce the exact production setup locally:

version: "3.9"

services:
  app:
    build: .
    container_name: node-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    networks:
      - app-network

  nginx:
    image: nginx:1.27-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Running docker compose up --build locally should give you the exact same topology you'll have in production — a Node.js container sitting behind Nginx.

Step 2: Setting Up Nginx as a Reverse Proxy

Nginx sits in front of your Node.js app and handles things your app shouldn't have to worry about: SSL termination, gzip compression, request buffering, and load balancing across multiple app instances.

Nginx Configuration Explained

upstream node_app {
    server app:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name example.com;

    # Redirect all HTTP traffic to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    gzip on;
    gzip_types text/plain application/json application/javascript text/css;

    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
        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;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /health {
        proxy_pass http://node_app/health;
        access_log off;
    }
}

Key points:

  • upstream node_app abstracts the backend, which makes it trivial to later add more app containers for load balancing — just add more server lines.
  • HTTP → HTTPS redirect is non-negotiable in 2026; browsers actively penalize insecure sites.
  • proxy_set_header directives ensure your Node.js app still sees the real client IP and protocol, which matters for logging, rate limiting, and secure cookies.
  • A dedicated /health location keeps health check traffic out of your access logs, reducing noise.

Step 3: Building a Custom GitHub Action

This is where things get interesting. Instead of writing a giant, repo-specific workflow file, we'll extract the build-push-deploy logic into a composite GitHub Action that can be versioned, reused, and shared.

Understanding Composite Actions

A composite action bundles a sequence of shell steps under a single uses: entry in your workflow. It's the simplest way to build a custom action without needing JavaScript or a Docker-based action runtime.

Create the following structure in your repository:

.github/
  actions/
    docker-deploy/
      action.yml
      deploy.sh

action.yml

name: "Docker Build, Push & Deploy"
description: "Builds a Docker image, pushes it to GHCR, and deploys it to a remote server via SSH."

inputs:
  image-name:
    description: "Name of the Docker image (e.g. ghcr.io/org/app)"
    required: true
  image-tag:
    description: "Tag for the Docker image"
    required: false
    default: ${{ github.sha }}
  registry-username:
    description: "Container registry username"
    required: true
  registry-password:
    description: "Container registry password or token"
    required: true
  ssh-host:
    description: "Deployment server hostname or IP"
    required: true
  ssh-user:
    description: "SSH username"
    required: true
  ssh-private-key:
    description: "SSH private key for authentication"
    required: true

runs:
  using: "composite"
  steps:
    - name: Log in to registry
      shell: bash
      run: echo "${{ inputs.registry-password }}" | docker login ghcr.io -u "${{ inputs.registry-username }}" --password-stdin

    - name: Build Docker image
      shell: bash
      run: docker build -t ${{ inputs.image-name }}:${{ inputs.image-tag }} -t ${{ inputs.image-name }}:latest .

    - name: Push Docker image
      shell: bash
      run: |
        docker push ${{ inputs.image-name }}:${{ inputs.image-tag }}
        docker push ${{ inputs.image-name }}:latest

    - name: Deploy to server
      shell: bash
      env:
        SSH_HOST: ${{ inputs.ssh-host }}
        SSH_USER: ${{ inputs.ssh-user }}
        IMAGE: ${{ inputs.image-name }}:${{ inputs.image-tag }}
      run: |
        mkdir -p ~/.ssh
        echo "${{ inputs.ssh-private-key }}" > ~/.ssh/deploy_key
        chmod 600 ~/.ssh/deploy_key
        ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
        bash ${{ github.action_path }}/deploy.sh

Notice how every piece of configuration — the image name, credentials, and server details — is exposed as an input. That's what makes this action portable: any repository can consume it just by passing different values.

Entry Point Script

The deploy.sh script keeps the actual remote deployment logic out of the YAML, which makes it far easier to read, test, and version:

#!/usr/bin/env bash
set -euo pipefail

ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" << EOF
  set -e
  echo "Pulling image: $IMAGE"
  docker pull $IMAGE

  echo "Starting new container..."
  docker stop node-app-new 2>/dev/null || true
  docker rm node-app-new 2>/dev/null || true

  docker run -d --name node-app-new \
    --network app-network \
    --restart unless-stopped \
    -e NODE_ENV=production \
    $IMAGE

  echo "Waiting for health check..."
  for i in {1..10}; do
    if docker exec node-app-new wget -qO- http://localhost:3000/health; then
      echo "New container is healthy."
      break
    fi
    sleep 3
  done

  echo "Swapping containers..."
  docker stop node-app 2>/dev/null || true
  docker rm node-app 2>/dev/null || true
  docker rename node-app-new node-app

  echo "Reloading Nginx..."
  docker exec nginx-proxy nginx -s reload

  echo "Cleaning up old images..."
  docker image prune -f
EOF

This script is doing the heavy lifting of a rolling deployment: it starts the new container alongside the old one, waits until it reports healthy, then swaps them and reloads Nginx — all without a single second of downtime for end users.

Step 4: Wiring It All Together with a Workflow

With the custom Action in place, the actual workflow file becomes refreshingly short.

The CI/CD Workflow File

name: CI/CD Pipeline

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - run: npm ci
      - run: npm run lint
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build, Push & Deploy
        uses: ./.github/actions/docker-deploy
        with:
          image-name: ghcr.io/${{ github.repository }}
          image-tag: ${{ github.sha }}
          registry-username: ${{ github.actor }}
          registry-password: ${{ secrets.GITHUB_TOKEN }}
          ssh-host: ${{ secrets.SSH_HOST }}
          ssh-user: ${{ secrets.SSH_USER }}
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

Notice the deploy job depends on test via needs: test — a failing test suite means the deployment never happens. That's a small line of YAML doing a lot of quiet, important work.

Step 5: Deploying to the Server

SSH-Based Deployment Strategy

SSH-based deployment is simple, requires no third-party infrastructure, and works well for small-to-mid-sized teams running their own VPS. For larger fleets, you'd eventually graduate to something like Kubernetes or a managed container service — but SSH-based Docker deployment remains a completely valid, production-ready approach for the vast majority of projects.

Zero-Downtime Reload with Nginx

The trick to zero downtime isn't in Nginx configuration alone — it's the combination of:

  1. Starting the new container before stopping the old one
  2. Waiting for a genuine health check to pass
  3. Only then swapping traffic over
  4. Reloading Nginx with nginx -s reload, which gracefully finishes in-flight requests on old worker processes before terminating them

This pattern is often called a rolling restart, and it's the single most impactful change you can make if your current deployment process causes a visible blip for users.

Real-World Example: End-to-End Flow

Picture a small SaaS team shipping a bug fix:

  1. A developer merges a pull request into main.
  2. GitHub Actions kicks off the test job — linting and unit tests run in about 40 seconds.
  3. The deploy job triggers the custom Action.
  4. Docker builds a new image tagged with the commit SHA (great for traceability) and latest.
  5. Both tags are pushed to GHCR.
  6. The Action SSHes into the production server, pulls the new image, and runs the rolling swap script.
  7. Nginx reloads, and the fix is live — typically within 90 seconds of the merge, with zero downtime.

No Slack messages saying "deploying now, hold off on testing." No manual server access. Just a merged PR and a live fix.

🚀 Pro Tips

  • Tag images with the Git SHA, not just latest. This makes rollbacks trivial — you can redeploy any previous SHA-tagged image instantly.
  • Use GitHub Environments with required reviewers for production deploys if you want a manual approval gate without giving up automation elsewhere.
  • Store secrets in GitHub Actions secrets, never in your repo — and rotate SSH keys periodically.
  • Add a /health endpoint to your Node.js app that checks database connectivity, not just "is the process alive." A process can be running and still be broken.
  • Cache your Docker layers using actions/cache or BuildKit's inline cache to speed up repeated builds significantly.
  • Use docker system prune on a schedule on your server to avoid disk space creeping up from old images and dangling layers.
  • Version your custom Action (e.g., tag it v1, v1.1) if you plan to reuse it across multiple repositories, so updates don't silently break other pipelines.

Common Mistakes to Avoid

  • Running the app as root inside the container. This is a needless security risk that's fixed with two lines in your Dockerfile.
  • Skipping health checks before swapping traffic. Without this, a broken deploy can go live and serve errors to real users before anyone notices.
  • Hardcoding secrets into the Dockerfile or workflow YAML. Always use encrypted secrets, never plaintext environment variables in version control.
  • Using node:latest in production. Untagged base images can change unexpectedly, breaking builds without warning. Pin a specific major version like node:20-alpine.
  • Forgetting to reload Nginx after container swaps. If Nginx's upstream connection isn't refreshed properly, you can end up routing traffic to a container that no longer exists.
  • Not cleaning up old Docker images and containers. Left unchecked, this silently fills up server disk space until deployments start failing.
  • Building the Docker image on the production server itself. This wastes server resources and risks inconsistent builds. Always build in CI and ship a prebuilt image.

Best Practices Checklist

  • ✅ Multi-stage Docker builds for smaller, faster images
  • ✅ Non-root user inside containers
  • ✅ Health checks at both the Docker and Nginx level
  • ✅ SHA-based image tagging for easy rollbacks
  • ✅ Secrets managed exclusively through GitHub Actions secrets
  • ✅ Reusable, versioned custom GitHub Action
  • ✅ Rolling deployment strategy for zero downtime
  • ✅ HTTPS enforced at the Nginx layer

📌 Key Takeaways

  • Automating your Node.js deployment pipeline with Docker, GitHub Actions, and Nginx eliminates manual errors and speeds up shipping.
  • A custom composite GitHub Action turns your build-push-deploy logic into a reusable, versioned artifact instead of repeated YAML.
  • Multi-stage Dockerfiles and non-root users are simple changes with outsized security and performance benefits.
  • Nginx's reverse proxy role — combined with a rolling container swap — is what actually delivers zero-downtime deployments.
  • Treat health checks, secrets management, and rollback strategy as core parts of the pipeline, not afterthoughts.

Conclusion

Automating your deployment pipeline isn't about chasing the latest tooling trend — it's about removing friction and risk from the part of software delivery that matters most: getting your code safely in front of users.

By combining a well-structured Dockerfile, a custom GitHub Action, and Nginx as a reverse proxy, you get a deployment process that's fast, repeatable, and genuinely production-ready — without needing a dedicated platform team or expensive tooling.

Start small: containerize your app, get a manual deployment working, then automate one piece at a time. Before long, git push really will be the only step left in your release process.

References

Discussion

All Articles
Node.jsDockerGitHub ActionsNginxCI/CDDevOps

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.