Vercel is great until you look at the bill. The Hobby tier is free but crippled for production. Pro is $20/seat/month and includes 1 TB of data transfer a month; traffic past that is billed at on-demand regional rates. For most Next.js apps, a $6 VPS with PM2 and Nginx handles the same load for a fraction of the cost. This guide walks through the exact setup for Next.js 16 on Ubuntu 24.04, including the one step most tutorials skip that causes broken CSS in production.
This post contains affiliate links. If you purchase through our links, we may earn a commission at no extra cost to you.

Vercel vs self-hosting: the cost case
Before touching a terminal, it’s worth being honest about when each option makes sense.
| Scenario | Vercel | Vultr VPS ($6/mo) |
|---|---|---|
| Solo dev, low traffic | $0 (Hobby) | $6/mo |
| Pro tier (1 seat) | $20/mo | $6/mo |
| Team (5 seats) | $100/mo | $6/mo (no per-seat fee) |
| 1 TB bandwidth/month | Included in Pro ($20/mo); regional rates beyond 1 TB | Included in $6 plan |
| Build minutes | 6,000 min/mo cap | Unlimited (your CPU) |
The breakeven is fast. Once you’re past a small side project or need multiple team members without per-seat billing, self-hosting wins on cost. A site at 1 TB/month bandwidth still fits inside Vercel Pro’s included transfer, so it costs $20 on Vercel vs $6 on Vultr, and past 1 TB Vercel adds regional per-GB charges while the $6 Vultr plan includes 2 TB.
Vercel’s advantages are real though: zero DevOps, edge CDN built-in, and first-party Next.js integration by the same team. If you’d rather push code and never think about servers, Vercel at $20/mo is fair value. This guide is for everyone else.
Vultr‘s $6 High Performance plan (1 vCPU AMD EPYC-Genoa, 1 GB RAM, 25 GB NVMe SSD, 2 TB transfer, IPv4 included) is the baseline for this guide. New accounts get $300 in free credit valid for 30 days, which means you can follow this entire guide without spending anything.
For context on how Vultr compares across its full plan lineup, see our Vultr pricing breakdown.
Stack overview
- Next.js 16.2.7 (current stable, June 2026): requires Node.js 20.9.0+
- PM2 7.0.1: process manager with auto-restart, cluster mode, systemd integration
- Nginx: reverse proxy, SSL termination, static asset caching
- Certbot: free Let’s Encrypt SSL
- Ubuntu 24.04 LTS: recommended OS for this setup
Why PM2 instead of Docker? For a single Next.js app on a 1 GB VPS, PM2 uses ~100-150 MB less RAM than Docker’s overhead. Docker shines for multi-service isolation; for just Node.js + Nginx, PM2 is cleaner and simpler.
Step 1: server setup + swap (do this first)
Deploy a Vultr Ubuntu 24.04 instance. SSH in as root, then add swap immediately, because next build will OOM-kill on 1 GB RAM without it:
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
💡 Tip: Unsure how much swap your server needs? Our free Linux Swap Calculator recommends a size based on your RAM.
Then create a non-root user:
adduser yourname
usermod -aG sudo yourname
# Log out and back in as yourname
Update packages:
sudo apt update && sudo apt upgrade -y
Step 2: install Node.js via nvm
Do not use Ubuntu’s default apt install nodejs, because it installs an outdated version. Next.js 16 requires Node.js 20.9.0+. Use nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
node -v # Must show v22.x.x
Then install PM2:
npm install -g pm2
Step 3: clone, configure, and build
Configure standalone mode (in your repo; commit this before deploying)
Edit next.config.js to enable standalone output and streaming-compatible Nginx headers:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async headers() {
return [
{
source: '/:path*{/}?',
headers: [{ key: 'X-Accel-Buffering', value: 'no' }],
},
]
},
}
module.exports = nextConfig
The X-Accel-Buffering: no header is required if you use React Suspense streaming or Server Component streaming through Nginx. Without it, responses buffer and streaming breaks.
Clone and build on the server
mkdir ~/apps && cd ~/apps
git clone https://github.com/your-username/your-nextjs-app.git
cd your-nextjs-app
npm ci
npm run build
Copy static assets (critical: most tutorials skip this)
After a standalone build, static files must be manually copied or production CSS/JS returns 404:
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
This is the most common cause of “app loads but looks broken” on first deployment.

