How to deploy a Next.js site on a cheap VPS: nginx, Cloudflare Tunnel and PM2

(card blurb):

You have a Next.js app and you want it live on your domain around the clock, not just while npm run dev is open on a laptop. The budget is a VPS at $3-5 a month, self-managed. This is the full path to deploy a Next.js site on a VPS: install Node.js, build the project, run it under PM2, optionally wrap it in nginx, and expose it through a Cloudflare Tunnel with no inbound port and no manual certificate work. It is written from a real deploy on Ubuntu 24.04, 1 vCPU, 2 GB of RAM, kept deliberately small so you can see exactly where it strains.

In short. Install an LTS Node.js from NodeSource, run npm ci and npm run build. The build must happen before the first start: on older Next versions next start crashed without it, on Next 16 the server comes up but serves broken pages. Run next start directly through an ecosystem.config.js under PM2 in fork mode, not via npm start. The public entry is a Cloudflare Tunnel via config.yml: cloudflared holds an outbound connection to Cloudflare, so inbound 80/443 stay closed, certbot is not needed, and the origin IP stays hidden. By default the tunnel goes straight to the app; nginx is a separate layer for disk-served static and rate limiting. Autostart is pm2 startup plus pm2 save after every change, verified with a real sudo reboot.

What you need to deploy a Next.js site on a VPS

A production Next.js app is not a folder of static files (unless you do a full static export, covered below). It is a live Node.js process: next start runs a server that returns HTML and handles server components, API routes, middleware and ISR. That needs three things on top of bare Node.js.

  • A process manager: a program that starts your process, brings it back after a crash, restarts it on a memory limit, and starts it on boot. We use PM2.
  • A reverse proxy: a web server that accepts the request and hands it to the app on localhost, handling headers, static files, timeouts and rate limiting. That is nginx, and it is optional: by default the tunnel talks to the app directly.
  • A public entry point: a way to route internet traffic to the box. The classic option is to open 80/443 and get a TLS certificate. We use a Cloudflare Tunnel (a named tunnel via config.yml): cloudflared holds an outbound connection to Cloudflare and opens no ports at all.
  • PM2. Keeps next start alive, watches memory, sets up autostart. Remove it and after a crash or reboot the site is down until you SSH in by hand.
  • nginx. Serves hashed static assets from disk, sets the X-Forwarded-* headers, rate-limits. Remove it and the app serves its own static assets and works, but you lose disk-served files, a single place for headers, and rate limiting.
  • Cloudflare Tunnel. Holds an outbound connection to Cloudflare, TLS terminates on their side, the origin IP stays hidden. Remove it and you must open 80/443, issue and renew a certificate, and expose the IP.

Fork: your site is fully static. If next.config.js sets output: 'export', the app builds into an out/ folder and needs no Node at runtime. PM2 drops out: serve out/ directly from nginx and point the tunnel at nginx. That is the cheapest option, but with no on-request server rendering, no API routes, no ISR and no middleware. Everything below assumes the normal SSR mode with next start.

Is 1 vCPU and 2 GB of RAM enough

A running Next.js site is light: next-server comes up at around 120 MB RSS (pm2 showed 112 MB in the measurement) and settles at a 200-220 MB plateau under load. The max_memory_restart: '450M' limit from step 4 fires well before memory runs out, so it is a leak guard rather than a normal mechanism. One core comfortably serves several hundred requests per minute for a mid-weight site.

The heavy part is the build. npm run build for even an empty Next 16 app peaks at about 1.2 GB RSS (measured 1,266,740 KB). On a 1 GB plan the build does not complete, even for a hello-world. So 2 GB is the practical minimum, and a real project needs one of three things on top:

  • 1-2 GB of swap (see step 1), which covers the build peak;
  • NODE_OPTIONS=--max-old-space-size=1536 before npm run build, which caps the V8 heap so the compiler does not balloon;
  • a CI build that ships a ready .next folder, so npm run build never runs on the server.

