Back to blog
DevOps17 min readJune 24, 2026

CI-Built Deployments with GitHub Actions: Zero-Downtime Node.js Releases

L

Lineard Engineering

Software Engineering Studio

CI-Built Deployments with GitHub Actions: Zero-Downtime Node.js Releases

Building your application directly on the production server is one of the most common — and most expensive — mistakes in small-team DevOps. It bloats the disk with source code, dev dependencies, and build caches; it couples deploys to a fragile git pull; and a failed build can take your API offline. This guide walks through a leaner model: build once in GitHub Actions, ship only the compiled artifact and production dependencies to a single VPS, and deploy with zero downtime, health checks, and automatic rollback.

We'll start from an empty Node.js + TypeScript project, wire up TypeORM migrations, write the GitHub Actions workflow, and build the release and deploy scripts piece by piece. Every command is generic — substitute your own server IP, paths, and process names as you go.


The architecture

The core idea: the CI runner does all the heavy lifting (install, build, package), and the production server only ever receives a small tarball of compiled output plus a production-only dependency install.

deployment flow
GitHub Actions (CI)                    Production Server
─────────────────                      ─────────────────
npm ci                                 npm ci --omit=dev
npm run build        ──release.tgz──►  dist/ + assets + static
prepare-release.sh                     migrations (compiled)
                                       pm2 reload api

The server does not need:

  • A git checkout or git pull on deploy
  • TypeScript, ESLint, Jest, or any other devDependency
  • To run npm run build at all

The server does need:

  • Node.js 22+ (the runtime only)
  • PM2 as the process manager
  • A .env with database and service credentials — managed on the server and never included in the artifact
  • A persistent public/uploads/ directory for user content, preserved across deploys

Why this matters on a small VPS

A build-on-server setup keeps source, .git history, dev dependencies, and a build cache on disk — often several gigabytes. A CI artifact approach keeps only the compiled release plus production node_modules, which is the smallest practical footprint for a budget server.


Prerequisites

  • Server access: SSH into your production host (we'll use 203.0.113.10 as a placeholder)
  • A GitHub repository with Actions enabled
  • PM2 installed globally on the server
  • Node.js 22+ on the server (runtime and npm ci --omit=dev only)

Step 1: Set up the project

Start with a standard TypeScript API. The key is that your package.json exposes a clean set of scripts the CI pipeline can call: a build that emits to dist/, a type check, and a production migration command that runs against compiled JavaScript.

package.json
{
  "name": "my-api",
  "version": "1.0.0",
  "engines": { "node": ">=22" },
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "test": "jest",
    "start": "node dist/main.js",
    "db:migrate:prod": "typeorm migration:run -d dist/data-source.js"
  }
}

Notice db:migrate:prod points at dist/data-source.js, not a .ts file. On the server there is no TypeScript toolchain, so migrations must run from the compiled datasource. During development you'd use the TypeScript variant instead, as described in the TypeORM migrations docs.

The TypeORM datasource

Your data-source.ts compiles to dist/data-source.js and should reference compiled entity and migration globs so it resolves correctly in production.

src/data-source.ts
import "reflect-metadata";
import { DataSource } from "typeorm";

export const AppDataSource = new DataSource({
  type: "postgres",
  url: process.env.DATABASE_URL,
  // Point at compiled JS so the same config works in production.
  entities: ["dist/**/*.entity.js"],
  migrations: ["dist/migrations/*.js"],
  migrationsRun: false, // we run migrations explicitly during deploy
  synchronize: false,   // never use synchronize in production
});

Never use synchronize in production

synchronize: true can silently drop or alter columns. Always use explicit migrations for any environment that holds real data. See the TypeORM data source options.


Step 2: Generate an SSH key pair

GitHub Actions needs a dedicated key to reach your server. Generate one locally — keep the private half for GitHub, push the public half to the server.

terminal (local)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy

This creates github_actions_deploy (private) and github_actions_deploy.pub (public). Do not reuse your personal key — a dedicated deploy key can be rotated or revoked without affecting anything else.

Step 3: Add the public key to the server

terminal (local)
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub ubuntu@203.0.113.10

If ssh-copy-id isn't available, append the contents of the .pub file to ~/.ssh/authorized_keys on the server manually.

Step 4: Configure GitHub secrets

In your repository, go to Settings → Secrets and variables → Actions and add the following. Only the private key is strictly required.

