← Back to Developer Blog
💻 DeveloperJuly 24, 202614 min read

Deploying Next.js to a GCP VM with CI/CD, PostgreSQL, and Zero-Downtime Migrations

A real-world walkthrough of deploying a Next.js 16 app to Google Cloud Platform using a VM, systemd, Nginx, GitHub Actions CI/CD, Cloud SQL PostgreSQL, and a production-hardened deploy script.

By Raspib Technology

Deploying Next.js to a GCP VM with CI/CD, PostgreSQL, and Zero-Downtime Migrations

Vercel is great — until it isn't. When you need full control over your server, custom background jobs, WebSocket support, or you simply want to avoid vendor lock-in, deploying to a VM is the right call.

This is a real-world walkthrough of how we deployed a Next.js 16 app to Google Cloud Platform using Compute Engine, systemd, Nginx, GitHub Actions, and Cloud SQL PostgreSQL. We'll cover every issue we hit along the way — including the ones that took hours to debug.

Architecture Overview

GitHub → GitHub Actions CI/CD
           ↓
    GCP Compute Engine VM (e2-small)
    ├── Nginx (reverse proxy + SSL)
    ├── Node.js (Next.js standalone server)
    ├── systemd (process management)
    └── Cloud SQL PostgreSQL (managed DB)

Two environments:

Environment VM Database Domain Branch
Production e2-small, 2GB RAM Cloud SQL PostgreSQL www.yourapp.com main
Staging e2-small, 2GB RAM PostgreSQL on VM staging.yourapp.com staging

Total cost: ~$33–36/month.

VM Setup

Create the VM

gcloud compute instances create your-app-vm \
  --zone=europe-west2-a \
  --machine-type=e2-small \
  --image-family=ubuntu-2404-lts \
  --image-project=ubuntu-os-cloud \
  --boot-disk-size=20GB \
  --tags=http-server,https-server

Reserve a static IP so your DNS doesn't break on VM restarts:

gcloud compute addresses create your-app-ip --region=europe-west2
gcloud compute instances delete-access-config your-app-vm --access-config-name="External NAT" --zone=europe-west2-a
gcloud compute instances add-access-config your-app-vm \
  --access-config-name="External NAT" \
  --address=$(gcloud compute addresses describe your-app-ip --region=europe-west2 --format='value(address)') \
  --zone=europe-west2-a

Install dependencies

# Node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

# Nginx + Certbot
sudo apt-get install -y nginx certbot python3-certbot-nginx

Add swap (critical for e2-small)

Without swap, npm ci will OOM-kill on a 2GB VM:

sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Next.js Standalone Build

In next.config.ts, enable standalone output:

const nextConfig = {
  output: 'standalone',
}
export default nextConfig

This produces .next/standalone/server.js — a self-contained Node server with no node_modules needed at runtime. After building, copy static assets:

npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public

systemd Service

systemd manages the Node process — restarts it on crash, starts it on boot.

File: /etc/systemd/system/your-app.service

[Unit]
Description=Your App Next.js
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/your-app
ExecStart=/usr/bin/node .next/standalone/server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=DATABASE_URL=postgresql://user:password@host/dbname
Environment=ADMIN_SESSION_SECRET=<min-32-chars>
Environment=UNSUBSCRIBE_CSRF_SECRET=<min-32-chars>
Environment=TRUST_PROXY=true
Environment=TRUSTED_CLIENT_IP_HEADER=x-real-ip
Environment=RATE_LIMIT_SALT=<min-32-chars>
Environment=ENABLE_BROADCAST_SEND=false

[Install]
WantedBy=multi-user.target

The `%` escaping trap

If your DATABASE_URL contains %40 (URL-encoded @), systemd will interpret % as a unit specifier and silently mangle the value. Use %% to get a literal %:

# Wrong — systemd eats the %40
Environment=DATABASE_URL=postgresql://user%40host/db

# Correct
Environment=DATABASE_URL=postgresql://user%%40host/db

After editing the service file:

