CI-Built Deployments with GitHub Actions: Zero-Downtime Node.js Releases
Lineard Engineering
Software Engineering Studio

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.
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 apiThe server does not need:
- A git checkout or
git pullon deploy - TypeScript, ESLint, Jest, or any other devDependency
- To run
npm run buildat all
The server does need:
- Node.js 22+ (the runtime only)
- PM2 as the process manager
- A
.envwith 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.10as 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=devonly)
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.
{
"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.
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.
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deployThis 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
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub ubuntu@203.0.113.10If 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.
| Secret | Required | Description |
|---|---|---|
SSH_PRIVATE_KEY | Yes | Full private key contents (with PEM headers) |
SERVER_USER | No | SSH user (default: ubuntu) |
SERVER_HOST | Yes | Server IP or hostname |
DEPLOY_PATH | No | App directory (default: /home/ubuntu/my-api) |
MAIL_* | No | Optional 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)
# 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 .envPlace .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.
#!/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.
#!/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.
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.shThe 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
npm ci
npm run build
bash scripts/prepare-release.sh
# release.tgz is now ready for a manual scp + deploy-remote.sh runZero-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/
.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 scriptThe deploy flow, step by step
- Extract the CI artifact into a fresh staging dir
releases/<new-id>/ - Run
npm ci --omit=devin staging only - Run migrations from staging
- If migrations fail → delete staging, leave
currentuntouched, PM2 keeps serving the old build - If migrations succeed → save old
currentasprevious, swap the symlink,pm2 reload - If the health check fails → auto-rollback the symlink to
previousand reload PM2 - Prune releases beyond the last 3 (always keeping
current+previous)
Failure modes at a glance
| Failure point | API status | Disk state |
|---|---|---|
npm ci | Up | Staging dir removed on exit |
| Migration fails | Up | Staging dir removed |
| Health check fails | Up (auto-rollback) | Failed release removed |
| Deploy succeeds | Up (new release) | Old release kept as previous |
Manual rollback
When you need to revert by hand, a tiny script flips the symlink back.
#!/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"# 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-12345Do 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.
| Approach | Typical disk on server | Runtime |
|---|---|---|
| Git pull + build + all deps | Largest (src, .git, devDeps, build cache) | PM2 |
| CI artifact + prod deps | Smallest practical | PM2 |
| Docker image | Extra image layers + container FS | PM2 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.
#!/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"# 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_KEYincludes the full PEM headers and footers - Confirm the public key is in
~/.ssh/authorized_keyson the server - Check that
ssh-keyscanpopulatedknown_hostsfor your host
Migration failures
- Check the database credentials in the server's
.env - Migrations run via
npm run db:migrate:prodagainst the compileddist/data-source.js - Inspect logs with
pm2 logs api
PM2 issues
pm2 status
pm2 logs api --lines 50
pm2 restart apiBuild failures in CI
- Reproduce TypeScript errors locally with
npm run typecheck - Ensure
package-lock.jsonis 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.
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