devops

GitHub Actions CI/CD: Deploy Next.js to VPS (2026)

Step-by-step tutorial to automate Next.js deployments to a VPS using GitHub Actions CI/CD pipeline with zero-downtime rollouts.

June 24, 2026·12 min read·
#github-actions#nextjs#cicd#vps#deployment

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:

RequirementDetails
VPSUbuntu 22.04 or 24.04, at least 1 GB RAM, public IP
DomainPointed at your VPS (optional — IP works too)
GitHub repoPrivate or public, containing your Next.js project
Node.js 20+Installed on both local and VPS
PM2Process manager on the VPS
NginxReverse proxy on the VPS
SSH accessKey-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

  1. Go to your repo → SettingsSecrets and variablesActions
  2. Click New repository secret
  3. Name: SSH_PRIVATE_KEY
  4. 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 NameValue
SSH_HOSTYour VPS IP address
SSH_USERroot (or your deploy user)
SSH_PORT22 (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

  1. Checkout code — pulls the latest main branch
  2. Set up Node.js — uses Node 20 and caches node_modules
  3. Install dependenciesnpm ci for clean, reproducible installs
  4. Build Next.js — produces the standalone output
  5. Run lint — gate; fails the pipeline if lint errors exist
  6. Prepare deployment artifact — gathers standalone build, static assets, and public files
  7. Deploy to VPS — copies files via SCP to a timestamped release directory
  8. Restart application — symlinks current to the new release, reloads PM2
  9. Cleanup 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:

GitHub Actions pipeline view

If it fails, check:

  • Build step: Ensure output: 'standalone' is set in next.config.js
  • SCP step: Verify SSH key and host are correct
  • PM2 step: Make sure PM2 is installed and ecosystem.config.js exists
  • 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

  1. Never commit secrets — use GitHub Secrets for SSH_PRIVATE_KEY, API keys, database URLs
  2. Restrict SSH access — the deploy key should have minimal permissions; don't reuse your personal key
  3. Use environment-specific .env files — GitHub Secrets can inject entire .env files at build time:
- name: 🔐 Create .env file
  run: |
    echo "${{ secrets.ENV_FILE }}" > .env.production
  1. Rotate deploy keys — regenerate every 90 days
  2. Pin action versions — use exact tags (@v4, not @main) to prevent supply-chain attacks
  3. 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
Bandwidth20 TB1 TB
Compute2 vCPU, 4 GB RAMShared
Cold startsNoneOccasional
Full controlYesNo
MaintenanceYour responsibilityZero
SSR/ISRUnlimited1M 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' in next.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 main and 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.

#github-actions#nextjs#cicd#vps#deployment
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →