DEV Community

devtocash
devtocash

Posted on • Originally published at devtocash.com

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

๐Ÿ’ก Originally published on devtocash.com โ€” where this guide stays updated. I write hands-on DevOps/SRE deep-dives there weekly.

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
Enter fullscreen mode Exit fullscreen mode

Verify installations:

node --version   # v20.x.x
npm --version    # 10.x.x
pm2 --version    # 5.x.x
nginx -v         # nginx/1.24.x
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x scripts/deploy.sh
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable it:

ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

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,
  }]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or manually:

# On the VPS
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "ssh-ed25519 AAAAC3..." >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Enter fullscreen mode Exit fullscreen mode

Add Private Key to GitHub Secrets

  1. Go to your repo โ†’ Settings โ†’ Secrets and variables โ†’ Actions
  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 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
Enter fullscreen mode Exit fullscreen mode

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 dependencies โ€” npm 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(),
  })
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  }]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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
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.


Related Reading


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.


๐Ÿ“Œ Read the latest version of this guide โ€” plus the full library of DevOps, SRE, Kubernetes, observability & cloud-cost guides โ€” on devtocash.com.

Top comments (0)