Introduction
You push to main, and seconds later your Next.js app is live — no manual SSH, no npm run build on a sluggish VPS, no downtime. That's what a CI/CD pipeline built with GitHub Actions gives you, and in 2026 it's table stakes for any serious engineer.
This guide walks you through building a production-grade GitHub Actions pipeline that builds, tests, and deploys a Next.js application to a bare-metal VPS (DigitalOcean, Linode, Hetzner, or your own Ubuntu box). No Vercel, no Netlify, no managed platforms — just you, your VPS, and full control.
By the end, you'll have:
- A Next.js project with an optimized production build
- A GitHub Actions workflow that triggers on every push to
main - Automatic deployment to your VPS via SSH with zero-downtime PM2 cluster reloads
- Rollback capability if something breaks
Prerequisites
Before you start, make sure you have:
| Requirement | Details |
|---|---|
| VPS | Ubuntu 22.04 or 24.04, at least 1 GB RAM, public IP |
| Domain | Pointed at your VPS (optional — IP works too) |
| GitHub repo | Private or public, containing your Next.js project |
| Node.js 20+ | Installed on both local and VPS |
| PM2 | Process manager on the VPS |
| Nginx | Reverse proxy on the VPS |
| SSH access | Key-based SSH to your VPS |
VPS Initial Setup (Run Once)
# SSH into your VPS
ssh root@your-vps-ip
# Update packages
apt update && apt upgrade -y
# Install Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
# Install PM2 globally
npm install -g pm2
# Install Nginx
apt install -y nginx
# Create app directory
mkdir -p /var/www/nextjs-app
chown -R $USER:$USER /var/www/nextjs-app
Verify installations:
node --version # v20.x.x
npm --version # 10.x.x
pm2 --version # 5.x.x
nginx -v # nginx/1.24.x
Step 1: Prepare Your Next.js Project
Your Next.js project needs a proper build configuration. Let's configure next.config.js (or .mjs for ESM projects):
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
poweredByHeader: false,
compress: true,
experimental: {
// Optional: enable if you need server actions
serverActions: true,
},
}
module.exports = nextConfig
The output: 'standalone' directive is critical. It tells Next.js to produce a self-contained build that includes all necessary node_modules. Without it, you'd need to run npm install on the VPS for every deploy — slow and fragile.
Package.json Scripts
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/server.js",
"lint": "next lint",
"test": "jest --passWithNoTests"
}
}
The start script references .next/standalone/server.js — this is what PM2 will execute on the VPS. The standalone output bundles everything into a single directory you can copy directly.
Add a Build Script for the VPS
Create a scripts/deploy.sh file in your project root:
#!/bin/bash
set -e
echo "📦 Installing dependencies..."
npm ci --omit=dev
echo "🔨 Building Next.js..."
npm run build
echo "✅ Build complete. Standalone output at .next/standalone/"
ls -la .next/standalone/server.js
Make it executable:
chmod +x scripts/deploy.sh
Step 2: Configure Your VPS
Nginx Reverse Proxy
Create an Nginx config for your app:
# /etc/nginx/sites-available/nextjs-app
server {
listen 80;
server_name your-domain.com;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 256;
gzip_comp_level 6;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
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;
}
# Serve static assets directly
location /_next/static {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, max-age=3600, immutable";
}
}
Enable it:
ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
PM2 Ecosystem File
Create /var/www/nextjs-app/ecosystem.config.js:
module.exports = {
apps: [{
name: 'nextjs-app',
script: 'server.js',
cwd: '/var/www/nextjs-app/current',
instances: 2,
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
max_memory_restart: '500M',
kill_timeout: 10000,
listen_timeout: 5000,
wait_ready: true,
}]
}
Key decisions here:
instances: 2— runs your app on 2 CPU cores via PM2 cluster mode. This enables zero-downtime reloads.exec_mode: 'cluster'— essential for graceful restarts.wait_ready: true— PM2 waits for your app to signal readiness before sending traffic.
To support wait_ready, add this to your server entry point (if using a custom server) or rely on Next.js's built-in readiness signal.
Step 3: Set Up SSH for GitHub Actions
GitHub Actions needs passwordless SSH access to your VPS.
Generate a Deploy Key
On your local machine (not the VPS):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy
This creates:
~/.ssh/github_actions_deploy— private key (goes to GitHub Secrets)~/.ssh/github_actions_deploy.pub— public key (goes to the VPS)
Add Public Key to VPS
# From your local machine
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub root@your-vps-ip
Or manually:
# On the VPS
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "ssh-ed25519 AAAAC3..." >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Add Private Key to GitHub Secrets
- Go to your repo → Settings → Secrets and variables → Actions
- Click New repository secret
- Name:
SSH_PRIVATE_KEY - Value: paste the entire contents of
~/.ssh/github_actions_deploy(including-----BEGIN OPENSSH PRIVATE KEY-----and-----END OPENSSH PRIVATE KEY-----)
Also add these secrets:
| Secret Name | Value |
|---|---|
SSH_HOST | Your VPS IP address |
SSH_USER | root (or your deploy user) |
SSH_PORT | 22 (or your custom SSH port) |
Step 4: Write the GitHub Actions Workflow
Create .github/workflows/deploy.yml:
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch: # Allows manual trigger from GitHub UI
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
build-and-deploy:
name: Build & Deploy
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: ⬇️ Checkout code
uses: actions/checkout@v4
- name: 📦 Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 📥 Install dependencies
run: npm ci --omit=dev
- name: 🔨 Build Next.js
run: npm run build
env:
NEXT_TELEMETRY_DISABLED: 1
- name: 🧪 Run lint
run: npm run lint
continue-on-error: false
- name: 🧹 Prepare deployment artifact
run: |
mkdir -p deploy-package
cp -r .next/standalone/* deploy-package/
cp -r .next/static deploy-package/.next/static 2>/dev/null || true
cp -r public deploy-package/ 2>/dev/null || true
# Include package.json for PM2
cp package.json deploy-package/ 2>/dev/null || true
- name: 📤 Deploy to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
source: "deploy-package/*"
target: "/var/www/nextjs-app/releases/$(date +%Y%m%d-%H%M%S)/"
strip_components: 1
rm: false
- name: 🔄 Restart application
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
RELEASE_DIR=$(ls -td /var/www/nextjs-app/releases/*/ | head -1)
# Symlink current → latest release
ln -sfn "$RELEASE_DIR" /var/www/nextjs-app/current
# Install production dependencies if needed
cd /var/www/nextjs-app/current
npm ci --omit=dev --production || true
# Reload PM2 with zero downtime
pm2 reload ecosystem.config.js || pm2 start ecosystem.config.js
# Save PM2 process list for resurrection on reboot
pm2 save
# Health check
sleep 3
curl -sf http://localhost:3000/ > /dev/null && echo "✅ Health check passed" || echo "⚠️ Health check failed"
- name: 🧹 Cleanup old releases
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd /var/www/nextjs-app/releases
ls -t | tail -n +6 | xargs -r rm -rf
What This Workflow Does
Checkout code— pulls the latestmainbranchSet up Node.js— uses Node 20 and cachesnode_modulesInstall dependencies—npm cifor clean, reproducible installsBuild Next.js— produces the standalone outputRun lint— gate; fails the pipeline if lint errors existPrepare deployment artifact— gathers standalone build, static assets, and public filesDeploy to VPS— copies files via SCP to a timestamped release directoryRestart application— symlinkscurrentto the new release, reloads PM2Cleanup old releases— keeps the 5 most recent releases, removes the rest
Why concurrency Matters
concurrency:
group: production-deploy
cancel-in-progress: false
This ensures only one deployment runs at a time. If you push twice in quick succession, the second workflow queues rather than interrupting the first. You don't want two pm2 reload commands racing each other.
Step 5: Test the Pipeline
Push to main and watch the magic:
git add .
git commit -m "feat: add GitHub Actions CI/CD pipeline"
git push origin main
Go to your repo's Actions tab. You should see the workflow running:
If it fails, check:
- Build step: Ensure
output: 'standalone'is set innext.config.js - SCP step: Verify SSH key and host are correct
- PM2 step: Make sure PM2 is installed and
ecosystem.config.jsexists - Health check: Your app must respond on
localhost:3000
Common Fixes
Error: pm2: command not found
# On your VPS, create a symlink if PM2 was installed globally via npm
which pm2
# If not found:
export PATH=$PATH:$(npm bin -g)
# Permanent fix — add to ~/.bashrc
echo 'export PATH=$PATH:$(npm bin -g)' >> ~/.bashrc
Error: ENOENT: no such file or directory, open 'server.js'
You forgot output: 'standalone' in next.config.js. Your build output is in .next/ but not in .next/standalone/.
Error: Port 3000 already in use
# Kill whatever is on port 3000
lsof -ti:3000 | xargs kill -9
pm2 restart nextjs-app
Advanced: Zero-Downtime Deployments
The workflow above already achieves zero-downtime because PM2's cluster mode with pm2 reload does rolling restarts: it starts new instances, waits for them to become healthy, then gracefully shuts down old ones.
But you can go further.
Health Check Endpoint
Add an API route that PM2's wait_ready can probe:
// app/api/health/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
status: 'ok',
uptime: process.uptime(),
timestamp: Date.now(),
})
}
Then in ecosystem.config.js:
module.exports = {
apps: [{
// ... other config
wait_ready: true,
listen_timeout: 10000,
ready_timeout: 15000,
env: {
NODE_ENV: 'production',
PORT: 3000,
},
}]
}
Database Migrations
If your app uses a database, add a migration step before the PM2 reload:
- name: 🗄️ Run database migrations
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/nextjs-app/current
npx prisma migrate deploy
Always run migrations before restarting the app, so new code matches the schema.
Slack/Discord Notifications
Notify your team on deploy:
- name: 📢 Notify on success
if: success()
uses: slackapi/slack-github-action@v1.27.0
with:
payload: |
{
"text": "✅ Deploy to production succeeded!\nBranch: ${{ github.ref_name }}\nCommit: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Rollback Strategy
Something broke? Roll back in seconds:
# SSH into your VPS
ssh root@your-vps-ip
# List releases
ls -la /var/www/nextjs-app/releases/
# Point 'current' to the previous release
ln -sfn /var/www/nextjs-app/releases/20260624-143000 /var/www/nextjs-app/current
# Reload PM2
pm2 reload nextjs-app
This works because every release is a full snapshot. No git operations needed on the server. No npm install race conditions.
Security Considerations
- Never commit secrets — use GitHub Secrets for
SSH_PRIVATE_KEY, API keys, database URLs - Restrict SSH access — the deploy key should have minimal permissions; don't reuse your personal key
- Use environment-specific
.envfiles — GitHub Secrets can inject entire.envfiles at build time:
- name: 🔐 Create .env file
run: |
echo "${{ secrets.ENV_FILE }}" > .env.production
- Rotate deploy keys — regenerate every 90 days
- Pin action versions — use exact tags (
@v4, not@main) to prevent supply-chain attacks - Set
timeout-minutes— prevents runaway workflows from burning your GitHub Actions quota
Cost Comparison: VPS vs Vercel/Netlify
Why bother with a VPS when Vercel offers one-click deploys?
| VPS (Hetzner CX22) | Vercel Pro | |
|---|---|---|
| Monthly cost | ~$4-6 | $20 |
| Bandwidth | 20 TB | 1 TB |
| Compute | 2 vCPU, 4 GB RAM | Shared |
| Cold starts | None | Occasional |
| Full control | Yes | No |
| Maintenance | Your responsibility | Zero |
| SSR/ISR | Unlimited | 1M ISR revalidations |
For most production apps, the $16/month difference compounds — that's $192/year saved, enough for a backup server or monitoring stack.
Conclusion
You now have a complete CI/CD pipeline that:
- Triggers automatically on every push to
main - Builds and tests your Next.js app in a clean environment
- Deploys to your VPS with zero downtime
- Keeps the last 5 releases for instant rollbacks
- Costs a fraction of managed platforms
Here is your checklist to get started:
- Configure
output: 'standalone'innext.config.js - Set up Nginx reverse proxy on your VPS
- Create PM2 ecosystem file with cluster mode
- Generate SSH deploy key pair
- Add secrets to GitHub (
SSH_PRIVATE_KEY,SSH_HOST,SSH_USER) - Create
.github/workflows/deploy.yml - Push to
mainand verify - Set up Slack/Discord notifications
- Test rollback procedure
The workflow YAML in this guide is production-ready — copy it, adapt the health check and migration steps to your stack, and ship.
Next article: Prometheus & Grafana — monitor this deployment end-to-end.