
Your Next.js app works with npm run dev. Now it has to run on a VPS: on your own domain, over HTTPS, running again by itself after a crash or a reboot, and updated without visitors hitting an error page. This guide sets that up on a fresh Ubuntu 24.04 or Debian 12 server with Node.js from NodeSource, next start under PM2, nginx in front with the headers Next.js relies on, a Let's Encrypt certificate, and deploys that switch releases with one pm2 reload.
Quick answer: Install Node.js 26 LTS from NodeSource, because both distributions ship Node.js 18 and Next.js 16 needs 20.9 or later. As a separate deploy user, clone each release into its own folder, run npm ci and npm run build, point a current symlink at it and run next start --hostname 127.0.0.1 --port 3000 under PM2 in cluster mode, with pm2 save and pm2 startup for reboots. Proxy your domain to that port with nginx, passing the Host header or Server Actions fail, and add HTTPS with certbot --nginx. To redeploy, build a new release, switch the symlink and run pm2 reload. Never build in the folder that is serving: next build starts by deleting most of .next.
Before You Start
You need a domain with an A record (and an AAAA record for IPv6) pointing at the VPS, an account with sudo rights, and the app in a Git repository with a package-lock.json, which npm ci requires. The commands work on Ubuntu 24.04 and Debian 12; if you log in as root, leave out sudo. On a Space-Node VPS, Ubuntu 24.04 LTS is the recommended choice in the order form, and you get full root access, so everything below works as written.
Step 1: Install Node.js, nginx and PM2
Ubuntu 24.04 ships Node.js 18.19.1 and Debian 12 ships 18.20.4, both too old: Next.js 16 needs Node.js 20.9 or later. Node.js 26 became the Active LTS line on 28 October 2026, and the Node.js project says production apps should only run Active LTS or Maintenance LTS releases (Node.js 24 is now Maintenance LTS). NodeSource's repository carries both:
sudo apt update
sudo apt install -y curl git nginx
curl -fsSL https://deb.nodesource.com/setup_26.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
sudo npm install -g pm2@latest
The script adds NodeSource's node_26.x repository and pins it above the distribution's own package, so Node.js updates arrive with your normal apt upgrade.
Step 2: Create a Deploy User and a Release Layout
Run the app as an ordinary user, not root. PM2 keeps its process list in the home folder of the user who runs it (~/.pm2), so every PM2 command in this guide runs as deploy:
sudo useradd --create-home --shell /bin/bash deploy
sudo -iu deploy
mkdir -p ~/myapp/releases ~/myapp/shared
Every deploy gets a new folder under releases, and ~/myapp/current is a symlink to the one that serves. The reason: next build begins by deleting everything in .next apart from its cache and a few working folders, while a running server still reads files from there, such as the JavaScript and CSS it sends to browsers. Build in the live folder and requests that need one of those files can fail until the server restarts on the new build. With a folder per release, the old one keeps serving untouched, and a rollback is a symlink change. PM2's own guide to this Capistrano-style layout adds two rules: give the app an absolute cwd that points at current, and run PM2 from a folder that never gets deleted.
Step 3: Decide Where Environment Variables Live
Next.js reads variables at two moments. Anything starting with NEXT_PUBLIC_ is written into the browser JavaScript during next build, so changing it later does nothing until you build again. Other variables stay on the server and are read when server code runs: on every request for a dynamically rendered page, but during the build for a page that is prerendered. So the build and the running server need the same values.
Keep them in one file outside the releases, ~/myapp/shared/.env.production.local, and copy it into each release before building. Next.js loads .env* files both in next build and when next start boots, and create-next-app keeps .env files out of Git. Two traps:
PORTdoesn't work in a.envfile, because the server starts before the file is read. Use--port.- Don't export
NODE_ENV=productionin the deploy user's shell. npm then leaves out devDependencies innpm ci, and the build needs some of them:create-next-appputs TypeScript and Tailwind CSS there. Next.js setsNODE_ENV=productionfornext buildandnext startby itself.
Step 4: Build the First Release and Start It With PM2
Still as deploy, create ~/myapp/shared/.env.production.local with your NAME=value lines, then clone, build and link the first release (use your own repository URL):
R=$HOME/myapp/releases/$(date +%Y%m%d%H%M%S)
git clone https://github.com/you/myapp.git "$R"
cp ~/myapp/shared/.env.production.local "$R/"
cd "$R" && npm ci && npm run build
ln -sfn "$R" ~/myapp/current
-n makes ln replace the symlink itself instead of creating a new link inside the folder it points to. Now create ~/myapp/ecosystem.config.cjs:
module.exports = {
apps: [{
name: 'myapp',
cwd: '/home/deploy/myapp/current',
script: 'node_modules/next/dist/bin/next',
args: 'start --hostname 127.0.0.1 --port 3000',
exec_mode: 'cluster',
instances: 1,
kill_timeout: 10000,
env: { NODE_ENV: 'production' },
}],
};
scriptis thenextcommand from the release's ownnode_modules, which PM2 runs with Node.js, as cluster mode requires.--hostname 127.0.0.1keeps port 3000 off the internet. Without it,next startlistens on0.0.0.0.exec_mode: 'cluster'matters for deploys: PM2's code only reloads without a gap in cluster mode and falls back to a restart in fork mode. One instance, because each Next.js instance keeps its own cache, andrevalidateTag()on one doesn't reach the others unless you add a shared cache handler.kill_timeout: PM2 stops a process with SIGINT and, by default, sends SIGKILL 1.6 seconds later. On SIGINT, Next.js stops taking new connections and finishes open requests andafter()callbacks; its docs recommend allowing 10 to 30 seconds.
Start it, check that it answers, and make it survive reboots:
cd ~/myapp
pm2 start ecosystem.config.cjs
curl -I http://127.0.0.1:3000/
pm2 save
pm2 startup
pm2 save stores the process list. pm2 startup prints a command that starts with sudo env PATH=$PATH:. Run it from your admin account, since deploy has no sudo rights: it installs a systemd service, pm2-deploy, that restores the saved list at boot. pm2 logs myapp --lines 50 shows recent output, and our Node.js production guide with PM2 covers log rotation.
Step 5: Put nginx in Front
The Next.js docs recommend a reverse proxy such as nginx rather than exposing the Node.js server to the internet: it deals with malformed requests, slow connection attacks, payload size limits and rate limiting. From your admin account, create /etc/nginx/sites-available/myapp:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
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/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t && sudo systemctl reload nginx
Each header line fixes something Next.js would otherwise get wrong:
- Host. nginx sends
$proxy_hostby default, here127.0.0.1:3000. Next.js compares the browser'sOriginwith the host it receives to block cross-site Server Actions, so every Server Action would be rejected.$hostpasses your domain. - X-Forwarded-Proto. Without it, Next.js treats every request as plain
http, because that is how nginx talks to it. Once Step 6 is done,$schemeishttps. - X-Forwarded-For. Without it, Next.js fills in the address that connected, which is nginx, so every visitor looks like 127.0.0.1. nginx adds the real client address as the last entry.
You need both listen lines: listen 80 covers IPv4, and nginx's [::] socket accepts only IPv6 by default. If UFW is active, open ports 80 and 443 with sudo ufw allow 'Nginx Full'.
nginx also buffers responses by default, and the Next.js docs say buffering has to be off for streamed pages, such as loading states and Suspense, to arrive in parts. Their fix is a header that nginx obeys, sent from next.config.js (in next.config.ts, add the headers function to your config object):
module.exports = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [{ key: 'X-Accel-Buffering', value: 'no' }],
},
]
},
}
proxy_buffering off; in the location block does the same from nginx's side.
Step 6: Add HTTPS With Certbot
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
Certbot's nginx plugin proves you control the domain over port 80, which is why the DNS record and the firewall come first. It installs the certificate in your nginx configuration and redirects HTTP to HTTPS by default; it stopped asking about the redirect in version 1.4.0. The Ubuntu and Debian packages include a systemd timer that runs Certbot twice a day for renewals, and --dry-run tests renewal against Let's Encrypt's staging server without saving any certificates. Our nginx reverse proxy with SSL guide has more on this setup.
Step 7: Redeploy Without Downtime
Save this as ~/myapp/deploy.sh, run chmod +x ~/myapp/deploy.sh once, and run the script as deploy for each new version:
#!/usr/bin/env bash
set -eu
APP=$HOME/myapp
R=$APP/releases/$(date +%Y%m%d%H%M%S)
git clone https://github.com/you/myapp.git "$R"
cp "$APP/shared/.env.production.local" "$R/"
cd "$R"
npm ci
npm run build
ln -sfn "$R" "$APP/current"
cd "$APP"
pm2 reload myapp
set -e stops the script at the first failing command, so a broken build never reaches the symlink and the old release keeps serving. The build runs in its own folder, so the live site never sees it. After the switch, pm2 reload starts a new worker, which lands in the new release because its cwd is the symlink, waits until it listens, and then sends the old worker SIGINT. Clear out old release folders now and then, but never the one current points to. To deploy on every push, call the script from a GitHub Actions workflow over SSH.
Option: Standalone Output
With output: 'standalone' in your Next.js config, next build also writes .next/standalone: a minimal server.js plus only the files from node_modules that the app uses, so the server needs no npm ci. Copy the static files in, as the docs show:
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
Make the contents of .next/standalone your release folder, and in the PM2 app remove args and set:
script: 'server.js',
env: { NODE_ENV: 'production', PORT: 3000, HOSTNAME: '127.0.0.1' },
server.js reads PORT and HOSTNAME, and listens on 0.0.0.0 when HOSTNAME is not set. It takes only .env and .env.production along, so put server secrets in PM2's env instead. Standalone also lets you build somewhere other than the VPS. Build on Linux x64, because npm installs prebuilt binaries, such as those of the image library sharp, for the platform it runs on.
When the Build Runs Out of Memory
If the VPS runs short of memory during next build, you see one of two things:
- A line that starts with
FATAL ERROR:and ends inJavaScript heap out of memory: Node.js reached its heap limit. If the server has memory to spare, raise the limit withNODE_OPTIONS=--max-old-space-size=1536 npm run build. The value is in MiB, and 1536 is what Node's docs suggest for a machine with 2 GiB, leaving room for other processes. - The build stops without a JavaScript error: the kernel's OOM killer ended it.
sudo journalctl -k | grep -i 'killed process'then shows anOut of memory: Killed processline from the current boot.
Fixes, cheapest first. Add swap, so the build can spill over to disk; swapon's manual calls dd from /dev/zero the most portable way to create the file:
sudo dd if=/dev/zero of=/swapfile bs=1MiB count=2048
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap defaults 0 0' | sudo tee -a /etc/fstab
swapon --show
Then trim the build: the Next.js memory guide suggests productionBrowserSourceMaps: false and experimental.serverSourceMaps: false, and if memory runs out in the "Running TypeScript" step, checking types in CI and skipping that step with typescript.ignoreBuildErrors. Next, build elsewhere with standalone output. Or add RAM: on Space-Node you can upgrade to a bigger VPS plan from the client area.
If It Still Fails
- 502 Bad Gateway: nginx got no valid response from the app. As
deploy, checkpm2 statusandpm2 logs myapp --lines 50, thencurl -I http://127.0.0.1:3000/. Our nginx 502 guide for PM2 apps works through every cause. - "Invalid Server Actions request." The log says the
x-forwarded-hostorhostheader "does not matchoriginheader". Checkproxy_set_header Host $host;. If a proxy in front cannot pass the public host, add the host from the browser's address bar toserverActions.allowedOrigins. - 413 Request Entity Too Large: nginx accepts request bodies up to 1 MB by default. Raise
client_max_body_sizein theserverblock. Server Actions have their own 1 MB default,serverActions.bodySizeLimit. - Down after a reboot: read
sudo journalctl -u pm2-deploy, and runpm2 saveagain whenever you change the process list.
FAQ
Can visitors reach next start directly, without nginx?
It runs, but the Next.js docs advise against exposing the Node.js server directly (see Step 5), and next start cannot serve HTTPS: --experimental-https exists only for next dev. nginx with a Certbot certificate covers both.
How do I roll back a bad deploy?
As deploy, point the symlink at the previous release and reload: ln -sfn ~/myapp/releases/<previous> ~/myapp/current && pm2 reload myapp. That folder still has its own node_modules and .next, so nothing is rebuilt.
Should I run more instances to use all vCores?
Only with a shared cache. Each instance keeps its own in-memory cache (50 MB by default), and revalidateTag() on one instance doesn't clear the others, so visitors can get stale pages. The Next.js self-hosting guide describes a custom cache handler, for example on Redis, for running several instances.
My site is fully static. Do I need PM2 at all?
No. With output: 'export', next build writes HTML, CSS and JavaScript to an out folder that any web server can serve, nginx included. You give up everything that needs a server: Server Actions, ISR, rewrites, redirects, headers, Proxy and the default image optimization.
Does next/image work on a VPS?
Yes, with next start and no configuration. For glibc systems such as Ubuntu and Debian, the self-hosting guide links a note from the image library sharp: it limits its thread use there and recommends an alternative memory allocator such as jemalloc.
Sources
All opened on 30 October 2026:
- Next.js docs: Deploying, Self-hosting, Installation, next CLI, Environment variables, output, serverActions, Memory usage, Static exports, next.config.js
- Next.js 16.4.3 source: build/index.ts, build/utils.ts, start-server.ts, base-server.ts, action-handler.ts, config.ts, create-next-app templates and the v16.4.3 release
- Node.js: Previous releases, release schedule, CLI options, node_errors.cc
- NodeSource: installation instructions and the setup_26.x script
- Distribution packages: nodejs in Ubuntu 24.04 and Debian 12; nginx in Ubuntu 24.04 and Debian 12; nginx.conf for Ubuntu and Debian; the nginx UFW profile; certbot in Ubuntu 24.04 and Debian 12, with their timers for Ubuntu and Debian
- PM2 docs: Quick Start, Application Declaration, Startup Script, Cluster Mode, Graceful Start/Shutdown, Environment Variables, Process Management, Log Management and Capistrano-like deployments
- PM2 source: Common.js, Reload.js, ProcessContainer.js, Startup.js, interpreter.js, paths.js and constants.js
- nginx docs: ngx_http_proxy_module, ngx_http_core_module, Beginner's Guide and command-line parameters
- Certbot and Let's Encrypt: Certbot User Guide, Certbot changelog, Challenge Types
- npm: npm ci; sharp: Installation
- Linux and tools: swapon(8), mkswap(8), mkswap.c, fstab(5), oom_kill.c, journalctl, systemctl, useradd(8), sudo(8), ln(1), dd(1), date(1), cp(1), chmod(1), mkdir(1), tee(1), Bash set builtin, git clone, curl, RFC 9110