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 pushto a live deployment in under two minutes. - Reliability — Automated pipelines remove human error from the deployment process. No more forgotten
.envvariables 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:
- A developer pushes code to the
mainbranch. - GitHub Actions triggers a workflow.
- The workflow runs tests and lints the code.
- A custom Action builds a Docker image using a multi-stage
Dockerfile. - The image is tagged and pushed to a container registry (GitHub Container Registry, in our case).
- The Action connects to the production server over SSH.
- The server pulls the new image, stops the old container, and starts the new one.
- 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.jsonand 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-alpinekeeps the image small — Alpine-based images shave off tens of megabytes compared to the default Debian-based images.- The
builderstage installs all dependencies (including dev) so it can run your build step (TypeScript compilation, bundling, etc.). - The
runnerstage 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
HEALTHCHECKinstruction 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_appabstracts the backend, which makes it trivial to later add more app containers for load balancing — just add moreserverlines.- HTTP → HTTPS redirect is non-negotiable in 2026; browsers actively penalize insecure sites.
proxy_set_headerdirectives ensure your Node.js app still sees the real client IP and protocol, which matters for logging, rate limiting, andsecurecookies.- A dedicated
/healthlocation 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:
- Starting the new container before stopping the old one
- Waiting for a genuine health check to pass
- Only then swapping traffic over
- 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:
- A developer merges a pull request into
main. - GitHub Actions kicks off the
testjob — linting and unit tests run in about 40 seconds. - The
deployjob triggers the custom Action. - Docker builds a new image tagged with the commit SHA (great for traceability) and
latest. - Both tags are pushed to GHCR.
- The Action SSHes into the production server, pulls the new image, and runs the rolling swap script.
- 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
/healthendpoint 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/cacheor BuildKit's inline cache to speed up repeated builds significantly. - Use
docker system pruneon 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:latestin production. Untagged base images can change unexpectedly, breaking builds without warning. Pin a specific major version likenode: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.