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.
Table of Contents
- Architecture Overview
- VM Setup
- Create the VM
- Install dependencies
- Add swap
- Next.js Standalone Build
- systemd Service
- The `%` escaping trap
- Sudoers for CI/CD
- Nginx Configuration
- GitHub Actions CI/CD
- CI — runs on every PR
- Production deploy — runs on merge to `main`
- The Production Deploy Script
- Critical ordering lesson
- Reading DATABASE_URL from the systemd unit file
- The `/api/health` Endpoint
- Production Startup Validation
- The env var debugging loop
- Required env vars checklist for production
- Idempotent Database Migrations
- Staging vs Production
- Debugging Production Issues
- SSH into the VM
- View live logs
- Check health manually
- Verify env vars are loaded
- Common issues and fixes
- Final Thoughts
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:
- Checks broadcast sending is disabled (safety gate before migrations)
- Stops the service and confirms it's down
- Runs database migrations
- Checks for active DB claims (safe to proceed)
- Starts the service
- Polls
/api/healthuntil the app is ready - 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 VMs —
npm ciwill 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
How to Migrate an RDS Database Between AWS Accounts
Moving your PostgreSQL or MySQL database to a new AWS account? Here's the complete snapshot-based migration process, including encrypted snapshots, KMS key sharing, and cleanup to stop charges.
Read more →How to Migrate S3 Buckets Between AWS Accounts in 5 Minutes
Moving to a new AWS account? Here's how to migrate your S3 bucket data without losing a single file. Simple, fast, and free.
Read more →AWS Spot Instances: Run Your Staging Server for $2/Month
Stop paying $50+/month for staging servers. Here's how to set up a complete staging environment with AWS Spot Instances, auto-deploy via GitHub Actions, and free SSL.
Read more →