Deploy a Next.js 16 App on a VPS with Nginx, systemd or PM2, and HTTPS

A working guide to running Next.js 16 on your own VPS: Node.js LTS, build-time versus runtime environment variables, a systemd unit and PM2 alternative, an Nginx server block with certbot HTTPS, the standalone output option, logs, and a two-port release script.

Deploy a Next.js 16 App on a VPS with Nginx, systemd or PM2, and HTTPS article cover image

To deploy a Next.js 16 app on a VPS, run next build on the server (or in CI), start the app with next start bound to 127.0.0.1, keep it running with a process manager such as systemd or PM2, and put Nginx in front as a reverse proxy that terminates HTTPS with a Let's Encrypt certificate from certbot. The Next.js self-hosting guide recommends exactly this shape: a reverse proxy in front of the Node.js server rather than exposing Node directly to the internet.

This guide gives you working configuration for each piece: Node.js, the build, environment variables, a systemd unit (with a PM2 alternative), an Nginx server block, HTTPS, logs, and a release process that switches versions with only a brief interruption, if any. It assumes Ubuntu 24.04 or 26.04 LTS, a server that has already been hardened (a sudo user, SSH keys, ufw allowing SSH, 80 and 443), and a domain whose DNS A/AAAA records point at the server.

What you are building

`text

Browser ──HTTPS──> Nginx :443 ──HTTP──> Next.js (node) 127.0.0.1:3001 or :3002

│ managed by systemd

└─ certbot renews TLS certificates

`

Nginx handles TLS, HTTP-to-HTTPS redirects, request size limits and slow clients. Next.js only ever sees local connections. Two ports are used so that a new release can start alongside the old one before traffic is switched; more on that in the release section.

Deploying with next start (or the standalone server) supports every Next.js feature, including Server Actions, Proxy, image optimisation and incremental static regeneration, according to the Next.js deployment docs. A static export would not need Node.js at all, but loses those features.

Prepare the server: Node.js, a service user and directories

Next.js 16 requires Node.js 20.9 or later. Node.js 20 reached end of life in 2026, and the Node.js project states that production applications should only use Active LTS or Maintenance LTS releases, so install a current LTS line (22 or 24 at the time of writing) using the official instructions on nodejs.org, or your distribution's package if it provides a supported version. Check:

`bash

node -v

npm -v

`

Create a dedicated, unprivileged user to run the app, and a directory layout that keeps each release separate:

`bash

sudo adduser --system --group --home /var/www/myapp --shell /usr/sbin/nologin myapp

sudo mkdir -p /var/www/myapp/releases /etc/myapp

sudo chown -R myapp:myapp /var/www/myapp

sudo apt install nginx

`

The layout will look like this:

`text

/var/www/myapp/

releases/

20261003-1410/ # one directory per release

20261001-0930/

slot-3001 -> releases/20261003-1410 # symlink per port

slot-3002 -> releases/20261001-0930

/etc/myapp/myapp.env # secrets, outside the code

/etc/nginx/myapp-upstream.conf # which port is live

`

Environment variables and the build

Next.js treats environment variables in two different ways, and getting this wrong is the most common self-hosting bug. From the environment variables guide and the self-hosting guide:

  • Variables prefixed with `NEXT_PUBLIC_` are inlined into the browser JavaScript at next build. Changing them requires a new build.
  • Other variables are server-only. They are read at runtime during dynamic rendering, but pages that are prerendered at build time read them during next build. So the build needs the same environment as the running app.
  • next build and next start load .env, .env.production and .env.local files from the project directory, and values already set in the process environment take priority over files. Never commit secrets to the repository.

Keep production secrets in one root-owned file outside the code:

`bash

sudo tee /etc/myapp/myapp.env > /dev/null <<'EOF'

DATABASE_URL=postgres://myapp:[email protected]:5432/myapp

NEXT_PUBLIC_SITE_URL=https://www.example.com

EOF

sudo chown root:myapp /etc/myapp/myapp.env

sudo chmod 640 /etc/myapp/myapp.env

`

