Setting Up a Production-Grade CI/CD Pipeline for Node.js with GitHub Actions
Beyond the basic "build and push" tutorial — secrets management, real integration tests, Docker image caching, and a deploy that verifies itself
Senior Developer

Most tutorials show you how to make a GitHub Actions workflow. This one shows you how to make one that is actually fit for production — with environment separation, secret management, test gates, Docker builds, and a deployment that does not make you nervous.
The stack: Node.js app, Docker, GitHub Container Registry (GHCR), and a VPS running Nginx. No third-party deployment services. No surprise invoices.
What the Pipeline Does
On every push to main:
Runs linting and type checks
Runs the test suite
Builds a Docker image and pushes it to GHCR
SSHes into the VPS and pulls + restarts the container
Verifies the health check passes before marking the deploy successful
On pull requests:
Runs linting and tests
Does NOT deploy anywhere
Project Structure
/your-app
├── src/
├── tests/
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── .github/
└── workflows/
├── ci.yml # runs on all PRs and pushes
└── deploy.yml # runs only on push to mainStep 1 — The Dockerfile
A production Dockerfile should use multi-stage builds to keep the final image small and not include dev dependencies.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# If you have a build step (TypeScript, etc.)
RUN npm run build
# Stage 2: Production image
FROM node:20-alpine AS production
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy only what we need from the builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
USER appuser
EXPOSE 3000
# Health check built into the image
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "dist/index.js"]Key decisions here:
Multi-stage build keeps the image lean (often 3-4x smaller)
Non-root user runs the app (
appuser) — important for container securitynpm ciinstead ofnpm install— deterministic, respects lockfileHealth check baked into the image so Docker itself knows when the container is ready
Step 2 — The CI Workflow (Runs on Every PR)
.github/workflows/ci.yml
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: [main]
jobs:
lint-and-test:
name: Lint & Test
runs-on: ubuntu-latest
services:
# Spin up a Postgres instance for integration tests
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run typecheck
- name: Run tests
run: npm test
env:
NODE_ENV: test
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
JWT_SECRET: test-secret-not-real
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
A few things worth noting:
The Postgres
servicesblock spins up a real database for tests, not a mock. Integration tests that hit a real DB catch far more bugs than unit tests with mocked queries.cache: "npm"cachesnode_modulesbetween runs — often saves 1-2 minutes.Coverage report is uploaded as an artifact even on failure, so you can debug what broke.
Step 3 — The Deploy Workflow (Runs Only on Main)
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
concurrency:
group: production-deploy
cancel-in-progress: false # Never cancel an in-flight production deploy
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Re-run tests before deploying — never skip this
test:
name: Run Tests
uses: ./.github/workflows/ci.yml
build:
name: Build & Push Image
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build-push.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Cache layers from the previous build — speeds up rebuilds significantly
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Production
needs: build
runs-on: ubuntu-latest
environment: production # Enables environment protection rules in GitHub
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_SSH_PORT }} # your custom SSH port
script: |
set -e
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo "Pulling $IMAGE..."
docker pull $IMAGE
echo "Stopping old container..."
docker stop app 2>/dev/null || true
docker rm app 2>/dev/null || true
echo "Starting new container..."
docker run -d \
--name app \
--restart unless-stopped \
--env-file /opt/app/.env.production \
-p 3000:3000 \
$IMAGE
echo "Waiting for health check..."
sleep 5
RETRIES=0
until docker inspect --format='{{.State.Health.Status}}' app | grep -q "healthy"; do
RETRIES=$((RETRIES + 1))
if [ $RETRIES -ge 10 ]; then
echo "Container failed health check. Rolling back..."
docker stop app
docker rm app
# Restart from the previous image tag if you stored it
exit 1
fi
echo "Not healthy yet ($RETRIES/10)..."
sleep 5
done
echo "Deploy successful."
# Clean up old images to save disk space
docker image prune -f
Step 4 — Secrets Setup in GitHub
Go to Settings → Secrets and variables → Actions → New repository secret:
Secret | Value |
|---|---|
| Your server IP or domain |
|
|
| Contents of your |
| Your custom SSH port (e.g. |
Also go to Settings → Environments and create a production environment. You can add required reviewers here so deploys to production require manual approval — useful for teams.
Step 5 — The .env File on Your VPS
Your production secrets live on the VPS only, never in your repo or your Docker image.
# On your VPS at /opt/app/.env.production
DATABASE_URL=postgresql://user:password@localhost:5432/yourdb
JWT_SECRET=a-long-random-string-generated-with-openssl-rand-hex-32
NODE_ENV=production
PORT=3000Generate a proper JWT secret:
openssl rand -hex 32Set correct permissions:
chmod 600 /opt/app/.env.production
chown deploy:deploy /opt/app/.env.productionStep 6 — Nginx as the Front Door
Your Docker container listens on port 3000. Nginx handles SSL and proxies to it.
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Recommended SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain application/json application/javascript text/css;
location / {
proxy_pass http://127.0.0.1:3000;
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;
proxy_cache_bypass $http_upgrade;
}
}Get your SSL certificate with Certbot:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com
# Certbot auto-configures renewal via a systemd timerCommon Problems and Their Fixes
The deploy fails with "permission denied" on Docker
# On the VPS, add your deploy user to the docker group
sudo usermod -aG docker deploy
# Log out and back in for the group change to take effectContainer starts but health check keeps failing Check the container logs before blaming the health check:
docker logs app --tail 50Nine times out of ten, it is a missing environment variable or a database connection issue, not the health check itself.
Old images filling up disk space Add a cron job to the deploy user:
# crontab -e (as deploy user)
0 3 * * * docker image prune -f >> /var/log/docker-prune.log 2>&1GitHub Actions runner can't reach your VPS Check your UFW rules — make sure port 22 (or your custom SSH port) allows connections from GitHub Actions runner IP ranges. GitHub publishes these at https://api.github.com/meta.
What You End Up With
A git push to main that:
Runs your full test suite including real database integration tests
Builds a minimal, secure Docker image
Pushes it to GitHub's own container registry (free for public repos)
Deploys to your VPS and verifies it is healthy before marking success
Keeps secrets out of your repo, out of your image, and off your CI logs
Total external cost: your VPS. Everything else is free.
Comments (0)
Login to post a comment.