Back to blog

Self-Host Next.js on a VPS: Build, PM2, Nginx and Zero-Downtime Reloads

IM Host EditorialOctober 25, 20268 min read
Self-Host Next.js on a VPS: Build, PM2, Nginx and Zero-Downtime Reloads

Next.js runs well on a plain Linux server. You need four pieces: a build that produces a small self-contained server, a process manager that keeps it running, Nginx in front of it, and a deploy routine that swaps versions without dropping requests. This guide sets up all four on Ubuntu or Debian and explains why each step exists, so you can adapt it to your own app.

What you need before you start

  • A Linux VPS with root or sudo access. As a rule of thumb, building a mid-size Next.js app on the server needs at least 2 GB of RAM, and 4 GB is more comfortable. If you build in CI and only run the app on the server, 1 to 2 GB is often enough for a small site.

  • Node.js LTS (20 or 22). Check with node -v.

  • A domain with an A record (and AAAA if you use IPv6) pointing to the server.

Step 1: Turn on standalone output

By default, next start needs your whole node_modules folder on the server. With standalone output, Next.js traces which files your app actually imports and copies only those into .next/standalone, together with a minimal server.js. The result is smaller, faster to copy, and does not need npm install at runtime.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};
module.exports = nextConfig;

Build it:

npm ci
npm run build

One detail catches almost everyone: standalone output does not include the public folder or .next/static. If you skip copying them, pages load without CSS or JavaScript and the browser shows 404 errors for every /_next/static/... file.

cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/

Test the server directly:

cd .next/standalone
PORT=3000 HOSTNAME=127.0.0.1 node server.js
# in a second terminal
curl -I http://127.0.0.1:3000

The standalone server reads the HOSTNAME variable to choose which interface to listen on. Many shells already set HOSTNAME to the machine name, so set it to 127.0.0.1 explicitly. That way the app only listens locally and Nginx is the only public entry point.

Step 2: Use a release folder layout

If you build inside the folder the live app runs from, the build overwrites files while the app is serving them. Users get errors for a minute or two on every deploy. A release layout avoids this: each deploy goes into its own folder, and a symlink points to the active one.

/var/www/app/
  repo/        # git checkout, where you build
  releases/    # one folder per deploy
  shared/      # files that survive deploys
  current      # symlink to the active release

A deploy script that follows this layout:

#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
REL=$APP/releases/$(date +%Y%m%d-%H%M%S)

cd $APP/repo
git pull --ff-only
npm ci
npm run build

mkdir -p "$REL/.next"
cp -r .next/standalone/. "$REL"/
cp -r public "$REL"/public
cp -r .next/static "$REL"/.next/static

# keep hashed assets from older builds for open browser tabs
mkdir -p $APP/shared/_next/static
cp -r .next/static/. $APP/shared/_next/static/

# atomic switch of the symlink
ln -s "$REL" $APP/current.tmp
mv -T $APP/current.tmp $APP/current

pm2 reload app --update-env

# keep the last 5 releases
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf

Using ln -s followed by mv -T replaces the symlink in one step, so there is no moment where current does not exist. Keeping old releases also gives you a one-command rollback, covered below.

About environment variables: put runtime values in the PM2 ecosystem file or in a file outside the release folder, so every release sees the same values. Variables that start with NEXT_PUBLIC_ are inlined into the JavaScript at build time, so they must be set when you run npm run build, not only at runtime.

Step 3: Run it with PM2

PM2 restarts the app if it crashes, starts it on boot, and handles reloads.

sudo npm install -g pm2

Create /var/www/app/ecosystem.config.js:

module.exports = {
  apps: [{
    name: 'app',
    script: '/var/www/app/current/server.js',
    cwd: '/var/www/app/current',
    exec_mode: 'cluster',
    instances: 2,
    listen_timeout: 10000,
    kill_timeout: 5000,
    max_memory_restart: '600M',
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
      HOSTNAME: '127.0.0.1'
    }
  }]
};
cd /var/www/app
pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd

pm2 startup prints a command. Run that command with sudo so PM2 starts on boot with your saved process list.