SecretRequiredDescription
SSH_PRIVATE_KEYYesFull private key contents (with PEM headers)
SERVER_USERNoSSH user (default: ubuntu)
SERVER_HOSTYesServer IP or hostname
DEPLOY_PATHNoApp directory (default: /home/ubuntu/my-api)
MAIL_*NoOptional email notifications on failure

No repo access token needed

Because the server never pulls from git during a deploy, there is no need for a repository access token on the box. The artifact arrives over SSH and nothing else touches your source.


Step 5: Prepare the server (first time only)

terminal (server)
# Install Node.js 22 (runtime only — no build tools required)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

# Install PM2 globally
sudo npm install -g pm2
pm2 startup   # run the command it prints to enable boot persistence

# Create the deploy directory and the persistent uploads folder
mkdir -p ~/my-api/public/uploads
cd ~/my-api

# Create your .env here — it is never overwritten by a deploy
nano .env

Place .env before the first deploy

Your .env lives at $DEPLOY_PATH/.env and is symlinked into every release. The deploy never creates or overwrites it, so it must exist before your first deploy or migrations will fail to connect to the database.


Step 6: The release script

prepare-release.sh runs in CI after the build. It collects only what production needs into a single release.tgz — compiled code, runtime assets, the lockfile, and the server-side deploy script. Crucially, it excludes secrets and user-generated content.

scripts/prepare-release.sh
#!/usr/bin/env bash
set -euo pipefail

OUT="release.tgz"
STAGING="$(mktemp -d)"
trap 'rm -rf "$STAGING"' EXIT

echo "→ Collecting build output"
cp -r dist "$STAGING/"
[ -d assets ] && cp -r assets "$STAGING/"            # i18n / locale files

# Static files the app serves at runtime
mkdir -p "$STAGING/public"
for d in templates swagger; do
  [ -d "public/$d" ] && cp -r "public/$d" "$STAGING/public/"
done
[ -f public/logo.png ] && cp public/logo.png "$STAGING/public/"

# Manifest + lockfile so the server can install prod deps deterministically
cp package.json package-lock.json "$STAGING/"
[ -f .npmrc ] && cp .npmrc "$STAGING/"

# Ship the server-side apply script alongside the artifact
cp scripts/deploy-remote.sh "$STAGING/"

echo "→ Creating $OUT"
tar -czf "$OUT" -C "$STAGING" .
echo "✓ $OUT ready ($(du -h "$OUT" | cut -f1))"

The artifact deliberately excludes .env, public/uploads, and any scratch directories like public/temp. Those stay on the server and are linked into each release.


Step 7: The zero-downtime deploy script

deploy-remote.sh runs on the server. It stages the new release in its own directory, installs production dependencies, runs migrations, and only then swaps a symlink to make it live. If anything fails before the swap, the running API is untouched.

scripts/deploy-remote.sh
#!/usr/bin/env bash
set -euo pipefail

DEPLOY_PATH="${DEPLOY_PATH:?DEPLOY_PATH is required}"
ARTIFACT="${ARTIFACT:-/tmp/release.tgz}"
PM2_NAME="${PM2_NAME:-api}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/health}"

RELEASE_ID="$(date +%Y%m%d%H%M%S)-$RANDOM"
RELEASES_DIR="$DEPLOY_PATH/releases"
NEW_RELEASE="$RELEASES_DIR/$RELEASE_ID"

# Load Node (nvm or system install)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

mkdir -p "$NEW_RELEASE" "$DEPLOY_PATH/public/uploads"

echo "→ Extracting artifact"
tar -xzf "$ARTIFACT" -C "$NEW_RELEASE"

echo "→ Installing production dependencies"
( cd "$NEW_RELEASE" && npm ci --omit=dev )

echo "→ Linking shared, non-artifact files"
ln -sfn "$DEPLOY_PATH/.env" "$NEW_RELEASE/.env"
ln -sfn "$DEPLOY_PATH/public/uploads" "$NEW_RELEASE/public/uploads"

echo "→ Running database migrations"
if ! ( cd "$NEW_RELEASE" && npm run db:migrate:prod ); then
  echo "✗ Migration failed — current release left untouched"
  rm -rf "$NEW_RELEASE"
  exit 1
fi

# Promote: remember the old release as 'previous', point 'current' at the new one
if [ -L "$DEPLOY_PATH/current" ]; then
  ln -sfn "$(readlink -f "$DEPLOY_PATH/current")" "$DEPLOY_PATH/previous"
