Vercel makes deploying Next.js feel like a single git push. It’s great — until you need control over the runtime, a boring box, or a bill that doesn’t scale with a client’s traffic. Running Next.js on your own VPS is not hard. It’s a rail you lay once, then git push forever. Here’s the whole path, copy-pasteable, with the two gotchas that actually bite people.
What you’re building
A standard production layout: your Next.js app runs as a long-lived Node process on 127.0.0.1:3000, and Nginx sits in front, terminating TLS and proxying to it. That’s it. One server, one app, zero orchestration complexity.
Internet → Nginx (:443, TLS) → Next.js server (:3000)
1. Get a server
Grab an Ubuntu LTS VPS — 26.04 “Resolute Raccoon” is the current LTS, released 23 April 2026.[1] Pin an LTS rather than the newest interim release.
- 1–2 GB RAM is fine for a small app.
- A 1GB box will run Next.js but can swap during builds; 2GB makes
next buildcomfortable. We don’t quote a peak build-memory figure until we’ve measured it on a clean box — check your own app withnext buildunder/usr/bin/time -v. - Pick any provider (DigitalOcean, Linode, Vultr, Hetzner). If you mainly run one app, budget matters more than brand.
2. Lock it down first
Before touching Node, secure the box. This is the step people skip and regret at 3am.
# as root, create a deploy user and give it sudo
adduser deploy
usermod -aG sudo deploy
# key-based login only
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
# firewall: SSH, HTTP, HTTPS only
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
Then disable root SSH login and password auth in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
Restart SSH (sudo systemctl restart ssh), and keep your current session open until you’ve verified the new login works.
3. Install Node
Use nvm so you can pin and switch versions rather than depending on the distro’s stale Node.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# reload your shell, then:
nvm install 24
nvm use 22
Install the current Active LTS major — Node 24 as of September 2026; Node 22 moved to Maintenance LTS on 23 September 2026.[2] Using an unsupported major changes build flags and some module behaviour.
You want the Node LTS, and you want it stable. The moment you deploy, the exact Node version becomes part of your app’s contract — pin it in engines in package.json:
"engines": { "node": ">=22" },
4. Get your code onto the server
Three options, in order of how much you want to automate:
Easiest first deploy — scp/rsync:
rsync -avz --exclude node_modules --exclude .next ./ deploy@your-server:/home/deploy/apps/myapp/
Cleaner — git clone from your repo. You’ll want the app in its own directory:
sudo mkdir -p /home/deploy/apps
sudo chown deploy:deploy /home/deploy/apps
git clone https://github.com/you/myapp.git /home/deploy/apps/myapp
cd /home/deploy/apps/myapp
5. Build it (the first big gotcha)
Install and build on the server:
npm ci
npm run build
Two things that make this reliable:
- Use
npm ci, notnpm install— it installs from the lockfile, reproducible, and won’t silently drift your deps in prod. - Set
output: 'standalone'innext.config.js. Without it you ship a hugenode_modulesand a server that has to resolve everything at runtime. With it, Next.js produces a self-contained/.next/standalonefolder with just the runtime code and needed deps.
// next.config.js
module.exports = {
output: 'standalone',
};
When you run from standalone, launch the server from inside /.next/standalone — the server.js there has its own tiny node_modules. That’s the second gotcha: if you pm2 start from the project root instead, you’ll run the wrong bundle and hit weird path errors.
6. Run it with PM2
PM2 keeps the process alive, restarts it on a crash, and starts it on reboot. Skip nohup and raw node &.
npm i -g pm2
# from the project root, or from .next/standalone if you want the tight bundle:
pm2 start server.js --name myapp
Better: declare it in an ecosystem.config.js at the project root so the config is versioned with the app:
module.exports = {
apps: [{
name: 'myapp',
script: 'server.js', // if standalone: '.next/standalone/server.js'
cwd: '/home/deploy/apps/myapp',
instances: 1, // don't blissfully set 'max' on a 1GB box
exec_mode: 'fork',
env: { NODE_ENV: 'production', PORT: 3000 },
}],
};
Then:
pm2 start ecosystem.config.js
pm2 save
pm2 startup # run the printed command to enable boot-time start
PM2’s cluster mode runs several processes and load-balances between them with no code changes.[3] In practice keep exec_mode: 'fork' unless you’ve measured that a single process can’t saturate the CPU — on a small VPS cluster mode often adds memory pressure for no user-visible gain.
7. Nginx as the front door
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
}
Enable it and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
proxy_http_version 1.1 plus the Upgrade/Connection headers are what let WebSockets (and Next.js dev-time HMR) work through the proxy. Without them, connection upgrades silently fail.
8. Free SSL with Let’s Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-run
Certbot nginx plugin rewrites the server block to add TLS and a redirect from :80. Keep the proxy_pass as-is; only the TLS layers change.
9. Wire up auto-deploy
You’re one git push from a deploysmith-grade setup. A GitHub Actions workflow that SSHes in and redeploys:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /home/deploy/apps/myapp
git pull
npm ci
npm run build
pm2 restart myapp
If the app uses output: 'standalone', Next.js writes a self-contained .next/standalone folder that you launch with node .next/standalone/server.js;[4] the snippet above assumes a project-root server.js. Check your exact entry point before locking the workflow.
Troubleshooting the two failures that actually happen
502 Bad Gateway from Nginx. The proxy can’t reach 127.0.0.1:3000. Check the process is actually listening: pm2 list, then curl http://127.0.0.1:3000. Usually port 3000 isn’t bound because the server crashed on boot.
Paths 404 but the page shell loads. You’re running the wrong bundle. output: 'standalone' means the app must be started from /.next/standalone and the .next/static and public folders copied next to it, or the asset paths won’t resolve. If you keep root-level server.js, drop standalone.
The “that’s it” summary
A VPS gives you the same Next.js runtime you’d get anywhere, plus the ability to set the Node version, add a cache, or run a second process. The path is short: secure the box, install Node, build with standalone, run with PM2, proxy with Nginx, TLS with Certbot. The only genuinely version-sensitive pieces are the Node major and the PM2 process settings — nail those and the rest is mechanical.
Sources
- Ubuntu 26.04 LTS “Resolute Raccoon” — Canonical release announcement (23 Apr 2026). https://ubuntu.com/blog/2026/04/23/canonical-releases-ubuntu-26-04-lts-resolute-raccoon — checked 2026-09-27
- Node.js Releases — Active LTS / Maintenance schedule. https://nodejs.org/en/about/previous-releases — checked 2026-09-27
- PM2 — Cluster Mode (load balancing across processes). https://pm2.keymetrics.io/docs/usage/cluster-mode/ — checked 2026-09-27
- Next.js —
output: 'standalone'(next.config.jsreference). https://nextjs.org/docs/app/api-reference/config/next-config-js/output — checked 2026-09-27