A pipeline is not just “automatic deployment”. It is to reduce risk per release and improve recovery time when something goes wrong.

##Pipeline goal

  • reproducible build,
  • automatic validation,
  • predictable deployment,
  • clear rollback.
  1. Push to main.
  2. Build + checks.
  3. Deploy via SSH to VPS.
  4. Restart controlled with PM2.
  5. Health checks and basic monitoring.

Base workflow in GitHub Actions

Minimal example to build + deploy via SSH when there is a push to main:

name: ci-cd-vps

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install
        run: npm ci

      - name: Build
        run: npm run build

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            /var/www/app/scripts/deploy.sh

VPS deploy script

Separating the deploy into a script avoids long commands and reduces shell errors within the workflow:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/app"
RELEASES_DIR="$APP_DIR/releases"
CURRENT_LINK="$APP_DIR/current"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
NEW_RELEASE="$RELEASES_DIR/$TIMESTAMP"

mkdir -p "$NEW_RELEASE"
git -C "$APP_DIR" fetch origin main
git -C "$APP_DIR" archive origin/main | tar -x -C "$NEW_RELEASE"

cp "$APP_DIR/shared/.env" "$NEW_RELEASE/.env"
npm --prefix "$NEW_RELEASE" ci --omit=dev
npm --prefix "$NEW_RELEASE" run build

ln -sfn "$NEW_RELEASE" "$CURRENT_LINK"
pm2 startOrReload "$APP_DIR/shared/ecosystem.config.js" --update-env

##Smoke test post deploy

A simple check avoids leaving broken release as “success”:

#!/usr/bin/env bash
set -euo pipefail

URL="https://tu-dominio.com/health"

for i in {1..10}; do
  if curl -fsS "$URL" >/dev/null; then
    echo "health ok"
    exit 0
  fi
  sleep 3
done

echo "health failed"
exit 1

Non-negotiable security points

  • secrets only on GitHub Secrets,
  • SSH keys with minimum permissions,
  • periodic key rotation,
  • active security headers in app and nginx.

Typical errors in CI/CD VPS

  • .env bad generated by heredoc indentation,
  • dependency installed on runner but not on server,
  • restart without checking health,
  • deploy that steps on artifacts without versioning.

Secure Deploy Checklist

  • clean build
  • quick backup of previous release
  • supported migrations
  • restart controlled
  • smoke test of critical paths

Happy reading! ☕

Comments