Getting a Node.js app to deploy automatically is the easy part. Getting it to deploy safely — with the right secrets per environment, database migrations that won't nuke production data, and an automatic escape hatch when something goes wrong — that is where most tutorials tap out. This guide picks up exactly there.
What We Are Actually Building
A GitHub Actions workflow that:
- Runs tests and linting on every pull request
- Deploys to a staging environment on merge to
develop - Deploys to production on merge to
main, with a pre-deployment migration step - Runs a health check post-deploy and rolls back automatically on failure
We will use a typical Node.js + PostgreSQL stack, but the patterns apply to any database-backed service.
Step 1: Repository Structure and Environment Setup
Before writing a single line of YAML, get your GitHub repository wired up correctly.
Go to Settings → Environments and create two environments: staging and production. GitHub Environments let you scope secrets and add protection rules (like required reviewers before a production deploy — highly recommended).
Add the following secrets to each environment:
APP_HOST— the server IP or hostnameDB_URL— the full database connection stringSSH_PRIVATE_KEY— a deploy key with access to your serverHEALTH_CHECK_URL— the endpoint to probe after deployment
Using environment-scoped secrets means your staging DB_URL can never leak into a production workflow step. This is not just good practice — it is the difference between a recoverable mistake and a catastrophic one.
Step 2: The Base Workflow File
Create .github/workflows/deploy.yml. Start with the trigger and job matrix:
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
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
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
deploy:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
Two things worth noting here. First, the services block spins up a real Postgres container for your test job — no mocking, no SQLite shims. If your queries work here, they will work in production. Second, the environment expression dynamically selects production or staging based on the branch, so the correct scoped secrets are injected automatically.
Step 3: Running Database Migrations Safely
This is where most pipelines cut corners. Running migrations mid-deploy, without a strategy, is how you get a 2 AM incident.
The safe pattern is: migrate first, deploy second. Add this as a dedicated step before the deployment step:
- name: Run database migrations
run: |
npm run migrate:latest
env:
DATABASE_URL: ${{ secrets.DB_URL }}
The critical constraint is that your migrations must be backward compatible with the current running version of the code. That means:
- Never drop a column in the same migration that removes it from code. Drop it in a follow-up migration after the new code is fully deployed and stable.
- Always add columns as nullable first, then backfill, then add constraints.
- Use a migration tool like
node-postgres-migrate,db-migrate, or Knex migrations with a lock table so concurrent deploys cannot run migrations simultaneously.
If the migration step fails, the workflow halts before any code hits your servers. That single constraint saves enormous pain.
Step 4: The Deployment Step
For a VM-based deployment (DigitalOcean, AWS EC2, Hetzner), use SSH to pull and restart:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.APP_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
git pull origin ${{ github.ref_name }}
npm ci --omit=dev
pm2 reload ecosystem.config.js --env ${{ github.ref_name == 'main' && 'production' || 'staging' }}
For containerised workloads, swap the SSH script for a docker pull + docker compose up -d or a Kubernetes rollout command. The surrounding workflow structure stays identical.
Step 5: Automatic Rollback on Failed Health Checks
This is the step that makes the pipeline genuinely production-ready. After deployment, probe your health endpoint and roll back if it is not responding correctly:
- name: Health check and rollback on failure
run: |
echo "Waiting for app to stabilise..."
sleep 15
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.HEALTH_CHECK_URL }})
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS. Initiating rollback..."
ssh deploy@${{ secrets.APP_HOST }} "cd /var/www/myapp && git checkout HEAD~1 && npm ci --omit=dev && pm2 reload ecosystem.config.js"
exit 1
fi
echo "Health check passed. Deploy successful."
Your /health endpoint should verify more than just HTTP reachability. It should confirm database connectivity, check any critical third-party integrations, and return a non-200 status if anything is degraded. A shallow health check that only confirms the process is running will let broken deploys slip through.
Environment-Specific Configuration Beyond Secrets
Secrets handle credentials, but environment-specific configuration — feature flags, log levels, API base URLs — should live in your deployment environment's process manager config (ecosystem.config.js for PM2, environment-specific .env files loaded at startup, or a secrets manager like AWS SSM Parameter Store).
Do not bake environment differences into your workflow YAML beyond what is strictly necessary for routing. Keep the pipeline thin; keep the environment smart.
Protecting Production With Branch Rules
The workflow is only as safe as your branch protection rules. In GitHub, under Settings → Branches, enforce the following for main:
- Require pull request reviews before merging
- Require status checks to pass (specifically the
testjob) - Require the
productionenvironment's approval gate before deploy jobs run
These rules mean no one — not even a repo admin — can push untested code directly to production.
Why This Matters for Your Project
A CI/CD pipeline that stops at "it deploys" is a liability dressed up as automation. Wiring in environment-scoped secrets prevents credential leakage across stages. Decoupling migrations from code deployment means your database and application stay in sync without downtime. Automatic rollbacks compress your mean time to recovery from "someone notices and manually intervenes" to under two minutes. Whether you are shipping a SaaS MVP or scaling a platform to thousands of users, these patterns are the foundation that lets your team deploy multiple times a day — with confidence instead of dread.





