Back to Article List

Deploy Next.js 16 on a VPS with PM2 and Nginx

Deploy Next.js 16 on a VPS with PM2 and Nginx - Deploy Next.js 16 on a VPS with PM2 and Nginx

Why self-host Next.js

Vercel is convenient for some teams, but convenience comes with real costs. You're locked into their platform, subject to their pricing tiers and rate limits, and you'll always face cold starts on the free tier. Self-hosting your Next.js application on a VPS gives you absolute control over infrastructure, predictable monthly costs that never surprise you, and the ability to run multiple apps on a single machine without paying per-app fees.

Beyond economics, self-hosting eliminates vendor lock-in. Your app doesn't depend on a third party's API, pricing changes or policy shifts. You own the entire stack. Cold starts disappear. You can use native modules, custom Node dependencies and even binary tools that Vercel's serverless environment might reject. You get granular control over environment variables, database connections and resource allocation.

Next.js 16 actually makes self-hosting simple. The framework provides a standalone output mode that bundles your app into a lean, portable directory containing only what you need to run. No giant node_modules folder, no build artifacts from local development. Just production code and dependencies.

The Next.js standalone output mode explained

Here's what happens under the hood when you enable standalone mode. Next.js performs dependency tracing during the build process. It walks through all your pages, components, API routes and server functions, recording every dependency they need. This creates a dependency graph. The build then copies only those specific dependencies into a new directory structure, separate from your full node_modules. For official Next.js documentation on this feature, see the Next.js output configuration guide.

The output is minimal. A typical app that starts with 500 MB of node_modules might produce a 50-100 MB standalone directory. This dramatic reduction happens because you're excluding dev dependencies, unused packages and the build toolchain itself. The next build command outputs a compiled, production-ready application.

Inside the standalone directory, you'll find a server.js file. This is a minimal HTTP server that Next.js generates. It's not your custom server. It's a small Node process that handles routing, server-side rendering, API routes and static files. You run this file directly with Node, and it listens on whatever port you assign.

Static files and public assets aren't bundled by default. This is intentional. Large static files (images, downloads, videos) belong on CDNs or nginx, not bundled with the app. You'll need to manually copy your public folder and .next/static folder to the server for the standalone build to serve them.

Configure next.config.mjs for standalone output

Open your project's next.config.mjs file. If you're using next.config.js (older format), consider migrating to .mjs. Add or update the output configuration.

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  experimental: {
    optimizePackageImports: ['@mui/material', '@mui/icons-material'],
  },
};

export default nextConfig;

The standalone mode is the key setting. The experimental optimize setting is optional but helps reduce bundle size if you're using Material-UI. You can add other Next.js config here like redirects, rewrites, custom headers or domain aliases.

Test the configuration locally before deploying. Run npm run build and look for the .next/standalone directory in your project root. This directory is what you'll deploy to your VPS.

Build and prepare for deployment

The build process generates several outputs. After npm run build completes, you have a .next directory with three key subdirectories: standalone (your app), static (images and assets), and cache (for image optimization). You also have your public folder and package.json files.

Manually copying these pieces is error-prone. Create a deployment script to automate the process. Call it prepare-deploy.sh in your project root.

#!/bin/bash

# Exit on any error
set -e

# Clean up previous deploy artifacts
rm -rf deploy-dist

# Create the deploy directory structure
mkdir -p deploy-dist

# Copy the standalone Next.js build
cp -r .next/standalone/* deploy-dist/

# Create .next directory in deploy-dist and copy static assets
mkdir -p deploy-dist/.next
cp -r .next/static deploy-dist/.next/
cp -r .next/cache deploy-dist/.next/ 2>/dev/null || true

# Copy public folder if it exists
if [ -d "public" ]; then
  cp -r public deploy-dist/
fi

# Copy package.json and package-lock.json for dependency verification
cp package.json deploy-dist/
cp package-lock.json deploy-dist/ 2>/dev/null || true

# Optional: copy .env.production if it exists locally
if [ -f ".env.production.local" ]; then
  cp .env.production.local deploy-dist/.env.production
fi

echo "Deploy package ready in deploy-dist/"
echo "Size: $(du -sh deploy-dist | cut -f1)"

Make it executable with chmod +x prepare-deploy.sh. Now when you run bash prepare-deploy.sh, everything needed for production gets organized into one clean directory. The size output at the end helps you verify the package is reasonable before transferring.

Transfer to your VPS using rsync

SSH and scp work but they're slow for large directory transfers. Rsync is faster, handles partial transfers gracefully, and only syncs files that have changed. For deployments, rsync is the standard choice.

rsync -avz --delete deploy-dist/ deploy@your-vps-ip:/var/www/nextjs-app/

Replace deploy with your VPS username and your-vps-ip with your actual server IP or domain. The flags mean: -a (archive mode, preserves permissions), -v (verbose), -z (compress during transfer), --delete (remove files on server that don't exist locally).

The --delete flag is important. It keeps your deployment directory clean, removing old files that you've deleted locally. Without it, deleted code might still run on your server.

If you have a large app with many static assets, the first rsync transfer might take minutes. Subsequent transfers are much faster because rsync only copies changed files.

Configure PM2 with cluster mode and ecosystem.config.js

SSH into your VPS and create an ecosystem configuration file for PM2. This file tells PM2 everything about how to run your Next.js app. For comprehensive PM2 documentation, see the official PM2 cluster mode guide.

module.exports = {
  apps: [
    {
      // Name for your app, appears in pm2 list and logs
      name: 'nextjs-app',

      // Script to execute. For standalone builds, use server.js
      script: 'server.js',

      // Working directory where your app lives
      cwd: '/var/www/nextjs-app',

      // Number of instances to spawn
      // Use 'max' for one per CPU core, or a number like 2, 4
      instances: 'max',

      // Cluster mode uses the Node.js cluster module internally
      // Workers listen on the same port and PM2 distributes traffic
      exec_mode: 'cluster',

      // Restart the app if it crashes unexpectedly
      autorestart: true,

      // Prevent crash loops by limiting restart attempts
      max_restarts: 10,
      min_uptime: '10s',

      // Restart if a single instance exceeds this memory
      max_memory_restart: '500M',

      // Environment variables available to your Next.js app
      env: {
        NODE_ENV: 'production',
        HOSTNAME: '0.0.0.0',
        PORT: 3000,
      },

      // Different environment for development if needed
      env_development: {
        NODE_ENV: 'development',
        DEBUG: 'app:*',
      },

      // Log file paths
      error_file: '/var/log/pm2/nextjs-app-error.log',
      out_file: '/var/log/pm2/nextjs-app-out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',

      // Watch for file changes and restart (turn off in production)
      watch: false,
      ignore_watch: ['node_modules', 'logs', 'public', '.next/cache'],

      // Timeout for graceful shutdown before force kill
      kill_timeout: 10000,

      // Listen for 'ready' signal from your app
      listen_timeout: 10000,
    },
  ],
};

This config is exhaustive. The critical detail is HOSTNAME set to 0.0.0.0. Next.js standalone mode needs this to listen on all network interfaces. Without it, your app only listens on localhost and Nginx can't reach it. This causes the "connection refused" error that stumps many developers.

Cluster mode is the standout feature here. You set exec_mode: 'cluster' and instances: 'max', and PM2 spawns one worker per CPU core. Internally, PM2 uses the Node.js cluster module. All workers listen on port 3000. PM2 distributes incoming connections across them automatically. You don't run separate apps on different ports and load balance them yourself. PM2 handles load balancing internally.

Save this as ecosystem.config.js in your app directory, then install production dependencies and start the app.

cd /var/www/nextjs-app
npm ci --production
pm2 start ecosystem.config.js
pm2 save
pm2 startup

The npm ci --production command installs only dependencies marked in package.json, skipping dev dependencies. pm2 save persists your running processes. pm2 startup generates a system service that auto-starts PM2 on server reboot.

Nginx reverse proxy and static file serving

Create a new Nginx server block for your app. This file goes at /etc/nginx/sites-available/nextjs-app. For comprehensive Nginx documentation, see the official Nginx docs.

server {
  listen 80;
  server_name yourdomain.com www.yourdomain.com;

  # Gzip compression for faster content delivery
  gzip on;
  gzip_types text/plain text/css text/javascript application/json;
  gzip_min_length 1000;

  # Serve static Next.js assets with aggressive caching
  location /_next/static/ {
    alias /var/www/nextjs-app/.next/static/;
    expires 365d;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept-Encoding";
  }

  # Serve public folder assets
  location /public/ {
    alias /var/www/nextjs-app/public/;
    expires 30d;
    add_header Cache-Control "public";
  }

  # Proxy dynamic requests to Next.js app
  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;

    # Timeouts for long-running requests
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;
  }
}

The two location blocks handle static files separately from dynamic content. Static assets with immutable cache headers won't refetch for 365 days. This is safe because Next.js generates unique filenames for each build. Public folder assets get a shorter 30-day cache.

The main location block proxies everything else to your Next.js app on 127.0.0.1:3000. The proxy headers ensure your app knows the original client IP and protocol. This matters for things like CORS, IP-based logging and HTTPS detection.

Enable the site and test the configuration.

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

SSL certificates with Certbot and Let's Encrypt

Certbot is installed on your LumaDock VPS. Use it to get a free Let's Encrypt certificate for your domain.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

The --nginx flag tells Certbot to detect your Nginx configuration and update it automatically. Certbot modifies your server block to use HTTPS and redirects HTTP traffic to HTTPS. It also handles certificate renewal with a systemd timer.

Verify auto-renewal is configured.

sudo certbot renew --dry-run

If the dry-run succeeds, renewal will work automatically. Certbot renews certificates 30 days before expiration, so you'll never accidentally let one expire.

Environment variables build-time versus runtime

Next.js has two types of variables. Build-time variables prefixed with NEXT_PUBLIC_ are embedded into your JavaScript bundle and sent to the browser. The browser can read them, so don't put secrets here. Runtime variables exist only on the server and are never exposed to the client.

Since you build locally and deploy the compiled output, build-time variables must be set during the local build. Create a .env.local file in your project root before building.

NEXT_PUBLIC_API_URL=https://api.yourdomain.com
NEXT_PUBLIC_GTM_ID=GTM-XXXXX
NEXT_PUBLIC_STRIPE_KEY=pk_live_XXXXX
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
API_SECRET=secret-key-here

Run npm run build with these variables in environment. The NEXT_PUBLIC_ variables get baked into your compiled app. Once built, you can't change them without rebuilding.

Server-side variables like DATABASE_URL and API_SECRET don't get compiled in. You set these on the VPS in your PM2 ecosystem config. These can be changed without rebuilding.

To change NEXT_PUBLIC_ variables in production, you must rebuild locally, run prepare-deploy.sh, rsync the new build and reload the app. This is why teh habit of separating actual secrets from build-time configuration helps. Secrets stay in runtime variables.

Image optimization on your VPS

Next.js provides automatic image optimization via the Image component. Without Vercel, images are optimized on your server during the first request for each size. Subsequent requests serve the cached optimized version instantly. If you're running other Node.js frameworks like Fastify or NestJS, see the Fastify production deployment guide for similar approaches.

Optimized images are cached in .next/cache/images. This directory persists across deployments. If you're doing a fresh deploy on a new server, the first requests for each image will be slow while they're optimized, then fast from cache.

For high-traffic sites, configure persistent image cache storage. Update next.config.mjs.

const nextConfig = {
  output: 'standalone',
  images: {
    minimumCacheTTL: 60 * 24 * 365, // 1 year
    formats: ['image/avif', 'image/webp'],
  },
};

export default nextConfig;

This tells Next.js to aggressively cache images and prefer modern formats. For truly massive traffic, use an external CDN like Cloudflare to cache optimized images globally. This offloads image processing from your VPS entirely.

Running multiple Next.js apps on one VPS

A single VPS can run multiple Next.js applications if you have spare CPU and RAM. Each app needs its own PM2 configuration, port and Nginx server block.

In your ecosystem.config.js, add multiple apps.

module.exports = {
  apps: [
    {
      name: 'nextjs-app-1',
      script: 'server.js',
      cwd: '/var/www/nextjs-app-1',
      instances: 2,
      exec_mode: 'cluster',
      env: {
        PORT: 3000,
      },
    },
    {
      name: 'nextjs-app-2',
      script: 'server.js',
      cwd: '/var/www/nextjs-app-2',
      instances: 2,
      exec_mode: 'cluster',
      env: {
        PORT: 3001,
      },
    },
  ],
};

Each app listens on a different port. Create separate Nginx server blocks to proxy each domain to its corresponding app. Monitor resource usage with pm2 monit. If CPU or RAM maxes out, the server becomes sluggish for all apps. Scale up the VPS or migrate an app to separate hardware.

Redeployment workflow

When you have new code to deploy, the process is straightforward but must be exact.

git pull origin main
npm install
npm run build
bash prepare-deploy.sh
rsync -avz --delete deploy-dist/ deploy@your-vps-ip:/var/www/nextjs-app/
ssh deploy@your-vps-ip 'cd /var/www/nextjs-app && npm ci --production && pm2 reload ecosystem.config.js'

The key command is pm2 reload, not restart. Reload performs graceful restarts of workers one at a time. In cluster mode, while one worker is restarting, the others handle traffic. Your site stays live. Restart force-kills all workers at once, causing downtime. For more detail on tis approach, see the guide on zero-downtime deployments with PM2.

Adding --wait-ready makes PM2 wait for your app to signal readiness before moving to the next worker.

pm2 reload ecosystem.config.js --wait-ready --listen-timeout 5000

For this to work, your app must call process.send('ready') when fully loaded.

Troubleshooting and common issues

502 Bad Gateway means Nginx can't reach your app. Verify the app is actually running on port 3000.

pm2 list
pm2 logs nextjs-app

If PM2 shows the app crashed, check logs. Common reasons include missing environment variables, a bad next.config.mjs syntax error or an incompatibility in a dependency.

Connection refused errors usually mean the HOSTNAME: '0.0.0.0' setting is missing from your PM2 config. Next.js doesn't listen on all interfaces by default.

If static files aren't loading, verify the public and .next/static folders were copied to the VPS. Check that Nginx alias paths match your actual file locations.

Memory errors suggest your app has a leak or your VPS is too small. Use pm2 monit to watch memory growth. For detailed heap profiling and leak detection, see how to fix Node.js heap memory errors. Adjust max_memory_restart to a reasonable threshold and let PM2 auto-restart workers when they get too bloated.

Your idea deserves better hosting

24/7 support 30-day money-back guarantee Cancel anytime
Billing Cycle

1 GB RAM VPS

17.32 RON Save  50 %
8.64 RON Monthly
  • 1 vCPU AMD EPYC
  • 30 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

2 GB RAM VPS

26.01 RON Save  17 %
21.67 RON Monthly
  • 2 vCPU AMD EPYC
  • 30 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

6 GB RAM VPS

65.08 RON Save  33 %
43.37 RON Monthly
  • 6 vCPU AMD EPYC
  • 70 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P1

34.69 RON Save  25 %
26.01 RON Monthly
  • 2 vCPU AMD EPYC
  • 4 GB RAM memory
  • 40 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P2

65.08 RON Save  27 %
47.72 RON Monthly
  • 2 vCPU AMD EPYC
  • 8 GB RAM memory
  • 80 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P4

130.21 RON Save  20 %
104.16 RON Monthly
  • 4 vCPU AMD EPYC
  • 16 GB RAM memory
  • 160 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P5

158.43 RON Save  21 %
125.87 RON Monthly
  • 8 vCPU AMD EPYC
  • 16 GB RAM memory
  • 180 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P6

247.44 RON Save  21 %
195.34 RON Monthly
  • 8 vCPU AMD EPYC
  • 32 GB RAM memory
  • 200 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

AMD EPYC VPS.P7

303.88 RON Save  20 %
243.10 RON Monthly
  • 16 vCPU AMD EPYC
  • 32 GB RAM memory
  • 240 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G1

21.67 RON Save  20 %
17.32 RON Monthly
  • 1 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 1 GB DDR5 memory
  • 25 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G2

56.40 RON Save  23 %
43.37 RON Monthly
  • 2 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 4 GB DDR5 memory
  • 50 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G4

112.84 RON Save  27 %
82.45 RON Monthly
  • 4 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 8 GB DDR5 memory
  • 100 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G5

195.34 RON Save  33 %
130.21 RON Monthly
  • 4 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 16 GB DDR5 memory
  • 150 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G6

212.70 RON Save  31 %
147.58 RON Monthly
  • 8 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 16 GB DDR5 memory
  • 200 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

EPYC Genoa VPS.G7

325.59 RON Save  27 %
238.75 RON Monthly
  • 8 vCPU AMD EPYC Gen4 AMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture.
  • 32 GB DDR5 memory
  • 250 GB NVMe storage
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • 1 Gbps network
  • Automatic backup included
  • Firewall management
  • Free server monitoring

FAQ

How do I change environment variables after deploying?

For runtime variables, update your ecosystem.config.js and run pm2 reload ecosystem.config.js. For NEXT_PUBLIC_ variables, rebuild locally, redeploy and reload. There's no shortcut for build-time variables because they're compiled into your JavaScript.

Your ideas deserve better hosting

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.

GPU products are in high demand at the moment. Fill the form to get notified as soon as your preferred GPU server is back in stock.