You do not need to set NODE_ENV: Next.js sets it to production for next build and next start. Leaving it out of this file also matters for the next step, because npm ci skips devDependencies (such as TypeScript or Tailwind, which the build needs) when NODE_ENV=production is set.

Build a release. This example clones from Git into a timestamped directory, loads the environment file for the build, and installs dependencies from the lockfile:

`bash

REL=/var/www/myapp/releases/$(date +%Y%m%d-%H%M)

sudo -u myapp git clone --depth 1 --branch main https://github.com/your-org/your-app.git "$REL"

sudo -u myapp bash -c "cd $REL && set -a && . /etc/myapp/myapp.env && set +a && npm ci && npm run build"

`

npm run build runs next build, assuming your package.json has the standard "build": "next build" and "start": "next start" scripts. For a private repository, use a read-only deploy key for the myapp user rather than personal credentials. Building on the server needs memory; if builds are killed on a small VPS, add swap or build in CI and copy the result to the server.

The standalone option. Setting output: 'standalone' in next.config makes the build produce .next/standalone, a folder containing a minimal server.js and only the node_modules files the app needs, per the [output documentation](https://nextjs.org/docs/app/api-reference/config/next-config-js/output). It is most useful when you build in CI and ship an artefact, since the server then needs no npm ci. Two details matter: the public and .next/static folders are not copied automatically (cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/), and you start it with node server.js using the PORT and HOSTNAME environment variables instead of next start. If you build on the server, plain next start is simpler.

Run it with systemd (or PM2)

Next.js does not prescribe a process manager; its docs only require that the server is started with next start (or server.js) and stopped gracefully with SIGTERM or SIGINT, allowing 10 to 30 seconds for in-flight requests and after() callbacks to finish. systemd is already on every Ubuntu server and needs nothing extra, so it is used here. A template unit (the @ in the name) lets you run the same app on two ports.

Create /etc/systemd/system/[email protected]:

`ini

[Unit]

Description=myapp Next.js server on port %i

After=network.target

[Service]

Type=simple

User=myapp

Group=myapp

WorkingDirectory=/var/www/myapp/slot-%i

EnvironmentFile=/etc/myapp/myapp.env

Environment=PORT=%i

ExecStart=/usr/bin/node node_modules/next/dist/bin/next start -H 127.0.0.1 -p %i

Restart=on-failure

RestartSec=5

KillSignal=SIGTERM

TimeoutStopSec=30

NoNewPrivileges=true

PrivateTmp=true

[Install]

WantedBy=multi-user.target

`

Notes on the unit:

  • %i is the instance name, here the port. myapp@3001 runs from /var/www/myapp/slot-3001 on port 3001.
  • -H 127.0.0.1 matters. next start binds to 0.0.0.0 by default, which would expose the app directly if the firewall were ever misconfigured.
  • Check the path to Node with which node and adjust ExecStart if it is not /usr/bin/node.
  • EnvironmentFile and the other directives are documented in systemd.exec. Values set here override any .env files in the project.

Point the first slot at your release and start it:

`bash

sudo ln -sfn "$REL" /var/www/myapp/slot-3001

sudo systemctl daemon-reload

sudo systemctl enable --now myapp@3001

curl -I http://127.0.0.1:3001

`

PM2 alternative. PM2 is popular in Node.js teams and adds a dashboard (pm2 monit) and its own log handling. An equivalent ecosystem.config.js in the release directory:

`js

module.exports = {

apps: [

{

name: "myapp",

script: "node_modules/next/dist/bin/next",

args: "start -H 127.0.0.1 -p 3001",

cwd: "/var/www/myapp/slot-3001",

exec_mode: "fork",

instances: 1,

env: { NODE_ENV: "production" },

kill_timeout: 30000,

max_memory_restart: "800M",

},

],

};

`

Start it as the app user with pm2 start ecosystem.config.js, then run pm2 startup and the command it prints, followed by pm2 save, so the process list survives reboots, as described in the PM2 startup docs. PM2 does not read /etc/myapp/myapp.env by itself, so either load it into the shell before pm2 start or rely on .env.production in the project directory. Pick one process manager; running the same app under both leads to port conflicts.

Nginx reverse proxy and HTTPS with certbot

First, a file that says which port is live. Using a separate file means a release only changes one line:

`bash

echo 'server 127.0.0.1:3001;' | sudo tee /etc/nginx/myapp-upstream.conf

`

Then create /etc/nginx/sites-available/myapp:

`nginx

map $http_upgrade $connection_upgrade {

default upgrade;

'' close;

}

upstream myapp {

include /etc/nginx/myapp-upstream.conf;

}

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

client_max_body_size 10m;

location / {

proxy_pass http://myapp;

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 $connection_upgrade;

proxy_read_timeout 60s;

}

}

`

The map and Upgrade/Connection headers follow the Nginx WebSocket proxying guide and keep WebSocket connections (used by some libraries) working. X-Forwarded-Proto tells the app the original request was HTTPS. Raise client_max_body_size if users upload larger files.

Enable the site, test the configuration and reload:

`bash

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp

sudo rm -f /etc/nginx/sites-enabled/default

sudo nginx -t && sudo systemctl reload nginx

sudo ufw allow 'Nginx Full'

`

Now add HTTPS. The certbot instructions for Nginx install it as a snap:

`bash

sudo snap install --classic certbot

sudo ln -s /snap/bin/certbot /usr/local/bin/certbot

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

sudo certbot renew --dry-run

`

certbot obtains the certificate, adds listen 443 ssl and the certificate paths to your server block, and sets up an HTTP-to-HTTPS redirect. Renewal runs automatically from a systemd timer; the dry run confirms it will work. To enable HTTP/2, add http2 on; inside the HTTPS server block. That directive needs Nginx 1.25.1 or later, which Ubuntu 26.04's package meets; on Ubuntu 24.04's Nginx 1.24, use listen 443 ssl http2; instead.

Streaming. App Router streaming (loading.js, Suspense) needs the proxy not to buffer responses. The self-hosting guide recommends sending an X-Accel-Buffering: no header from Next.js via headers() in next.config; Nginx honours that header per response. Alternatively, add proxy_buffering off; to the location block.

Logs

With systemd, everything the app writes to stdout and stderr goes to the journal:

`bash

sudo journalctl -u myapp@3001 -f # follow live

sudo journalctl -u 'myapp@*' --since "1 hour ago"

sudo journalctl -u 'myapp@*' -p err # errors only

`

The journal rotates itself; to cap its size, set SystemMaxUse=500M (or similar) in /etc/systemd/journald.conf and restart systemd-journald. Nginx writes to /var/log/nginx/access.log and error.log, which Ubuntu's Nginx package rotates with logrotate. A 502 Bad Gateway in the Nginx error log almost always means the Node process is down or on a different port than the upstream file says.

With PM2, logs live in ~/.pm2/logs for the user running PM2; view them with pm2 logs myapp and install rotation with pm2 install pm2-logrotate, per the PM2 log management docs. Without rotation, PM2 logs grow until the disk fills.

Whichever you use, avoid logging secrets, full request bodies or personal data you do not need. Logs with IP addresses and email addresses are personal data under the GDPR and UK GDPR, so keep retention short.

A simple low-downtime release

A plain systemctl restart stops the old process before the new one is ready, which can mean a few seconds of 502 errors while Next.js boots. Two ports avoid that: start the new release on the idle port, check it responds, switch Nginx, then stop the old one. nginx -s reload (or systemctl reload nginx) starts new worker processes with the new configuration and lets old workers finish their open requests, so the switch itself does not drop connections.

Save this as /usr/local/bin/myapp-release and make it executable (sudo chmod +x):

`bash

#!/usr/bin/env bash

set -euo pipefail

REL="$1" # path to a built release directory

LIVE=$(grep -o '[0-9]\{4\}' /etc/nginx/myapp-upstream.conf)

NEXT=$([ "$LIVE" = "3001" ] && echo 3002 || echo 3001)

ln -sfn "$REL" "/var/www/myapp/slot-$NEXT"

systemctl restart "myapp@$NEXT"

# Wait up to 60 seconds for the new instance to answer

for i in $(seq 1 30); do

if curl -fsS -o /dev/null "http://127.0.0.1:$NEXT/"; then break; fi

sleep 2

[ "$i" = 30 ] && { echo "New release failed health check"; systemctl stop "myapp@$NEXT"; exit 1; }

done

echo "server 127.0.0.1:$NEXT;" > /etc/nginx/myapp-upstream.conf

nginx -t && systemctl reload nginx

systemctl enable "myapp@$NEXT"

systemctl disable --now "myapp@$LIVE"

echo "Live on port $NEXT (was $LIVE)"

`

Run it after building a release: sudo myapp-release /var/www/myapp/releases/20261003-1410. If the new build fails its health check, the old one keeps serving. To roll back, run the script again with the previous release directory. Prune old releases occasionally, keeping the last few for rollback.

Points to be aware of:

  • Version skew. A visitor who loaded a page from the old release may request JavaScript chunks or call Server Actions that do not exist in the new one. Setting a [deploymentId](https://nextjs.org/docs/app/api-reference/config/next-config-js/deploymentId) in next.config (for example from the Git commit hash) makes Next.js detect the mismatch and do a full page reload instead of failing.
  • Two instances, briefly. Both run for a few seconds during the switch. If you ever run them side by side for longer, the self-hosting guide notes they need the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY and, for shared caching and revalidation, a shared cache handler.
  • Database migrations must be compatible with both the old and new code for the length of the switch. Add columns before the code that uses them; remove them a release later.
  • The ISR cache lives on local disk inside each release's .next directory, so a new release starts with a fresh cache, which is usually what you want.

The release itself is the last step of a wider process: changes should already have been tested on a staging server with production-like settings. The staging vs production deployment workflow covers approvals, testing and rollback planning around this script, and if you are still deciding whether a VPS is the right home for the app at all, see Next.js vs WordPress for business websites for the platform trade-offs.

Related: secure the server first, choosing between shared, VPS and managed hosting.

Running Next.js on your own VPS gives you predictable costs and full control, but also makes you responsible for Node.js updates, certificates, logs and releases. If you want that work handled, website development covers building and deploying Next.js applications, including server setup and a release process your team can run.

Related posts

Shared vs VPS vs Managed Hosting for a Small Business Website or Store article cover image
Hosting, VPS & DevOps••11 min read

Shared vs VPS vs Managed Hosting for a Small Business Website or Store

A plain comparison of shared hosting, VPS and managed hosting or PaaS for small business sites and online stores: responsibilities, isolation and performance, the signs a WooCommerce store or Next.js app has outgrown shared hosting, GDPR data residency, and a decision table.

Read article →

How to Secure a New Ubuntu VPS: A Setup Checklist for Business Websites article cover image
Hosting, VPS & DevOps••11 min read

How to Secure a New Ubuntu VPS: A Setup Checklist for Business Websites

A step-by-step hardening checklist for a fresh Ubuntu 26.04 or 24.04 LTS VPS that will host a business website, with copy-paste commands for SSH keys, ufw, unattended-upgrades, fail2ban, time sync, swap, monitoring and backups.

Read article →

Business Email Compromise and Invoice Fraud: How Small Businesses Stop It article cover image
Cyber Security••12 min read

Business Email Compromise and Invoice Fraud: How Small Businesses Stop It

A defensive guide to business email compromise and invoice fraud for small businesses: how the scams work at a high level, the red flags, the payment and email controls that stop them, and what to do in the first hours after a fraudulent transfer in the US, UK and EU.

Read article →

Author

Anushka Dahanayake

Anushka Dahanayake builds SEO-focused websites, e-commerce platforms, dashboards, and automation systems for businesses worldwide.