How to Deploy Next.js on Ubuntu 24.04 VPS: Production Setup with PM2 + Nginx
Next.js is the most widely used React framework for building production web applications, and while Vercel offers a zero-config hosting platform, self-hosting on your own VPS gives you predictable costs, no cold starts, no function concurrency limits, and complete control over the runtime. This guide walks you through deploying a Next.js 15 application on Ubuntu 24.04, from provisioning Node.js 20 LTS to running the app in cluster mode behind an Nginx reverse proxy with TLS.
Skip the setup? Deploy a Next.js-ready VPS in one click. Launch a CloudCore Starter now and have your app live in under 10 minutes.
Table of Contents
Why Self-Host Next.js Instead of Vercel?
Vercel is an excellent developer experience, but for production workloads at any meaningful scale it becomes the most expensive part of your stack. Self-hosting on a VPS offers concrete advantages:
- Predictable flat-rate pricing -- A CloudCore Starter VPS costs the same whether you serve 10,000 or 10 million requests. Vercel bills per function invocation, per GB of bandwidth, per ISR write, and per image transformation. Bills in the hundreds or thousands of dollars per month are common once traffic scales.
- No function concurrency limits -- Vercel's Hobby tier caps concurrent serverless function executions, and even the Pro tier has regional limits. A self-hosted Node.js process handles as many concurrent requests as the event loop and CPU allow.
- No cold starts -- Your Node.js process stays warm permanently. First-request latency on a self-hosted Next.js app is typically 20-80 ms versus 200-1000 ms for cold Vercel functions.
- Full runtime control -- Install any native dependency, run background workers, open long-lived sockets, connect to databases without a connection pooler, use Redis on the same host. None of this is possible on Vercel's serverless runtime.
- No function timeouts -- Vercel kills requests after 10-60 seconds depending on plan. A self-hosted server can run a 5-minute export, stream a large file, or hold a WebSocket connection indefinitely.
- Data residency and compliance -- Choose exactly which country your server lives in. This matters for GDPR, HIPAA, and other regulatory frameworks that care about where user data is processed.
- No vendor lock-in -- The Next.js app you deploy here is identical to what runs on Vercel. You can move providers at any time with a single
git pushto a new server.
Cost Comparison at Scale
| Traffic Level | Vercel Pro | Self-Hosted (CloudCore Starter) |
|---|---|---|
| 100K requests/mo, 50 GB bandwidth | $20/mo | EUR 7.99/mo |
| 1M requests/mo, 500 GB bandwidth | ~$60-120/mo | EUR 7.99/mo |
| 10M requests/mo, 2 TB bandwidth | ~$400-800/mo | EUR 7.99/mo |
| Image transformations included | 5K/mo, then $5 per 1K | Unlimited (via Sharp) |
| ISR writes included | 1M/mo, then $2 per 1M | Unlimited |
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server (PuTTY on Windows, or the built-in terminal on macOS/Linux)
- At least 2 GB of RAM for small apps; 4 GB+ recommended because
next buildis memory-intensive - At least 10 GB of free disk space (Next.js builds,
node_modules, and ISR caches add up quickly) - A domain name pointed at your server's IP (A record) for TLS setup
- A Next.js 13, 14, or 15 application ready to deploy (this guide assumes the App Router)
Recommended Plan: CloudCore Starter>
For most Next.js applications, the CloudCore Starter plan provides ample headroom:>
- 4 vCPU cores
- 6 GB RAM
- 75 GB NVMe SSD
- Unmetered bandwidth
- EUR 7.99/month>
The 6 GB of RAM matters -- next build can consume 2-4 GB on medium-sized apps, and running out of memory during a production build is the single most common deployment issue.Connect to your server via SSH to get started:
ssh root@your-server-ipStep 1: Update System Packages
Start by updating the package index and upgrading installed packages so that the Node.js installer pulls clean dependencies:
sudo apt update && sudo apt upgrade -yInstall the build-essential packages. Sharp (for Next.js image optimization) and a few other native modules require a compiler:
sudo apt install -y build-essential curl git ca-certificates gnupgIf the kernel was upgraded, reboot before continuing:
sudo rebootStep 2: Install Node.js 20 LTS
Next.js 15 requires Node.js 18.18 or later, but Node.js 20 LTS is the recommended production version -- it is supported until April 2026 and delivers meaningful performance improvements over Node 18. Avoid Ubuntu's default apt install nodejs (ships outdated versions) and use NodeSource's official repository instead.
Add the NodeSource 20.x repository and install Node:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify the installation:
node --version
npm --versionExpected output:
v20.18.0
10.8.2Enable Corepack for pnpm/Yarn
Next.js projects commonly use pnpm or Yarn. Enable Corepack (bundled with Node 20) to manage these without a global install:
sudo corepack enableNow pnpm and yarn commands will work automatically, pinned to whatever version your package.json specifies in packageManager.
For a deeper walkthrough of Node.js installation options, see our full guide: How to Install Node.js on Ubuntu 24.04.
Step 3: Clone or Upload Your Next.js Application
Create a non-root user to own the application (running Node as root is a security risk):
sudo adduser --disabled-password --gecos "" nextjs
sudo usermod -aG sudo nextjs
sudo mkdir -p /var/www
sudo chown nextjs:nextjs /var/wwwSwitch to the new user and clone your repository:
sudo -iu nextjs
cd /var/www
git clone https://github.com/your-org/your-nextjs-app.git app
cd appInstall dependencies. Use --production=false (or omit NODE_ENV=production) so dev dependencies needed for the build are included:
npm ciOr if the project uses pnpm:
pnpm install --frozen-lockfilenpm ci (and --frozen-lockfile) installs exactly what the lockfile pins, which is what you want on a server -- never run npm install in production, as it can drift from the tested versions.
Step 4: Enable Standalone Output Mode
This is the single most important configuration change for self-hosting Next.js. By default, next build produces a .next folder that requires the entire node_modules tree at runtime (typically 300-800 MB). The standalone output mode traces every import and produces a self-contained .next/standalone directory with only the files the app actually uses -- usually 30-80 MB including a minimal node_modules.
Open next.config.js (or next.config.mjs / next.config.ts) and add output: 'standalone':
/* @type {import('next').NextConfig} / const nextConfig = { output: 'standalone',// Optional but recommended for self-hosting poweredByHeader: false, compress: true, images: { // Add your image domains here remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com' }, ], }, };
module.exports = nextConfig;
Why standalone mode matters
- Smaller deploys -- Docker images drop from ~1 GB to ~150 MB. Rsync uploads finish in seconds.
- Faster cold starts -- Node loads fewer files on boot.
- Self-contained -- You can delete the source
node_modulesafter the build and the app still runs. - No dev dependencies shipped -- TypeScript compilers, Tailwind, ESLint, etc. are stripped.
Step 5: Build the Application
Set your production environment variables first (see the Environment Variables section for the distinction between build-time and runtime):
cat > /var/www/app/.env.production <<'EOF'
NODE_ENV=production
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
DATABASE_URL=postgresql://user:pass@localhost:5432/appdb
NEXTAUTH_SECRET=change-me-to-a-random-string
NEXTAUTH_URL=https://yourdomain.com
EOFRun the build:
cd /var/www/app
npm run buildExpected output (abbreviated):
▲ Next.js 15.0.3 Creating an optimized production build ... ✓ Compiled successfully ✓ Linting and checking validity of types ✓ Collecting page data ✓ Generating static pages (42/42) ✓ Finalizing page optimization
Route (app) Size First Load JS ┌ ○ / 5.1 kB 95.3 kB ├ ○ /_not-found 877 B 85.3 kB ├ ƒ /api/auth/[...nextauth] 0 B 0 B └ ○ /dashboard 12.4 kB 102 kB
After the build completes, the standalone artifact lives at .next/standalone/. You also need to copy the public/ folder and .next/static/ folder into the standalone directory manually -- Next.js intentionally omits these so you can customize CDN strategies:
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/staticNow .next/standalone/ is a complete, runnable application. You can launch it directly with:
node .next/standalone/server.jsThe server listens on port 3000 by default. Override with PORT=4000 node .next/standalone/server.js or the HOSTNAME env var.
Step 6: Run with PM2 in Cluster Mode
Running node server.js in a foreground shell is fine for testing, but in production you need:
- Auto-restart if the process crashes
- Cluster mode to use all CPU cores
- Log rotation so disks do not fill up
- Startup on boot survival
sudo npm install -g pm2Switch back to the nextjs user and create an ecosystem config at /var/www/app/ecosystem.config.js:
module.exports = {
apps: [
{
name: 'nextjs-app',
script: '.next/standalone/server.js',
cwd: '/var/www/app',
instances: 'max', // Spawn one worker per CPU core
exec_mode: 'cluster', // Node cluster module for load balancing
env: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '127.0.0.1',
},
max_memory_restart: '1G', // Restart worker if it exceeds 1 GB
error_file: '/var/log/pm2/nextjs-error.log',
out_file: '/var/log/pm2/nextjs-out.log',
merge_logs: true,
time: true,
},
],
};Create the log directory:
sudo mkdir -p /var/log/pm2
sudo chown nextjs:nextjs /var/log/pm2Start the app:
cd /var/www/app
pm2 start ecosystem.config.jsExpected output:
[PM2] Starting /var/www/app/ecosystem.config.js in cluster_mode (4 instances)
[PM2] Done.
┌────┬────────────────┬─────────┬─────────┬─────────┬──────────┬────────┐
│ id │ name │ mode │ status │ cpu │ memory │ uptime │
├────┼────────────────┼─────────┼─────────┼─────────┼──────────┼────────┤
│ 0 │ nextjs-app │ cluster │ online │ 0% │ 92.3mb │ 0s │
│ 1 │ nextjs-app │ cluster │ online │ 0% │ 91.8mb │ 0s │
│ 2 │ nextjs-app │ cluster │ online │ 0% │ 90.1mb │ 0s │
│ 3 │ nextjs-app │ cluster │ online │ 0% │ 92.7mb │ 0s │
└────┴────────────────┴─────────┴─────────┴─────────┴──────────┴────────┘Persist Across Reboots
Tell PM2 to generate a systemd unit that resurrects your apps on boot:
pm2 save
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u nextjs --hp /home/nextjsRun the command PM2 prints (it will look like sudo systemctl enable pm2-nextjs).
Useful PM2 commands:
pm2 list-- Show all running appspm2 logs nextjs-app-- Tail logspm2 restart nextjs-app-- Zero-downtime reload:pm2 reload nextjs-apppm2 monit-- Live CPU/memory dashboardpm2 delete nextjs-app-- Stop and remove
Step 7: Configure Nginx as a Reverse Proxy
Nginx sits in front of Node.js to terminate TLS, cache static assets, compress responses, and apply rate limits. If you are new to Nginx, see How to Install Nginx on Ubuntu 24.04 first.
Install Nginx:
sudo apt install -y nginxCreate the site config at /etc/nginx/sites-available/nextjs-app:
upstream nextjs_upstream { server 127.0.0.1:3000; keepalive 64; }server { listen 80; listen [::]:80; server_name yourdomain.com www.yourdomain.com;
# Max upload size (adjust for your app) client_max_body_size 25m;
# Gzip (Brotli is even better if you compile the module) gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Next.js static assets -- long-lived immutable cache location /_next/static/ { proxy_cache nextjs_cache; proxy_pass http://nextjs_upstream; add_header Cache-Control "public, max-age=31536000, immutable"; }
# User-uploaded / public assets location /static/ { proxy_cache nextjs_cache; proxy_pass http://nextjs_upstream; add_header Cache-Control "public, max-age=3600"; }
# Main app -- everything else goes to Node 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; proxy_read_timeout 60s; } }
The Upgrade/Connection headers matter if your app uses WebSockets (for example via Next.js's middleware, Server-Sent Events, or a custom socket route).
Enable the site and validate:
sudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxStep 8: Add TLS with Let's Encrypt
Install Certbot and grab a free certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot edits your Nginx config to add listen 443 ssl, TLS certificate paths, and an HTTP-to-HTTPS redirect. It also installs a systemd timer to auto-renew certificates every 60 days.
Verify auto-renewal:
sudo certbot renew --dry-runStep 9: Cache Static Assets
Add a cache zone to /etc/nginx/nginx.conf inside the http { } block:
proxy_cache_path /var/cache/nginx/nextjs levels=1:2 keys_zone=nextjs_cache:100m max_size=1g inactive=7d use_temp_path=off;Create the cache directory:
sudo mkdir -p /var/cache/nginx/nextjs
sudo chown www-data:www-data /var/cache/nginx/nextjs
sudo systemctl reload nginxNext.js emits content-hashed filenames for _next/static/*, so an aggressive max-age=31536000, immutable cache is always safe -- if the bundle changes, the filename changes.
Environment Variables: Build-Time vs. Runtime
This trips up nearly every Next.js developer new to self-hosting.
Build-time variables (baked into the bundle)
Any variable prefixed with NEXT_PUBLIC_ is inlined into the client-side JavaScript at build time. Changing it requires a rebuild -- setting a new value in .env.production without rerunning next build has no effect.
# .env.production -- these are frozen at build time
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
NEXT_PUBLIC_POSTHOG_KEY=phc_abc123Runtime variables (read on each request)
Variables without the NEXT_PUBLIC_ prefix are read at runtime via process.env.* in server components, API routes, and middleware. You can change them and restart PM2 without rebuilding:
# .env.production -- runtime, no rebuild needed
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...
SMTP_PASSWORD=...After editing runtime variables:
pm2 restart nextjs-app --update-envThe --update-env flag is critical -- without it, PM2 reuses the env vars captured when the process first started.
ISR and On-Demand Revalidation
Incremental Static Regeneration (ISR) pages are cached to .next/standalone/.next/cache/ on disk. Two things to be aware of:
revalidatePath() / revalidateTag() needs to fan out to every worker. In cluster mode, the easiest fix is using a shared cache handler (Redis is well-supported).Minimal shared cache handler using Redis:
// cache-handler.js
const { RedisCache } = require('@neshca/cache-handler/redis-stack');
module.exports = class CacheHandler {
constructor(options) {
this.cache = new RedisCache({ url: process.env.REDIS_URL });
}
async get(key) { return this.cache.get(key); }
async set(key, data, ctx) { return this.cache.set(key, data, ctx); }
async revalidateTag(tag) { return this.cache.revalidateTag(tag); }
};Wire it up in next.config.js:
module.exports = {
output: 'standalone',
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // Disable in-memory to force shared cache
};Sharp and Image Optimization
Next.js's <Image> component calls Sharp for server-side optimization. In standalone mode, Sharp is not included in .next/standalone/node_modules/ by default. Install it inside the standalone directory:
cd /var/www/app/.next/standalone
npm install sharpAlternatively, set NEXT_SHARP_PATH=/var/www/app/node_modules/sharp and keep the Sharp copy from the parent node_modules/.
If you prefer to offload image optimization entirely, set images.unoptimized: true in next.config.js and serve images through Cloudflare, Bunny, or another CDN with image transforms.
Alternative: systemd Instead of PM2
PM2 is convenient, but it is an extra dependency. If you prefer pure systemd, skip PM2 and create /etc/systemd/system/nextjs.service:
[Unit] Description=Next.js Application After=network.target[Service] Type=simple User=nextjs WorkingDirectory=/var/www/app/.next/standalone Environment="NODE_ENV=production" Environment="PORT=3000" Environment="HOSTNAME=127.0.0.1" EnvironmentFile=/var/www/app/.env.production ExecStart=/usr/bin/node server.js Restart=always RestartSec=5 StandardOutput=append:/var/log/nextjs/out.log StandardError=append:/var/log/nextjs/error.log
[Install] WantedBy=multi-user.target
Enable and start:
sudo mkdir -p /var/log/nextjs
sudo chown nextjs:nextjs /var/log/nextjs
sudo systemctl daemon-reload
sudo systemctl enable --now nextjs
sudo systemctl status nextjsDownside: systemd does not do cluster mode. For single-process simplicity or if you are fronting Next.js with multiple VPS instances behind a load balancer, this is often the cleaner choice.
App Router vs. Custom Server
Next.js has a custom server mode (next start with your own Express/Fastify wrapper), but the App Router discourages it -- custom servers disable many optimizations including automatic static optimization and correct edge runtime behavior. Stick with the built-in server.js from standalone output unless you have a specific reason not to (such as integrating Socket.io on the same port).
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
JavaScript heap out of memory during next build | Default Node memory limit is 2 GB; medium/large builds need more | Run NODE_OPTIONS="--max-old-space-size=4096" npm run build. On 2 GB VPS, add swap: sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile |
Error: listen EADDRINUSE 0.0.0.0:3000 | Port 3000 already in use (stale Node, another PM2 process) | sudo lsof -i :3000 to find the PID, then kill <pid> or change PORT in ecosystem config |
Images return 500 with sharp error | Sharp missing from standalone node_modules | cd .next/standalone && npm install sharp, then pm2 restart nextjs-app |
| ISR pages show stale data on some requests but not others | Each cluster worker has its own in-memory cache | Configure a shared cache handler (Redis) -- see ISR section |
NEXT_PUBLIC_* var change has no effect | Public vars are inlined at build time | Rebuild: npm run build && pm2 reload nextjs-app |
| 502 Bad Gateway from Nginx | Node process crashed or not listening on expected port | pm2 logs nextjs-app for errors; curl -I http://127.0.0.1:3000 to test upstream directly |
| WebSocket connections fail after upgrade to HTTPS | Missing proxy_set_header Upgrade $http_upgrade | Confirm the Nginx Connection "upgrade" headers from Step 7 are present in the enabled HTTPS server block (Certbot may have duplicated the block) |
| Image optimization endpoint returns 400 | Source host not whitelisted | Add the hostname to images.remotePatterns in next.config.js and rebuild |
Build fails with Cannot find module '...' on server but works locally | Case-sensitive filesystem (Linux) vs. case-insensitive (macOS) | Fix import casing in your code to match actual filenames |
pm2 logs nextjs-app --lines 100
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.logFAQ
What's the minimum VPS I need to run Next.js?
For a small marketing site or blog, a 1 vCPU / 2 GB RAM plan is workable, but you will almost certainly need swap space to get through next build. For anything with a real user base -- 4 vCPU / 6 GB RAM (CloudCore Starter) is the sweet spot. Builds complete in 30-90 seconds, cluster mode has enough cores to scale, and 6 GB leaves room for the app, a small database, and system overhead.
Should I build on the server or build in CI and upload?
For small projects, building on the server is simple and works fine. For larger projects or frequent deploys, build in CI (GitHub Actions, GitLab CI) and rsync the .next/standalone/, public/, and .next/static/ folders to the server. This avoids installing dev dependencies on the server and shifts the CPU cost off production.
Can I run multiple Next.js apps on one VPS?
Yes. Give each app a unique port (3000, 3001, 3002) and a separate Nginx server block with its own server_name. PM2 handles multiple apps in one ecosystem file; just add additional entries to the apps array.
How do I do zero-downtime deploys?
With PM2 cluster mode, use pm2 reload nextjs-app (not restart). PM2 replaces workers one at a time, keeping the app continuously available. The typical zero-downtime deploy script is: git pull, npm ci, npm run build, cp -r public .next/standalone/public, cp -r .next/static .next/standalone/.next/static, pm2 reload nextjs-app.
Does Middleware work in self-hosted Next.js?
Yes. Middleware runs on the Node.js runtime (not the Edge runtime) when self-hosted, which actually gives you access to more APIs than the Vercel Edge environment -- you can use any Node built-in and most npm packages. Be aware that any package relying on node:crypto or native bindings will work here but would not on Vercel Edge.
How do I self-host with Docker instead?
The standalone output is designed for Docker. The official minimal Dockerfile is on GitHub. Copy .next/standalone/, public/, and .next/static/ into a node:20-alpine image, set CMD ["node", "server.js"], and you have a ~150 MB production image. Run it behind the same Nginx reverse proxy.
Next Steps
Now that your Next.js app is live on Ubuntu, here are recommended next steps:
- Set up automated deploys -- Wire up a GitHub Actions workflow that SSHs into your VPS, runs
git pull && npm ci && npm run build && pm2 reload nextjs-appon every push tomain. - Add a PostgreSQL database -- Most Next.js apps need a database. Install Postgres on the same server for small apps, or use a managed provider for production.
- Install Redis for ISR and sessions -- A shared Redis instance fixes the per-worker ISR cache problem and gives you a solid session store for NextAuth.
- Configure monitoring -- Add Uptime Kuma to watch your public URL, and wire PM2 to PM2 Plus or Prometheus for process-level metrics.
- Read the Next.js self-hosting docs -- The canonical reference is nextjs.org/docs/app/building-your-application/deploying#self-hosting. The Next.js source tree at github.com/vercel/next.js has additional deployment examples.
Skip the Manual Setup -- Deploy a Next.js-Ready VPS>
Our CloudCore Starter plan comes with Node.js 20, Nginx, PM2, and Certbot pre-installed. Drop in your app, run npm run build, and go live in minutes.
>
- Node.js 20 LTS and Corepack pre-configured
- Nginx with sane defaults and Let's Encrypt auto-renewal
- PM2 installed and wired to systemd
- Firewall and fail2ban hardened out of the box
- Full root access -- deploy any stack you want>
Launch CloudCore Starter -- EUR 7.99/month.