sudo systemctl daemon-reload
sudo systemctl restart your-app
sudo systemctl enable your-app

Sudoers for CI/CD

Your deploy user needs to run systemctl without a password prompt (GitHub Actions can't type passwords):

# /etc/sudoers.d/your-app-deploy
deploy ALL=(ALL) NOPASSWD: /bin/systemctl start your-app, /bin/systemctl stop your-app, /bin/systemctl restart your-app, /bin/systemctl daemon-reload

Nginx Configuration

server {
    listen 80;
    server_name www.yourapp.com;

    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;
    }
}

Then SSL via Let's Encrypt:

sudo certbot --nginx -d www.yourapp.com -d yourapp.com --non-interactive --agree-tos -m you@yourapp.com

Watch out: If you write Nginx config via SSH heredocs on Windows (using plink), $ gets escaped as \$. Always verify the config with sudo nginx -t and fix any \$ manually with nano.

GitHub Actions CI/CD

CI — runs on every PR

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main, staging, dev]

jobs:
  ci:
    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 lint
      - run: npm run typecheck
      - run: npm test
      - run: npm audit --audit-level=high
      - run: npm run build

Production deploy — runs on merge to `main`

# .github/workflows/deploy-production.yml
name: Deploy to Production
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VM_HOST }}
          username: ${{ secrets.VM_USER }}
          key: ${{ secrets.VM_SSH_KEY }}
          script: |
            set -Eeuo pipefail
            cd /var/www/your-app
            PREVIOUS_COMMIT="$(git rev-parse HEAD)"
            git fetch --all
            git reset --hard origin/main
            npm ci
            NEXT_PUBLIC_SITE_URL=https://www.yourapp.com \
              NODE_OPTIONS=--max-old-space-size=1536 \
              npm run build
            cp -r .next/static .next/standalone/.next/static
            cp -r public .next/standalone/public
            chmod +x scripts/deploy-production.sh
            DEPLOY_PREVIOUS_COMMIT="$PREVIOUS_COMMIT" ./scripts/deploy-production.sh

The Production Deploy Script

This is where the real safety lives. The script:

  1. Checks broadcast sending is disabled (safety gate before migrations)
  2. Stops the service and confirms it's down
  3. Runs database migrations
  4. Checks for active DB claims (safe to proceed)
  5. Starts the service
  6. Polls /api/health until the app is ready
  7. Rolls back (restarts old process) on any failure
#!/usr/bin/env bash
set -Eeuo pipefail

APP_ROOT="${APP_ROOT:-/var/www/your-app}"
SERVICE_NAME="${SERVICE_NAME:-your-app}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/health}"
HEALTH_ATTEMPTS="${HEALTH_ATTEMPTS:-10}"
HEALTH_SLEEP_SECS="${HEALTH_SLEEP_SECS:-3}"

require_broadcast_disabled() {
  local value
  value="$(sudo systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null \
    | tr ' ' '\n' \
    | grep '^ENABLE_BROADCAST_SEND=' \
    | tail -n1 \
    | cut -d= -f2-)" || true
  value="${value#\"}" value="${value%\"}"
  case "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" in
    ""|false|0|no) log "Broadcast send preflight: disabled" ;;
    true|1|yes) die "ENABLE_BROADCAST_SEND is enabled. Disable before migration." ;;
    *) die "Unrecognised ENABLE_BROADCAST_SEND value" ;;
  esac
}

run_migrations() {
  log "Applying schema.sql..."
  "$PSQL" "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "${APP_ROOT}/db/schema.sql"
  log "Applying migrations/latest.sql..."
  "$PSQL" "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "${APP_ROOT}/db/migrations/latest.sql"
}

poll_health() {
  local i body
  for i in $(seq 1 "$HEALTH_ATTEMPTS"); do
    body="$("$CURL" -fsS "$HEALTH_URL" 2>/dev/null || true)"
    log "health_probe attempt=${i} response=${body}"
    if printf '%s' "$body" | grep -q '"status":"ok"' \
      && printf '%s' "$body" | grep -q '"storageAvailable":true'; then
      return 0
    fi
    sleep "$HEALTH_SLEEP_SECS"
  done
  sudo journalctl -u "$SERVICE_NAME" -n 40 --no-pager 2>/dev/null || true
  return 1
}

