How to Deploy a Next.js Application on a VPS with DeployHQ

Devops & Infrastructure, Frontend, Node, and Tutorials

How to Deploy a Next.js Application on a VPS with DeployHQ

Vercel is the easiest way to deploy Next.js, but it is not the only way — and once your traffic grows, it is rarely the cheapest. Deploying to your own VPS gives you predictable costs, full control over caching and middleware, and no vendor lock-in.

This guide covers a production-ready Next.js deployment on a Linux VPS using the standalone output mode, PM2 for process management, Nginx for reverse proxying, and DeployHQ for automated deployments. The result is a pipeline where every git push builds your app, ships it to your server, and restarts it — no SSH required.

Why deploy Next.js on a VPS?

Vercel / Netlify VPS (this guide)
Cost at scale Usage-based — can spike unpredictably Fixed monthly cost, unmetered bandwidth
Control Limited to platform constraints Full root access, any Node version, any middleware
Cold starts Serverless functions can have latency Always-on process, no cold starts
Vendor lock-in Vercel-specific features (Edge Middleware, ISR) Standard Node.js server, portable anywhere
Regions Limited to provider's edge network Deploy wherever your users are

A $10–25/month VPS handles most Next.js applications comfortably. For high-traffic sites, that is a fraction of what serverless platforms charge.


Architecture overview

flowchart LR
    Browser["Browser"]
    Nginx["Nginx\n(TLS + reverse proxy\n+ static files)"]
    Next["Next.js\n(standalone server\nvia PM2)"]
    DeployHQ["DeployHQ"]
    Git["Git Repo"]

    Browser -->|HTTPS :443| Nginx
    Nginx -->|HTTP :3000| Next
    Git -->|push| DeployHQ
    DeployHQ -->|build + deploy| Next

Nginx handles TLS termination and serves static assets directly (faster than proxying them through Node). PM2 keeps the Next.js process alive, restarts it on crashes, and manages zero-downtime reloads.


Prerequisites

  • A VPS with at least 1 GB RAM and 1 vCPU (Ubuntu 22.04 or 24.04)
  • A domain name pointed at your VPS IP
  • A Next.js application in a Git repository
  • A DeployHQ account

Step 1: Install Node.js 22 and PM2

Connect to your VPS:

ssh deploy@your-server-ip

Install Node.js 22 (current LTS) using the official NodeSource repository:

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl nginx

# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

# Verify
node --version   # v22.x.x
npm --version    # 10.x.x

# Install PM2 globally
sudo npm install -g pm2

Step 2: Enable standalone output in your Next.js app

The standalone output mode is the key to clean VPS deployments. It traces your application's dependencies and produces a self-contained build with only the files needed to run — no node_modules install required on the server.

In your next.config.ts (or next.config.js):

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',
};

export default nextConfig;

After running npm run build, this produces:

.next/
  standalone/          # Self-contained server + minimal node_modules
    server.js          # Entry point (replaces `next start`)
    node_modules/      # Only what's needed at runtime
    package.json
  static/              # Static assets (JS, CSS, images)

Important: The standalone output does not include the public/ folder or .next/static/ — those need to be copied into the standalone directory or served directly by Nginx (which is faster anyway).


Step 3: Create the deployment directory

On your VPS:

sudo mkdir -p /var/www/myapp
sudo chown -R deploy:deploy /var/www/myapp

Step 4: Create a PM2 ecosystem file

Create ecosystem.config.cjs in your repository root:

module.exports = {
  apps: [
    {
      name: 'myapp',
      script: '.next/standalone/server.js',
      cwd: '/var/www/myapp',
      instances: 1,
      exec_mode: 'fork',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
        HOSTNAME: '127.0.0.1',
      },
      max_memory_restart: '512M',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
    },
  ],
};

Key settings:

  • HOSTNAME: '127.0.0.1' binds Next.js to localhost only — Nginx handles external traffic
  • max_memory_restart prevents memory leaks from taking down the server
  • script points to the standalone server.js, not next start

Step 5: Configure Nginx

Create /etc/nginx/sites-available/myapp:

upstream nextjs {
    server 127.0.0.1:3000;
}

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options SAMEORIGIN always;

    # Serve static assets directly from Nginx (faster than proxying through Node)
    location /_next/static/ {
        alias /var/www/myapp/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

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

    # Proxy everything else to Next.js
    location / {
        proxy_pass http://nextjs;
        proxy_http_version 1.1;
        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_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 60s;
        proxy_buffering on;
    }
}