fi
ln -sfn "$NEW_RELEASE" "$DEPLOY_PATH/current"

echo "→ Reloading PM2"
cd "$DEPLOY_PATH/current"
pm2 reload "$PM2_NAME" --update-env || pm2 start dist/main.js --name "$PM2_NAME"

echo "→ Health check"
ok=0
for _ in $(seq 1 36); do          # 36 attempts × 5s = up to 3 minutes
  if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then ok=1; break; fi
  sleep 5
done

if [ "$ok" -ne 1 ]; then
  echo "✗ Health check failed — rolling back to previous"
  if [ -L "$DEPLOY_PATH/previous" ]; then
    ln -sfn "$(readlink -f "$DEPLOY_PATH/previous")" "$DEPLOY_PATH/current"
    ( cd "$DEPLOY_PATH/current" && pm2 reload "$PM2_NAME" --update-env )
  fi
  rm -rf "$NEW_RELEASE"
  exit 1
fi

echo "→ Pruning old releases (keep last 3 + current + previous)"
current="$(basename "$(readlink -f "$DEPLOY_PATH/current")")"
previous="$(basename "$(readlink -f "$DEPLOY_PATH/previous" 2>/dev/null || echo none)")"
cd "$RELEASES_DIR"
ls -1dt */ | tail -n +4 | while read -r dir; do
  name="$(basename "$dir")"
  [ "$name" != "$current" ] && [ "$name" != "$previous" ] && rm -rf "$name"
done

echo "✓ Deployed $RELEASE_ID"

About the health check window

The loop polls GET /health every 5 seconds for up to 3 minutes (36 attempts). Most APIs bind their HTTP port shortly after the database connects, while background work like permission syncing or cron registration finishes afterward — so a generous window avoids false rollbacks on a cold start.


Step 8: The GitHub Actions workflow

Now we tie it together. The workflow has two jobs: test gates everything, and build-and-deploy compiles, packages, uploads, and applies the release over SSH.

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [master]
  workflow_dispatch: {}

concurrency:
  group: production-deploy
  cancel-in-progress: false   # never interrupt an in-flight deploy

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm test --if-present

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    env:
      SERVER_USER: ${{ secrets.SERVER_USER || 'ubuntu' }}
      SERVER_HOST: ${{ secrets.SERVER_HOST }}
      DEPLOY_PATH: ${{ secrets.DEPLOY_PATH || '/home/ubuntu/my-api' }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Package release
        run: bash scripts/prepare-release.sh

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts

      - name: Upload artifact
        run: |
          scp -i ~/.ssh/deploy_key release.tgz \
            "$SERVER_USER@$SERVER_HOST:/tmp/release.tgz"

      - name: Apply release
        run: |
          ssh -i ~/.ssh/deploy_key "$SERVER_USER@$SERVER_HOST" \
            "DEPLOY_PATH='$DEPLOY_PATH' ARTIFACT=/tmp/release.tgz bash -s" \
            < scripts/deploy-remote.sh

The last step pipes the repo's deploy-remote.sh straight into a remote shell with bash -s, so the server always runs the exact script from the commit being deployed — no stale copy to maintain.

Verifying locally before you push

terminal (local)
npm ci
npm run build
bash scripts/prepare-release.sh
# release.tgz is now ready for a manual scp + deploy-remote.sh run

Zero-downtime, rollback, and the server layout

A failed deploy never takes the API down, because new releases are staged under releases/<id>/ and only promoted after migrations succeed. The live version is whatever the current symlink points to.

~/my-api (server layout)
~/my-api/
  .env                          ← shared config (never overwritten)
  current  → releases/<id>/     ← live release (PM2 runs current/dist/main.js)
  previous → releases/<id>/     ← last good release (rollback target)
  releases/
    20260624120000-12345/
      dist/
      node_modules/
      assets/
      .env          → ../../.env
      public/uploads → ../../public/uploads
  public/uploads/               ← shared user content
  deploy-remote.sh
  rollback-remote.sh            ← manual rollback script

The deploy flow, step by step

  • Extract the CI artifact into a fresh staging dir releases/<new-id>/
  • Run npm ci --omit=dev in staging only
  • Run migrations from staging
  • If migrations fail → delete staging, leave current untouched, PM2 keeps serving the old build
  • If migrations succeed → save old current as previous, swap the symlink, pm2 reload
  • If the health check fails → auto-rollback the symlink to previous and reload PM2
  • Prune releases beyond the last 3 (always keeping current + previous)

Failure modes at a glance

Failure pointAPI statusDisk state
npm ciUpStaging dir removed on exit
Migration failsUpStaging dir removed
Health check failsUp (auto-rollback)Failed release removed
Deploy succeedsUp (new release)Old release kept as previous

Manual rollback

When you need to revert by hand, a tiny script flips the symlink back.

scripts/rollback-remote.sh
#!/usr/bin/env bash
set -euo pipefail

DEPLOY_PATH="${DEPLOY_PATH:?DEPLOY_PATH is required}"
PM2_NAME="${PM2_NAME:-api}"
TARGET="${1:-}"   # optional: a specific releases/<id> path

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

# Default to the recorded 'previous' release
[ -z "$TARGET" ] && TARGET="$(readlink -f "$DEPLOY_PATH/previous")"
[ -d "$TARGET" ] || { echo "✗ Rollback target not found: $TARGET"; exit 1; }

echo "→ Rolling back to $TARGET"
ln -sfn "$(readlink -f "$DEPLOY_PATH/current")" "$DEPLOY_PATH/previous"
ln -sfn "$TARGET" "$DEPLOY_PATH/current"
( cd "$DEPLOY_PATH/current" && pm2 reload "$PM2_NAME" --update-env )
echo "✓ Rolled back"
terminal (server)
# Roll back to the previous release
DEPLOY_PATH=~/my-api bash ~/my-api/rollback-remote.sh

# Or roll back to a specific release
DEPLOY_PATH=~/my-api bash ~/my-api/rollback-remote.sh releases/20260624120000-12345

Do you need Docker? Probably not

On a single small VPS, Docker adds image layers and daemon overhead — often 1–3 GB or more — which works against limited disk and budget. The CI-artifact-plus-PM2 path is leaner and simpler for one server.

ApproachTypical disk on serverRuntime
Git pull + build + all depsLargest (src, .git, devDeps, build cache)PM2
CI artifact + prod depsSmallest practicalPM2
Docker imageExtra image layers + container FSPM2 or orchestrator

If you later move to Kubernetes or ECS, reach for a Dockerfile then — but skip the container overhead while you're on a single box.


One-time server cleanup

If you're migrating from an old build-on-server workflow, reclaim the space it left behind after your first CI deploy succeeds.

scripts/server-cleanup.sh
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:?DEPLOY_PATH is required}"