Why these settings:

  • Cluster mode is what makes pm2 reload zero-downtime. PM2 starts a new worker, waits until it is listening, then stops an old one. Workers share port 3000. In fork mode, reload behaves like a restart and drops requests.

  • listen_timeout is how long PM2 waits for a new worker to start listening. On a small VPS, a cold Next.js start can take longer than the default.

  • kill_timeout gives in-flight requests time to finish before the old worker is killed.

  • instances: start with 2. Each worker has its own memory, so watch usage with pm2 monit before adding more. Node resolves the current symlink when it loads code, so each new worker loads the new release.

One caveat: with several workers, each has its own in-memory cache. If you rely on ISR or on-demand revalidation, workers can briefly serve different versions of a page. If that matters for your app, configure a shared cache handler or run a single instance.

Step 4: Put Nginx in front

Nginx terminates TLS, compresses responses, serves static files without touching Node, and buffers slow clients so they do not hold Node workers busy.

sudo apt install nginx

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

upstream nextjs {
    server 127.0.0.1:3000;
    keepalive 32;
}

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

    location /_next/static/ {
        alias /var/www/app/shared/_next/static/;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    location / {
        proxy_pass http://nextjs;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }
}
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo ufw allow 'Nginx Full'

Do not open port 3000 in the firewall. The app should only be reachable through Nginx.

Caching headers for /_next/static

Files under /_next/static have a content hash or the build ID in their path. A given URL always returns the same bytes, so it is safe to cache it for a year with immutable. Next.js already sends this header when Node serves these files. Serving them from Nginx takes that load off Node and lets you use the shared folder that keeps chunks from older builds.

Do not copy this header to other paths:

  • HTML pages change on every deploy. Let Next.js set their headers.

  • Files from public/, such as /logo.png, keep the same name when their content changes. Use a short cache (an hour to a day) or put a version in the file name.

  • /_next/image is handled by Next.js, which sets its cache time from images.minimumCacheTTL.

Check the result:

curl -sI https://example.com/_next/static/chunks/main-app.js | grep -i cache-control

Use a real file name from your build. The shared folder grows over time, so prune it now and then, for example with find /var/www/app/shared/_next/static -type f -mtime +30 -delete.

Common errors and what they mean

  • 502 Bad Gateway with connect() failed (111: Connection refused) while connecting to upstream in /var/log/nginx/error.log: nothing is listening on 127.0.0.1:3000. Run pm2 status and pm2 logs app --lines 50.

  • Unstyled pages and 404s for /_next/static/...: the static folder was not copied, or the alias path is wrong. Both the location and the alias need a trailing slash.

  • ChunkLoadError: Loading chunk ... failed in the browser after a deploy: an open tab is asking for a chunk from the previous build. The shared static folder prevents most of these.

  • The build stops with Killed or JavaScript heap out of memory: the server ran out of RAM during the build. Build in CI, add swap, or use a plan with more memory.

  • ss -tlnp | grep 3000 shows 0.0.0.0:3000: HOSTNAME is not set, and the app is listening on every interface.

Rolling back

Because old releases stay on disk, rollback is a symlink change and a reload:

cd /var/www/app/releases
PREV=$(ls -1 | sort | tail -n 2 | head -n 1)
ln -s /var/www/app/releases/$PREV /var/www/app/current.tmp
mv -T /var/www/app/current.tmp /var/www/app/current
pm2 reload app

Is a VPS the right home for your Next.js app?

It depends on your team. A VPS gives you full control and a fixed monthly cost. In return, you own OS updates, Node.js upgrades, monitoring and backups. Certbot renews TLS certificates on a timer, but you should still check that it runs. If your site is fully static, output: 'export' lets Nginx serve it with no Node process at all, which is simpler still.

Run it on IM HOST

A Linux VPS starts from $5/month with full root access on KVM, a dedicated IPv4 address plus an IPv6 /64, and DDoS protection. If you build on the server and want CPU that is not shared, Cloud VPS plans start from $16/month with 4 dedicated vCPU and 8 GB RAM, and include a free automatic backup every 3 days. There are no setup fees, engineers are available 24/7, and a 30-day money-back guarantee applies. Moving an existing app? Our free migration service handles files and databases without downtime.

More from our blog

Discover more practical guides and product insights from the IM Host team.

View all articles