Task

1 GB

2 GB

4 GB

npm run build on the server

fails even for an empty app (~1.2 GB peak)

the minimum; a mid-weight project needs swap or --max-old-space-size

comfortable

next-server at start

-

~120 MB RSS

~120 MB RSS

next-server under load

-

~200-220 MB, well below the 450 MB limit

same

Verdict

ship a CI-built .next only

working minimum for SSR

heavy ISR or many dependencies

This guide uses the 2 GB plan plus 2 GB of swap: the build of a mid-weight project passes and runtime has room left.

Step 1. Prepare the server

Baseline hardening first: SSH key login instead of a password, and a firewall. That is a topic of its own, covered in the fresh-VPS hardening guide. The minimum is to allow SSH in the firewall and enable it:

sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status

  • allow OpenSSH is the ufw profile that opens port 22 (or your custom port, if you changed it).
  • With a Cloudflare Tunnel you do not open 80 or 443 inbound: traffic arrives over the tunnel's outbound connection, and ufw does not touch outbound.

Do not stay on root: create a regular user and grant sudo when needed. The app will live in that user's home directory or in /var/www/app.

The Next.js build is memory-hungry (see the section above), so on 2 GB, and certainly on 1 GB, add swap: a disk-backed paging file that covers the peak.

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

  • fallocate -l 2G reserves a 2 GB file; chmod 600 keeps it private.
  • The /etc/fstab line re-enables swap after a reboot.
  • Check: swapon --show and free -h now list a swap line.

Swap on a cheap disk is slow and is not a substitute for RAM. Treat it as a safety net during the build, not as a running mode.

Step 2. Install Node.js

Ubuntu's default repository ships an old Node.js. Take an LTS from NodeSource, the project's official repository. Version 20 or newer works: the test box took setup_24.x (node v24.21.0), and the production project runs Node 20 LTS, which is also fine. Take the current LTS and check node -v afterwards.

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

  • setup_24.x: substitute the branch you want, setup_20.x, setup_22.x or setup_24.x.
  • curl's -fsSL: quiet, no progress bar, fail on a bad response, follow redirects.
  • sudo -E bash -: the script adds the NodeSource repo and its key; -E keeps your environment variables.
  • npm comes with Node, no separate install.

What you should see:

node -v
npm -v

The first prints a version number such as v20.x.x or v24.x.x, the second the npm version. If node -v returns command not found, the repo was not added: re-read the output of curl ... | bash.

Step 3. Build the project, before the first start

Get the code onto the server, usually a git clone into /var/www/app (create the directory first and hand it to your user: sudo mkdir -p /var/www/app && sudo chown $USER:$USER /var/www/app).

cd /var/www/app
git clone https://github.com/you/repo.git .

Next, environment variables. Secrets (API keys, connection strings) are not committed to the repo; on the server they live in a .env.production file at the project root. Next.js itself reads it, from its own working directory, which matters for step 4.

nano .env.production
chmod 600 .env.production

Install dependencies strictly from the lockfile, then build:

npm ci
npm run build

  • npm ci (clean install) installs packages exactly per package-lock.json, wiping node_modules first. It is reproducible and faster than npm install, which can silently bump versions.
  • npm run build runs next build, compiling the app into the .next directory.

Why this order. On Next 13-14, running next start without a build failed immediately with Error: Could not find a production build in the '.next' directory. On Next 16 the behaviour is softer and sneakier: the server comes up but serves broken pages and logs errors, so the symptom is less obvious while the outcome is the same. The rule is simple: build before every start, and after every code change.

What you should see at the end of the build: a line about a finished compile and a route table with render-type markers (Static, SSG, ƒ for dynamic). Check that .next/BUILD_ID now exists:

cat .next/BUILD_ID

A quick manual check that the app starts at all (port 3000, then Ctrl+C):