echo "→ Removing source, git history, and build caches"
rm -rf "$DEPLOY_PATH/src" "$DEPLOY_PATH/.git" "$DEPLOY_PATH/.turbo" "$DEPLOY_PATH/coverage"

echo "→ Cleaning npm cache"
npm cache clean --force || true

echo "→ Setting up PM2 log rotation so logs don't fill the disk"
pm2 install pm2-logrotate || true
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7

echo "✓ Cleanup complete"
terminal (local)
# Copy the script up, then run it on the server
scp scripts/server-cleanup.sh ubuntu@203.0.113.10:~/
ssh ubuntu@203.0.113.10 'DEPLOY_PATH=~/my-api bash ~/server-cleanup.sh'

Troubleshooting

SSH failures

  • Verify SSH_PRIVATE_KEY includes the full PEM headers and footers
  • Confirm the public key is in ~/.ssh/authorized_keys on the server
  • Check that ssh-keyscan populated known_hosts for your host

Migration failures

  • Check the database credentials in the server's .env
  • Migrations run via npm run db:migrate:prod against the compiled dist/data-source.js
  • Inspect logs with pm2 logs api

PM2 issues

terminal (server)
pm2 status
pm2 logs api --lines 50
pm2 restart api

Build failures in CI

  • Reproduce TypeScript errors locally with npm run typecheck
  • Ensure package-lock.json is committed and in sync

Security notes

  • Never commit .env, SSH keys, or service-account JSON
  • Rotate the deploy SSH key periodically
  • Use a dedicated deploy user with limited sudo where possible
  • The production artifact contains no secrets — only compiled code and the lockfile

The payoff

Builds become reproducible and isolated, the server stays lean, and a bad release can never take you offline — it either fails safe before the swap or auto-rolls back after it. That's a deployment pipeline you can trust on a single, inexpensive VPS.

GitHub ActionsCI/CDNode.jsPM2TypeORMDeploymentZero-downtime

Need a hand shipping this?

We design and build production deployment pipelines, backends, and payment systems. Let's talk about your setup.

Book a free intro call