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 reloadzero-downtime. PM2 starts a new worker, waits until it is listening, then stops an old one. Workers share port 3000. In fork mode,reloadbehaves like a restart and drops requests.listen_timeoutis 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_timeoutgives 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 withpm2 monitbefore adding more. Node resolves thecurrentsymlink 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/imageis handled by Next.js, which sets its cache time fromimages.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 Gatewaywithconnect() failed (111: Connection refused) while connecting to upstreamin/var/log/nginx/error.log: nothing is listening on 127.0.0.1:3000. Runpm2 statusandpm2 logs app --lines 50.Unstyled pages and 404s for
/_next/static/...: the static folder was not copied, or thealiaspath is wrong. Both thelocationand thealiasneed a trailing slash.ChunkLoadError: Loading chunk ... failedin 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
KilledorJavaScript 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 3000shows0.0.0.0:3000:HOSTNAMEis 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