node_modules/next/dist/bin/next start -p 3000 &
sleep 3
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "responds"
kill %1

  • curl -fsS <URL>: -f returns a non-zero exit code on an HTTP 4xx/5xx, -sS is quiet but still prints errors. Handy in scripts.
  • responds printed means the server came up and returned a page.

Step 4. PM2: keep the process alive

PM2 is a process manager for Node: it starts the app, brings it back after a crash, restarts it on a memory limit, keeps logs, and can start all of that on boot. Install it globally:

sudo npm install -g pm2

Describe the start config in an ecosystem.config.js. Keep it outside the release directory (in /var/www/, say) so a deploy does not touch it. Here is a working version from the production project; the key-by-key breakdown is below it:

module.exports = {
apps: [{
name: 'web',
script: '/var/www/app/node_modules/next/dist/bin/next',
args: 'start',
cwd: '/var/www/app',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '450M',
min_uptime: '30s',
max_restarts: 15,
exp_backoff_restart_delay: 200,
kill_timeout: 8000,
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
}

  • script: an absolute path to the Next binary (node_modules/next/dist/bin/next), not the .bin/next symlink and not npm. interpreter: 'node' is required, since PM2 does not always infer how to run the file. More on running via npm below; it is the key point.
  • cwd: the process working directory. Required, but not because PM2 reads it. Next.js reads the .env* files from its own working directory (process.cwd()). Without cwd, PM2 starts Next from its own directory, Next never finds .env.production, and the app starts with no variables. If a variable is needed before Next starts (or by the wrapper itself), put it in this config's env: {} block or use env_file.
  • exec_mode: 'fork', instances: 1: a single process, no clustering. PM2's cluster mode spawns process copies, each with its own memory; on 2 GB that only hurts, and Next is usually scaled with several VPS behind a load balancer.
  • autorestart: true: bring the process back after a crash. watch: false: do not watch files and restart on every change (in production that only gets in the way).
  • max_memory_restart: '450M': if the process RSS crosses 450 MB, PM2 restarts it. The next-server working plateau is 200-220 MB, so this is pure leak insurance.
  • min_uptime: '30s' + max_restarts: 15: if the process lives less than 30 seconds, 15 times in a row, PM2 stops retrying. Catches a broken build without an endless loop.
  • exp_backoff_restart_delay: 200: the delay between restarts grows exponentially from 200 ms. It does not hammer the box when the app crashes instantly.
  • kill_timeout: 8000: give next-server 8 seconds to shut down cleanly on SIGTERM before PM2 kills it hard. Fewer dropped requests on a restart.
  • env: variables PM2 always injects. PORT: 3000 is the port the app listens on.

Why not npm start. If script is npm and args is start, PM2 supervises the npm process: a thin wrapper that does almost nothing and sits at 0% CPU. The real next-server runs as its child, and when that child hangs or leaks, PM2 never sees it, because it is watching the wrapper. max_memory_restart then measures npm's memory, not the app's, and does not fire in time. So run the Next binary directly.

Start it and check the state:

cd /var/www/app
pm2 start /var/www/ecosystem.config.js
pm2 status
pm2 logs web --lines 30

What you should see: in pm2 status, the web app is online, the restart column is 0, memory is around 110-120 MB. Final check:

curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "app is live"

If restart climbs while you watch, read pm2 logs web: it is usually a missing environment variable (cwd unset or .env.production missing) or a bug in the code. The memory leaks that make max_memory_restart fire too often get their own article in this series (in progress).

If you use output: 'standalone'. In that mode Next writes a self-contained server to .next/standalone/server.js, and that is what you run: script: '/var/www/app/.next/standalone/server.js'. It expects public/ and .next/static/ next to it; Next does not copy them for you, so copy them at build time. The upside: fewer dependencies on the server and lower memory use.

Step 5. nginx as a reverse proxy (optional)

You can skip this layer. By default (see step 6) the tunnel talks to the app directly and the site works. Add nginx when you need to serve /_next/static/ straight from disk, set shared headers, rate-limit, or route several backends.

sudo apt-get install -y nginx

Behind a Cloudflare Tunnel, proxy_pass sees 127.0.0.1, and without configuration the app gets 127.0.0.1 as the visitor IP. To restore the real address, tell nginx to trust the local connection and take the IP from the Cloudflare header. In /etc/nginx/conf.d/realip.conf:

set_real_ip_from 127.0.0.1;
real_ip_header CF-Connecting-IP;

  • set_real_ip_from 127.0.0.1: trust this source to override the IP (cloudflared connects from localhost).
  • real_ip_header CF-Connecting-IP: Cloudflare puts the visitor's original IP in this header, cloudflared passes it through. After this, $remote_addr and X-Real-IP become the real address.

The main config at /etc/nginx/sites-available/app (edit under sudo):

server {
listen 127.0.0.1:80;
server_name _;
server_tokens off;

# Hashed Next.js assets: straight from disk, cache for a year
location /_next/static/ {
alias /var/www/app/.next/static/;
access_log off;
expires 365d;
add_header Cache-Control "public, immutable";
}

# Everything else: to the app
location / {
proxy_pass http://127.0.0.1:3000;
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
}
}

  • listen 127.0.0.1:80: nginx listens on localhost only. cloudflared connects locally, so nothing is exposed.
  • server_tokens off: drops the nginx version from headers and error pages.
  • location /_next/static/ via alias (not root), both paths with a trailing slash. File names contain a hash, so expires 365d and immutable are safe: a new build means new names. Verified: returns 200 with Cache-Control: public, immutable, max-age=31536000.
  • proxy_http_version 1.1: without it keep-alive to the app does not work and every request opens a new connection.
  • X-Forwarded-Proto https: a constant, not $scheme. Everything that reaches nginx came through the tunnel from Cloudflare, where TLS is already terminated, so the scheme is always https. (No separate map is needed for a single upstream. If you also serve the site over plain http directly, go back to $scheme.)
  • X-Forwarded-Host $host: the app sees the original domain even if it builds links from the host value.
  • proxy_read_timeout 60s: how long to wait for the app's response. 60 seconds is a sane default; for ISR, long regeneration or streaming, tune it per app. If the app uses WebSockets, add the Upgrade/Connection headers; for plain SSR you do not need them.

Enable the config, validate, reload:

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

After nginx -t, expect syntax is ok and test is successful. Check through nginx (put your domain in Host):

curl -fsS -o /dev/null -w '%{http_code}\n' -H 'Host: example.com' http://127.0.0.1/

Expect 200 (or whatever code your site root returns). A 403 on /_next/static/ usually means the nginx worker cannot traverse the directories down to .next/static: check permissions on /var/www/app and its subfolders.

Step 6. Cloudflare Tunnel via config.yml

cloudflared is a small daemon that opens an outbound connection to the Cloudflare edge and holds it. Visitors reach Cloudflare, which forwards requests down that connection to your server. The result: no inbound port, TLS terminates on Cloudflare's side (no certificate needed), and the origin IP is not in DNS.

Prerequisite: the domain is served by Cloudflare (its nameservers are set). Install cloudflared from the Cloudflare repository:

sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared

Check the version: cloudflared --version (2026.8.3 on the test box).

1. Authenticate and create the tunnel. The first command opens a login link for your Cloudflare account and stores a certificate in ~/.cloudflared/. The second creates a named tunnel and prints its UUID; the tunnel credentials go to ~/.cloudflared/<UUID>.json.

cloudflared tunnel login
cloudflared tunnel create web

2. Write /etc/cloudflared/config.yml. Put in your UUID and domain:

tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json
ingress:
- hostname: example.com
service: http://localhost:3000
- service: http_status:404

  • ingress is a top-to-bottom list of rules: which host goes to which local service. The last rule with no hostname is required: it is the default response.
  • service: http://localhost:3000: the tunnel goes straight to the app. If you want nginx in between (step 5), use http://localhost:80.

3. Create the DNS record for each hostname. The command adds a proxied (orange-cloud) CNAME to <UUID>.cfargotunnel.com:

cloudflared tunnel route dns web example.com

4. Write the systemd unit by hand. In /etc/systemd/system/cloudflared.service:

[Unit]
Description=cloudflared tunnel
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target

  • --no-autoupdate: do not self-update; keep the version under apt control.
  • Restart=on-failure + RestartSec=5: bring the daemon back after a failure with a 5 second pause.
  • After/Wants=network-online.target: start once the network is up.

sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared

What you should see:

cloudflared tunnel list
systemctl status cloudflared --no-pager

The list shows the tunnel with a recent connection; the service status is active (running). From your laptop:

curl -sS -I https://example.com

Expect a successful response (the code depends on the site) and a server: cloudflare header. The site opens over https with a valid certificate you never issued.

The two variants to choose between

  • (a) Tunnel straight to the app (service: http://localhost:3000). No nginx at all. The app serves its own static assets; you lose only disk-served files and shared headers, and the site still works.
  • (b) Tunnel to nginx (service: http://localhost:80, nginx on 127.0.0.1:80) which then proxies the app. Use this when rate limiting, disk-served /_next/static/, several backends, or shared headers in one place matter.

nginx with certbot, or Cloudflare Tunnel

Classic: 80/443 + certbot

Cloudflare Tunnel

Inbound ports

80 and 443 open

none except SSH

TLS certificate

you issue and renew it (certbot)

TLS terminates on Cloudflare, renewal is not your problem

Origin IP

visible to anyone resolving the domain

hidden behind Cloudflare

Dependencies in the request path

your server and a CA

plus Cloudflare

Domain requirements

any DNS works

domain must be on Cloudflare

Extra network hop

none

yes, via the Cloudflare edge

Pick when

you need raw TCP, no third party in the path, not on Cloudflare

a cheap VPS where you want web ports closed and the origin hidden

For a small self-managed VPS the tunnel is usually the easier call: less attack surface and nothing to renew. If you need full control over the request path or a non-HTTP protocol, the classic certbot setup is more honest. For a reverse proxy with automatic HTTPS and no separate certbot, see the Caddy write-up.

Firewall when running through a tunnel

Default recommendation: with a tunnel, do not open 80 or 443 at all. Only SSH is exposed. Check sudo ufw status: the list should hold only OpenSSH. The cloudflared outbound connection still works: ufw filters inbound by default, not outbound.

If you did open port 80 for direct-IP checks, restrict it to the Cloudflare ranges, both IPv4 and IPv6:

for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
sudo ufw allow from "$ip" to any port 80 proto tcp
done
sudo ufw deny 80

  • The Cloudflare IP lists change over time: if you rely on this restriction, refresh them on a schedule.
  • ufw deny 80 at the end closes the port to everyone else.

An atomic release, and rollback

Rule: do not build over a running .next. During next build the directory is incomplete for a while, and if the app restarts right then it comes up on a broken build. So build aside and switch atomically, in one operation, with no "build is missing" state in between.

# persistent things live outside releases
sudo mkdir -p /var/www/releases /var/www/shared
# put .env.production into /var/www/shared/ once

REL=/var/www/releases/$(date +%Y%m%d-%H%M)
git clone --depth 1 https://github.com/you/repo.git "$REL"
ln -s /var/www/shared/.env.production "$REL/.env.production"
cd "$REL"
npm ci
npm run build

# check the build on a free port without touching production
node_modules/next/dist/bin/next start -p 3001 &
sleep 3
curl -fsS http://127.0.0.1:3001/ >/dev/null && echo "new build responds"
kill %1

# switch: the symlink flip is instant
ln -sfn "$REL" /var/www/app
pm2 restart web --update-env
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "production updated"

  • ln -sfn replaces the symlink in one operation: there is no moment when /var/www/app does not exist.
  • ecosystem.config.js sits in /var/www/, its cwd is /var/www/app, that is, the path through the symlink. After the switch, pm2 restart re-execs the process with the new code.

Rollback is pointing the symlink back at the previous release:

ln -sfn /var/www/releases/<previous> /var/www/app
pm2 restart web

Keep the last 2-3 releases and delete the rest: ls -1dt /var/www/releases/* | tail -n +4 | xargs rm -rf.

Lightweight approach: an atomic .next swap

If you do not want release directories, and code plus node_modules change rarely, swap only .next. Build in a separate clone at /var/www/app-build, then:

cd /var/www/app
cp -r /var/www/app-build/.next .next.new
mv .next .next.prev && mv .next.new .next
pm2 restart web

mv within one filesystem is atomic: there is never a moment with an empty .next. Rollback: mv .next .next.bad && mv .next.prev .next && pm2 restart web.

About downtime on restart

pm2 reload promises a restart with no drop, but in fork mode with a single process it does not deliver: in the measurement, during reload, 11 of 57 requests returned connection refused and the gap lasted about a second. True zero downtime needs cluster mode with instances: 2 or more behind nginx, or a second app instance behind a load balancer. If a short gap on deploy is acceptable, stay on fork and pm2 restart: it is simpler and more predictable.

Autostart after a reboot

nginx and cloudflared run as systemd services and start on boot by themselves. Check: systemctl is-enabled nginx cloudflared, both answer enabled.

PM2 needs its own setup, two steps:

pm2 startup
# it prints a ready command, for example:
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u deploy --hp /home/deploy
# copy and run it
pm2 save

  • pm2 startup installs a pm2-<user>.service systemd unit (as root that is pm2-root.service, command pm2 startup systemd -u root --hp /root). The unit calls pm2 resurrect on boot.
  • pm2 save writes the current process list to ~/.pm2/dump.pm2. resurrect restores exactly what was in the last pm2 save. Hence the rule: run pm2 save after every change to ecosystem.config.js or the process set, or a reboot restores a stale state, or nothing.

Verify with a reboot, not on paper:

sudo reboot
# reconnect after a minute
pm2 status
curl -sS -I https://example.com

pm2 status shows the app online with a small uptime and restart 0, and the public URL opens. On the test box this matched exactly. The finer points (PATH in the unit, service ordering, delays before the network is up) get their own article in this series (in progress).

What went wrong -> cause -> what to do

Symptom

Cause

What to do

Right after start: 500s or blank pages, logs complaining about .next

next start ran before npm run build (on Next 13-14 an explicit Could not find a production build; on Next 16 the server just comes up on an incomplete build)

npm run build, confirm .next/BUILD_ID exists, pm2 restart web

The app cannot see variables from .env.production

cwd is not set: Next looks for .env files in its working directory and does not find them

set cwd in ecosystem.config.js, pm2 delete web && pm2 start ecosystem.config.js; if a variable is needed before Next starts, put it in the env block

The site does not come back after a reboot

pm2 save not run (or run before the last config change), or the pm2 startup command was not run with sudo

do both, pm2 save, reboot again to confirm

502 Bad Gateway from nginx

the process on :3000 is not responding, usually crashed during the build for lack of memory or from a bug (the 200-220 MB runtime plateau does not reach the 450 MB limit)

pm2 logs web, dmesg | grep -i oom; move the build off the server, add swap, fix the code

521 from Cloudflare

nothing is listening on the local port from ingress: pm2 or nginx is down

pm2 status, systemctl status nginx cloudflared, ss -ltnp

522 from Cloudflare

the server responds too slowly: CPU or memory pressure, or the timeout is too short

top, pm2 logs web; raise proxy_read_timeout for the specific response, deal with the load

523 from Cloudflare

a wrong service, port, or unreachable local name in ingress

match config.yml against the port that actually listens (ss -ltnp)

The restart counter climbs in pm2 status

a broken build or a missing required variable; min_uptime + max_restarts stop the loop

pm2 logs web, fix the root cause, pm2 restart web

An endless http -> https redirect

the app treats the connection as insecure

if the tunnel goes through nginx, send X-Forwarded-Proto https; if it goes straight to the app, enable proxy-header trust in the app (trustHost and similar)

FAQ

How do you deploy a Next.js site on a VPS?

Install an LTS Node.js, copy the code, run npm ci and npm run build. Run next start under a process manager (PM2 or systemd) so the app survives crashes and reboots. Expose it through a Cloudflare Tunnel via config.yml with no open ports; add nginx as a separate layer if you need disk-served static assets, rate limiting or several backends.

Next.js: PM2 or systemd?

Both keep the process alive and start it on boot. PM2 is faster to set up and ships max_memory_restart, exponential restart backoff, autostart and readable logs, which helps when an app leaks memory. systemd is the built-in Linux mechanism with no global npm package, chosen when you want fewer dependencies. You do not need both: PM2 installs a single unit via pm2 startup.

How do you set up an nginx reverse proxy for Next.js?

One server block: location / with proxy_pass http://127.0.0.1:3000 and the Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers; a separate location /_next/static/ with an alias to .next/static and a one-year cache. Behind Cloudflare add real_ip_header CF-Connecting-IP, or the app sees 127.0.0.1 instead of the visitor. Validate with nginx -t and reload nginx.

Can I use a Cloudflare Tunnel instead of nginx?

Yes, and that is the default: the ingress in config.yml points straight at http://localhost:3000. Add nginx as a separate layer when you need disk-served static assets, rate limiting, shared headers or several backends. Without nginx the app serves its own static assets and the site works; you lose the benefits of that layer, not the site.

Is 1 GB of RAM enough for Next.js?

No. Building even an empty Next 16 app peaks at around 1.2 GB RSS, so 1 GB is not enough for a hello-world. 2 GB is the practical minimum; a real project needs swap, NODE_OPTIONS=--max-old-space-size=1536, or a CI build that ships a ready .next. A running site is lighter: about 120 MB at start, 200-220 MB under load.

Why does Next.js not come back after a server reboot?

The process runs under PM2 but the state was never saved. On boot the pm2-<user>.service unit calls pm2 resurrect and restores whatever was in the last pm2 save. Run the printed pm2 startup command with sudo, run pm2 save after every config change, and verify with a real sudo reboot. nginx and cloudflared come up by themselves if their units are enabled.

Do I need certbot with a Cloudflare Tunnel?

No. TLS terminates on Cloudflare, and traffic from cloudflared to the app (or to nginx) goes over localhost. You never install or renew a certificate on the server. If you later open direct access on 443 bypassing the tunnel, then you need certbot.

Takeaways

  • Build before the first next start and after every code change. On Next 16 the server comes up without a build but serves broken pages.
  • In PM2, run next start directly, by absolute path to node_modules/next/dist/bin/next, with interpreter: 'node'. Via npm start PM2 watches the wrapper, not next-server, and max_memory_restart measures the wrong thing.
  • cwd is required: Next reads .env files from its own working directory, not PM2.
  • 1 GB of RAM is not enough even to build an empty app (~1.2 GB peak). 2 GB is the minimum, then swap, --max-old-space-size or a CI build.
  • A running next-server is ~120 MB at start, 200-220 MB under load; max_memory_restart 450M is a leak guard.
  • By default the tunnel goes straight to the app on :3000; nginx is an optional layer for disk-served static, rate limiting and shared headers.
  • Cloudflare Tunnel means no inbound ports, no certbot, a hidden IP; the cost is Cloudflare in the request path and an extra hop.
  • Release atomically: a new release in its own directory plus a symlink flip (or mv the .next directory within one filesystem). Keep the previous release for rollback.
  • pm2 reload/restart in fork mode with one process is a ~1 second gap. Zero downtime needs cluster mode with instances of 2 or more behind nginx.
  • Autostart is pm2 startup + pm2 save after every change, verified with a real reboot.

Next steps

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU