Server rack in a data center
Back to Blog
Next.jsDevOpsDeploymentNginxCI/CD

Deploying Next.js to a VPS: The Complete Setup

Vercel is great until it isn't — pricing, vendor lock-in, or compliance requirements. Here's how I self-host Next.js on a VPS with Nginx, PM2, and GitHub Actions.

Published on April 10, 202612 min read

Why Self-Host?

Vercel is the easiest way to deploy Next.js — it's built by the same team, and for most projects it's the right choice. But there are real reasons to self-host:

  • Cost: A small VPS ($6–$20/month) handles significant traffic. Vercel's pricing can grow fast at scale.
  • Compliance: Some clients require data to stay on specific infrastructure.
  • Control: Full control over the runtime environment, dependencies, and configuration.

This guide covers a production-grade setup on Ubuntu using Nginx as a reverse proxy, PM2 for process management, and GitHub Actions for CI/CD.

Server Setup

Start with a fresh Ubuntu 22.04 VPS. I use DigitalOcean Droplets, but Hetzner (better price/performance in Europe) or any VPS provider works.

# Update system
sudo apt update && sudo apt upgrade -y

# Install Node.js (use NodeSource for latest LTS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

# Install pnpm
npm install -g pnpm

# Install PM2
npm install -g pm2

# Install Nginx
sudo apt install -y nginx

# Set up firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Nginx Configuration

Nginx acts as a reverse proxy, forwarding traffic to the Next.js server running on port 3000:

# /etc/nginx/sites-available/myapp.com
server {
    listen 80;
    server_name myapp.com www.myapp.com;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    # Static assets — served directly by Nginx, bypassing Node.js
    location /_next/static/ {
        alias /var/www/myapp/.next/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location /public/ {
        alias /var/www/myapp/public/;
        expires 30d;
    }

    # Everything else goes to Next.js
    location / {
        proxy_pass http://localhost: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;
    }
}
# Enable the site
sudo ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

# Set up SSL with Certbot (free Let's Encrypt certificates)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.com -d www.myapp.com

Certbot automatically edits your Nginx config to add HTTPS and sets up auto-renewal.

PM2 Process Configuration

Create an ecosystem.config.js in your project root:

module.exports = {
  apps: [
    {
      name: "myapp",
      script: "node_modules/.bin/next",
      args: "start",
      cwd: "/var/www/myapp",
      instances: "max",        // One process per CPU core
      exec_mode: "cluster",    // Load balance across processes
      env_production: {
        NODE_ENV: "production",
        PORT: 3000,
      },
      max_memory_restart: "500M",
      error_file: "/var/log/pm2/myapp-error.log",
      out_file: "/var/log/pm2/myapp-out.log",
    },
  ],
};
# Start the app
pm2 start ecosystem.config.js --env production

# Save process list (survives reboots)
pm2 save

# Auto-start on server reboot
pm2 startup

GitHub Actions CI/CD

Create .github/workflows/deploy.yml:

name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 9

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build
        env:
          # Pass build-time env vars as GitHub Secrets
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            pnpm install --frozen-lockfile
            pnpm build
            pm2 reload ecosystem.config.js --env production

Add SERVER_HOST, SSH_PRIVATE_KEY, and any other secrets in your GitHub repository settings → Secrets.

Zero-Downtime Deploys

pm2 reload (not restart) performs a rolling reload in cluster mode — new processes start before old ones stop. Users never see a downtime window during deploys.

For database migrations, run them before the pm2 reload step:

npx prisma migrate deploy  # or your migration command
pm2 reload ecosystem.config.js --env production

Monitoring

pm2 monit        # Real-time CPU/memory per process
pm2 logs myapp   # Tail application logs
pm2 status       # Process status overview

For production monitoring, I add:

  • Better Uptime (free tier) — pings every minute, emails on downtime.
  • Sentry — error tracking and alerting.
  • Netdata or Grafana — server metrics (CPU, memory, disk, network).

The Result

A production Next.js deployment that:

  • Serves static assets directly from Nginx (fast, no Node.js overhead).
  • Runs across all CPU cores with load balancing.
  • Deploys automatically on every push to main.
  • Renews SSL certificates automatically.
  • Restarts automatically if the process crashes or the server reboots.

The total server cost for a mid-traffic project: $6–$20/month.