DEV Community

Cover image for Deploying Next.js on a VPS: The 12 Things Nobody Tells You
Ardiansyah Sulistyo
Ardiansyah Sulistyo

Posted on

Deploying Next.js on a VPS: The 12 Things Nobody Tells You

Moving a Next.js app off Vercel and onto a plain Ubuntu VPS usually starts with a painful realization: either your serverless functions are timing out on background jobs, or your client just handed you a strict "you must host this on our infrastructure" requirement.
Deploying the app itself is easy. What trips people up (and what cost me hours of debugging and locking myself out of my own server) is everything around the app.
Here are the 12 things that actually break when you leave the serverless ecosystem, in the order you'll hit them.

1. Next.js needs a process manager, not just npm start

Running npm start in a terminal dies the moment you disconnect. You need
something that keeps the process alive, restarts it on crash, and survives
a reboot. PM2 is the simplest option for a single-server Node deploy.

npm install -g pm2
Enter fullscreen mode Exit fullscreen mode
// ecosystem.config.js
module.exports = {
  apps: [{
    name: "my-app",
    script: "node_modules/.bin/next",
    args: "start",
    cwd: "/var/www/my-app",
    instances: 1,
    exec_mode: "fork",
    autorestart: true,
    max_memory_restart: "512M",
    env: { NODE_ENV: "production", PORT: 3000 },
  }],
};
Enter fullscreen mode Exit fullscreen mode
cd /var/www/my-app && pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd -u YOUR_USER --hp /home/YOUR_USER
Enter fullscreen mode Exit fullscreen mode

That last line is the one people forget - without it, PM2's process list doesn't survive a server reboot.

2. Nginx needs to proxy to the port, not serve the files

Next.js is not a static site (unless you've explicitly exported it as
one). Nginx's job is to forward requests to the Node process, not serve
files from disk:

upstream nextjs_upstream {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://nextjs_upstream;
        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;

        # WebSocket support - required for HMR and any realtime features
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Forgetting the WebSocket upgrade headers breaks more than dev mode

proxy_set_header Upgrade $http_upgrade; and Connection "upgrade" aren't
just for Fast Refresh in development. Any app using WebSockets or
Server-Sent Events in production (chat, live notifications, streaming AI
responses) silently breaks without these two lines. This is one of the
most common "works locally, broken in prod" bugs.

4. client_max_body_size - the silent 413 error

Nginx defaults to a 1MB request body limit. File uploads, image processing
endpoints, and some API routes will fail with a 413 Request Entity Too
Large
- and only in production, since your local dev server has no such
limit. Set it explicitly:

client_max_body_size 25M;
Enter fullscreen mode Exit fullscreen mode

5. proxy_buffering off for streaming responses

If you're streaming a response (Server-Sent Events, streaming LLM
completions via the Vercel AI SDK, etc.), Nginx's default buffering will
hold the entire response before sending it to the client - defeating the
purpose of streaming. Turn it off for the relevant location block:

proxy_buffering off;
Enter fullscreen mode Exit fullscreen mode

6. DNS propagation is the #1 cause of "SSL setup failed"

Before running Certbot, verify your domain's A record actually points at
the server:

dig +short example.com
# should print your server's public IP
Enter fullscreen mode Exit fullscreen mode

If it doesn't match, Certbot's HTTP-01 challenge will fail - not because
of a config error, but because Let's Encrypt can't reach your server at
that domain yet. This single check saves more support tickets than
anything else on this list.

7. UFW must allow SSH before you enable it

This one is a rite of passage:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable   # only after the rules above are in place
Enter fullscreen mode Exit fullscreen mode

Enable UFW before allowing SSH and you'll lock yourself out of your own
server. Always add the SSH rule first.

8. Certbot's Nginx plugin edits your config for you - read the diff

sudo certbot --nginx -d example.com -d www.example.com \
    --agree-tos -m you@example.com --redirect
Enter fullscreen mode Exit fullscreen mode

This adds the SSL server block, the HTTP→HTTPS redirect, and the
certificate paths automatically. Run nginx -t afterward to confirm it's
still valid, and glance at /etc/nginx/sites-available/example.com once
so you know what changed.

9. Certbot renewal isn't automatic until you verify it

Certbot installs a systemd timer, but "installed" isn't the same as
"working." Verify it explicitly:

sudo systemctl status certbot.timer
sudo certbot renew --dry-run
Enter fullscreen mode Exit fullscreen mode

If the dry run fails, your certificate will expire in 90 days without
you knowing until browsers start showing warnings to your users.

10. Fail2Ban's default backend breaks on Ubuntu 24.04

Ubuntu 22.04 and earlier used file-based auth logs (/var/log/auth.log).
24.04 moved logging fully to the systemd journal. If your Fail2Ban jail is
still configured for the file backend, it silently monitors a log that
barely gets written to - and bans nothing.

# /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 4
findtime = 10m
bantime = 1h
Enter fullscreen mode Exit fullscreen mode

Verify it's actually watching:

sudo fail2ban-client status sshd
Enter fullscreen mode Exit fullscreen mode

11. next/image is agonizingly slow without sharp

On Vercel, image optimization just works. On a raw VPS, if you use the <Image /> component without installing sharp, Next.js falls back to a purely JavaScript-based image optimizer. It is incredibly slow and eats up your CPU.

npm install sharp
Enter fullscreen mode Exit fullscreen mode

Add it to your production dependencies. If you don't, your $6 VPS will easily spike to 100% CPU utilization just trying to serve a few optimized avatars.

12. Your app builds fine locally and OOMs on a 1GB VPS

next build on a $6/month 1GB RAM droplet frequently gets killed by the
OOM reaper mid-build - with no clear error, just a dead process. If you
don't have a CI pipeline building elsewhere, add swap before your first
build:

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
Enter fullscreen mode Exit fullscreen mode

The checklist, if you're tired of doing this manually

I got tired of locking myself out of UFW, forgetting to set the WebSocket headers, and missing the systemd fixes. So, I bundled this exact Next.js setup (Nginx vhost, UFW, Fail2Ban, Swap, and Certbot) into a single bash script.

I also added one thing this checklist can't: a Telegram notification the moment anyone logs into the server over SSH.

You can grab the ServerSecure Setup here.

This article is the complete free version of the checklist - nothing here is paywalled. The script is just for when you'd rather spend those 15 terminal commands building your actual product.


Running into something not covered here? Drop it in the comments - I'll add it to the list.

Top comments (0)