Enable and test:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

The static file configuration is important — Nginx serves JS, CSS, and images directly from disk instead of proxying them through the Node.js process. This reduces load on your app and improves response times for static assets.


Step 6: Set up TLS with Certbot

HTTPS is required for any production site. Install Certbot and obtain a certificate:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Set up automatic renewal:

sudo certbot renew --dry-run

Certbot adds a systemd timer that renews certificates automatically.


Step 7: Configure DeployHQ

Create a project

  1. Sign up or log in to DeployHQ
  2. Create a new project and connect your GitHub or GitLab repository
  3. Add an SSH/SFTP server:
    • Host: your VPS IP
    • Port: 22
    • Username: deploy
    • Authentication: SSH key (generate one in DeployHQ and add the public key to ~/.ssh/authorized_keys on your VPS)
  4. Set the deploy path to /var/www/myapp/

Add a build step

In DeployHQ's Build Commands:

npm ci
npm run build

# Copy static assets into the standalone directory
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public

npm ci is preferred over npm install for CI/CD — it installs exact versions from package-lock.json and is faster.

Configure deployed files

In DeployHQ's Deployment settings, set the subdirectory to .next/standalone so only the self-contained build is uploaded — not node_modules or source files.

Add environment variables

Use DeployHQ's Config Files to inject a .env.production file:

DATABASE_URL=postgresql://user:password@db-host:5432/myapp
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
SESSION_SECRET=your-secret-here

This keeps secrets out of Git while ensuring they are available at runtime.

Add SSH commands

In DeployHQ's SSH Commands (run after deploy):

cd /var/www/myapp

# Reload PM2 with zero-downtime restart
pm2 reload ecosystem.config.cjs --update-env

# Save PM2 process list (survives server reboot)
pm2 save

# On first deploy only, start the app instead:
# pm2 start ecosystem.config.cjs
# pm2 save
# pm2 startup

pm2 reload performs a zero-downtime restart — it starts a new process, waits for it to be ready, then kills the old one. No dropped requests.


Complete deployment flow

After setup, every deployment follows this flow automatically:

  1. Push to Git — developer pushes to main branch
  2. DeployHQ builds — runs npm ci, npm run build, copies static assets
  3. DeployHQ deploys — uploads .next/standalone/ to your VPS via SSH
  4. SSH command runspm2 reload restarts the app with zero downtime
  5. Nginx serves — static assets from disk, dynamic routes via proxy

No SSH sessions, no manual commands, no forgotten steps.


Managing environment variables

Next.js has two types of environment variables:

Type Available in Set at
NEXT_PUBLIC_* Browser + server Build time (baked into JS bundles)
Everything else Server only Runtime

NEXT_PUBLIC_* variables must be available during npm run build — they are inlined into the client-side JavaScript. Set these in DeployHQ's build step environment, not just in runtime .env files.

Server-only variables (database URLs, API keys) only need to be available at runtime. Use DeployHQ's config files to deploy a .env.production alongside your app.


Performance optimisation

Enable gzip/brotli in Nginx

Add to your server block:

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
gzip_vary on;

Increase PM2 instances for multi-core servers

If your VPS has 2+ vCPUs, run multiple Next.js instances:

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'myapp',
      script: '.next/standalone/server.js',
      cwd: '/var/www/myapp',
      instances: 2,        // Match your vCPU count
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
        HOSTNAME: '0.0.0.0',
      },
    },
  ],
};

PM2 cluster mode load-balances requests across instances automatically.

Monitor with PM2

pm2 monit          # Real-time CPU and memory
pm2 logs myapp     # Application logs
pm2 status         # Process status overview

Troubleshooting

Problem Cause Fix
502 Bad Gateway Next.js not running Check pm2 status and pm2 logs myapp
Static assets 404 Missing copy step in build Ensure cp -r .next/static .next/standalone/.next/static runs
Environment variables undefined Not available at build time NEXT_PUBLIC_* vars must be in build environment
Port already in use Previous process still running pm2 delete myapp then redeploy
Slow first page load No static asset caching Check Nginx expires directives are active
Deploy succeeds but old content shows Browser cache or CDN cache Clear cache; check Cache-Control headers

Further reading

If you have questions or need help, reach out at support@deployhq.com or on Twitter/X.