Step 4: PM2 ecosystem config
Create ecosystem.config.js at the project root:
module.exports = {
apps: [
{
name: 'nextjs-app',
script: '.next/standalone/server.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '500M',
env_production: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '127.0.0.1',
},
},
],
}
instances: 'max' uses cluster mode, one worker per CPU core. On Vultr’s 1 vCPU plan, that’s one worker, but the config scales automatically if you upgrade to a larger plan later. The script points at the standalone server.js from Step 3, and HOSTNAME keeps it listening on localhost only, behind Nginx.
Start the app and persist across reboots:
pm2 start ecosystem.config.js --env production
pm2 save
pm2 startup # Copy and run the command it outputs
Verify it’s running: pm2 list should show your app with status online.
Step 5: Nginx reverse proxy
sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/yourdomain.com
Paste this config (replace yourdomain.com with your actual domain):
upstream nextjs_upstream {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
gzip on;
gzip_types text/plain application/json application/javascript text/css;
location /_next/static {
proxy_pass http://nextjs_upstream;
expires max;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://nextjs_upstream;
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;
proxy_cache_bypass $http_upgrade;
}
}
💡 Tip: Want a head start on the config? Our free Nginx Config Generator builds a working server block you can adapt to your setup.
Enable the config:
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t # Must return "test is successful"
sudo systemctl reload nginx
Step 6: SSL with Certbot
Your domain’s DNS A-record must already point to the Vultr server IP before this step, because Certbot validates via HTTP.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
sudo certbot renew --dry-run # Verify auto-renewal works
Certbot patches your Nginx config automatically and installs a systemd timer for renewal. On Ubuntu 24.04 with Nginx, HTTP/2 is enabled by default on SSL listeners, so no extra config is needed.
Updating your deployment
When you push code changes:
cd ~/apps/your-nextjs-app
git pull origin main
npm ci
npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
pm2 reload ecosystem.config.js --env production # Zero-downtime reload
pm2 reload restarts workers one at a time, so existing requests keep being served while new code rolls in.
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
OOM during npm run build |
1 GB RAM exhausted | Add 2 GB swap first; or: NODE_OPTIONS='--max-old-space-size=1024' npm run build |
| CSS/JS 404 in production | Static assets not copied after standalone build | cp -r .next/static .next/standalone/.next/static and cp -r public .next/standalone/public |
| 502 Bad Gateway | PM2 not running or wrong port | pm2 list; curl http://localhost:3000 on server; check Nginx error log |
NEXT_PUBLIC_ vars not updating |
Baked into bundle at build time | Must rebuild after changing any NEXT_PUBLIC_ variable |
| App doesn’t start after reboot | PM2 startup not configured | pm2 startup → run the output command → pm2 save |
| Node.js version too old | Ubuntu apt nodejs is outdated |
Use nvm to install Node.js 22 explicitly |
| Streaming broken through Nginx | Nginx buffering enabled | Add X-Accel-Buffering: no header in next.config.js |
The $6 plan is enough once you outgrow Vercel’s free tier
For a Next.js app that’s outgrown Vercel’s free tier (or where you’d rather own the infrastructure), Vultr’s $6 plan with PM2 + Nginx is a clean, production-grade setup. The critical details that most tutorials miss: add swap before building, copy static assets after a standalone build, and configure PM2 startup so your app survives a reboot.
This stack scales reasonably well on a single VPS. When you outgrow 1 GB RAM (usually around 100+ concurrent users with a database), upgrade to the $12/mo 2 GB plan. For the full picture on Vultr’s plan options, see our Vultr review.
Vultr has a $300 free credit offer for new accounts, valid for 30 days, which is enough time to build, test, and ship without paying anything.
Next.js version, PM2 version, and Vultr pricing verified June 22, 2026.