main() {
  cd "$APP_ROOT"
  require_broadcast_disabled
  load_database_url
  stop_service
  RECOVERY_ENABLED=1
  run_migrations          # migrations BEFORE claim check
  count_active_claims     # claim check AFTER migrations (columns exist)
  start_service
  poll_health || die "Readiness failed"
  log "Deploy succeeded"
}

Critical ordering lesson

We hit this bug: the deploy script ran count_active_claims (which queries lease_expires_at) before run_migrations added that column. The fix is simple — always run migrations first, then query the new schema.

# Wrong order
stop_service
count_active_claims   # ❌ column doesn't exist yet
run_migrations
start_service

# Correct order
stop_service
run_migrations        # ✅ schema is up to date
count_active_claims   # ✅ column exists now
start_service

Reading DATABASE_URL from the systemd unit file

The deploy script needs the DB connection string to run migrations, but it shouldn't be hardcoded in the script. We read it from the unit file:

load_database_url() {
  local line
  line="$(grep -E '^Environment=DATABASE_URL=' "$UNIT_FILE" | tail -n1 || true)"
  DATABASE_URL="${line#Environment=DATABASE_URL=}"
  DATABASE_URL="${DATABASE_URL#\"}" DATABASE_URL="${DATABASE_URL%\"}"
  # Unescape systemd %% → % so psql gets the real connection string
  DATABASE_URL="${DATABASE_URL//%%/%}"
  export DATABASE_URL
}

Note the %%% unescaping — systemd stores %%40 but psql needs %40.

The `/api/health` Endpoint

Every production app should have a health endpoint. Ours checks DB connectivity:

// src/app/api/health/route.ts
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET() {
  const storage = await getWaitlistStorage()
  const available = await storage.ping()
  return NextResponse.json(
    { status: available ? 'ok' : 'unhealthy', storageAvailable: available },
    { status: available ? 200 : 503 }
  )
}

The deploy script polls this until it returns {"status":"ok","storageAvailable":true} or times out and triggers rollback.

Production Startup Validation

We use Next.js instrumentation.ts to fail fast on missing config:

// src/instrumentation.ts
export async function register() {
  if (process.env.NODE_ENV !== 'production') return

  const { assertAdminSessionSecretConfigured } = await import('@/lib/admin/auth')
  const { assertUnsubscribeCsrfConfigured } = await import('@/lib/unsubscribe/csrf')
  const { assertProductionStorageSafe } = await import('@/lib/waitlist/config')
  const { assertTrustedProxyConfigured, assertRateLimitSaltConfigured } = await import('@/lib/request')

  assertAdminSessionSecretConfigured()
  assertUnsubscribeCsrfConfigured()
  assertProductionStorageSafe()
  assertTrustedProxyConfigured()
  assertRateLimitSaltConfigured()
}

If any required env var is missing, the app refuses to serve requests. This is intentional — a half-configured app is worse than a down app.

The env var debugging loop

When we first deployed, the app started but /api/health returned nothing. The health probe showed empty responses. We added journalctl output to the deploy script on failure:

log "--- last 40 service log lines ---"
sudo journalctl -u "$SERVICE_NAME" -n 40 --no-pager 2>/dev/null || true

This revealed the actual error immediately:

Error: An error occurred while loading instrumentation hook:
UNSUBSCRIBE_CSRF_SECRET must be set to a strong value (≥32 characters) in production.

Then after fixing that, the next deploy showed:

Error: TRUST_PROXY=true is required in production

The lesson: always dump service logs on health check failure. Without this, you're blind.

Required env vars checklist for production

These are the vars our startup assertions require — missing any one of them crashes the app silently:

Variable Requirement
DATABASE_URL Must be set with valid Postgres connection string
ADMIN_SESSION_SECRET ≥32 characters
UNSUBSCRIBE_CSRF_SECRET ≥32 characters
TRUST_PROXY Must be true
TRUSTED_CLIENT_IP_HEADER Must be x-real-ip or x-forwarded-for
RATE_LIMIT_SALT ≥32 characters

Generate strong secrets:

openssl rand -hex 32

Idempotent Database Migrations

All our migration files are ledger-guarded — they check a schema_migrations table before running:

-- db/migrations/latest.sql
\ir 20260720_add_broadcast_columns.sql

-- Inside each migration file:
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM schema_migrations WHERE name = '20260720_add_broadcast_columns') THEN
    RAISE NOTICE 'Migration already applied — skipping.';
    RETURN;
  END IF;

  ALTER TABLE broadcast_recipients ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
  ALTER TABLE broadcast_recipients ADD COLUMN IF NOT EXISTS claim_token uuid;

  INSERT INTO schema_migrations (name) VALUES ('20260720_add_broadcast_columns');
END $$;

This means running migrations on every deploy is safe — already-applied migrations just print "skipping" and move on. No risk of double-applying.

Staging vs Production

We run two environments from the same codebase. Staging uses a simpler deploy (no safety gates, no health check polling):

# deploy-staging.yml
script: |
  cd /var/www/your-app-staging
  git reset --hard origin/staging
  npm ci
  npm run build
  cp -r .next/static .next/standalone/.next/static
  cp -r public .next/standalone/public
  PGPASSWORD='${{ secrets.STAGING_DB_PASSWORD }}' psql \
    -h localhost -U ${{ secrets.STAGING_DB_USER }} \
    -d ${{ secrets.STAGING_DB_NAME }} \
    -f db/schema.sql
  PGPASSWORD='${{ secrets.STAGING_DB_PASSWORD }}' psql \
    -h localhost -U ${{ secrets.STAGING_DB_USER }} \
    -d ${{ secrets.STAGING_DB_NAME }} \
    -f db/migrations/latest.sql
  sudo systemctl restart your-app-staging

Staging DB credentials go in GitHub Secrets — never hardcoded in the workflow file.

Debugging Production Issues

SSH into the VM

gcloud compute ssh your-app-vm --zone=europe-west2-a
# Or via IAP tunnel if direct SSH is blocked:
gcloud compute ssh your-app-vm --zone=europe-west2-a --tunnel-through-iap

View live logs

sudo journalctl -u your-app -f
sudo journalctl -u your-app -n 50 --no-pager

Check health manually

curl -s http://127.0.0.1:3000/api/health

Verify env vars are loaded

sudo systemctl show your-app --property=Environment

Common issues and fixes

Symptom Cause Fix
Health probe returns empty App crashing on startup Check journalctl for instrumentation errors
%40 in DB URL ignored systemd % specifier Use %%40 in service file
systemctl: Interactive authentication required Missing sudoers entry Add /etc/sudoers.d/your-app-deploy
502 Bad Gateway Nginx \$ escaping Edit nginx config, replace \$ with $
npm ci OOM killed No swap on VM Add 1GB swap
Migration column error Claim check before migration Run migrations before querying new columns

Final Thoughts

Deploying to a VM gives you full control but requires you to handle things Vercel does automatically — process management, SSL, zero-downtime deploys, and health monitoring.

The patterns that matter most:

  • Fail fast on missing config — startup assertions catch misconfiguration before users do
  • Migrations before queries — never query columns that don't exist yet
  • Always dump logs on failure — blind health check failures waste hours
  • Idempotent migrations — safe to run on every deploy
  • Swap on small VMsnpm ci will OOM without it

The setup takes a day to get right. After that, deploys are one git push away.


About Raspib Technology

We build and deploy production web applications for businesses across Nigeria and beyond. From Next.js to Laravel, GCP to AWS — we handle the full stack.

📞 +234 905 162 3555 📧 info@raspibtech.com 🌐 raspibtech.com

RC: 8957098 | DUNS: 669824701

Need Help with Your Project?

Let's discuss how Raspib Technology can help transform your business

Related Articles