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 build comfortable. We don’t quote a peak build-memory figure until we’ve measured it on a clean box — check your own app with next build under /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, not npm install — it installs from the lockfile, reproducible, and won’t silently drift your deps in prod.
  • Set output: 'standalone' in next.config.js. Without it you ship a huge node_modules and a server that has to resolve everything at runtime. With it, Next.js produces a self-contained /.next/standalone folder 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

  1. 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
  2. Node.js Releases — Active LTS / Maintenance schedule. https://nodejs.org/en/about/previous-releases — checked 2026-09-27
  3. PM2 — Cluster Mode (load balancing across processes). https://pm2.keymetrics.io/docs/usage/cluster-mode/ — checked 2026-09-27
  4. Next.js — output: 'standalone' (next.config.js reference). https://nextjs.org/docs/app/api-reference/config/next-config-js/output — checked 2026-09-27