How to deploy Next.js or Nuxt on a VPS with Ubuntu 24.04
To deploy Next.js on a VPS, install Node.js on Ubuntu, build the app with npm run build, keep it running with PM2 and put nginx in front as a reverse proxy with a free Let's Encrypt certificate. Nuxt works the same way with node .output/server/index.mjs. For updates, build each release in its own folder and switch a symlink.
Key takeaways
next starton one server supports server rendering, image optimization, Proxy (formerly Middleware) and ISR without extra setup.- PM2 keeps the app alive and restarts it after a reboot; nginx handles HTTPS and slow or malformed requests.
- Building every release in its own folder and switching a symlink gives you deploys without downtime and instant rollbacks.
- Running several instances needs a shared cache, a fixed Server Actions key and a deployment ID. One instance doesn't.
On this page
- Can you host Next.js without Vercel?
- What you need before you start
- Step 1: Install Node.js, nginx and PM2 on Ubuntu 24.04
- Step 2: Build the first release and run it with PM2
- Step 3: Put nginx in front as a reverse proxy
- Step 4: Add free HTTPS with Let's Encrypt
- Step 5: Zero-downtime deploys from Git
- How Nuvlo deploys Next.js and Nuxt for you
- When a VPS is the wrong choice
Can you host Next.js without Vercel?
Yes. Next.js is open source, and its self-hosting guide covers running it on a plain Node.js server. Most features work with next start and no extra configuration. What you give up are the platform services around the framework, not the framework itself.
| Feature | On one VPS with next start | What to watch |
|---|---|---|
| Server rendering, Route Handlers, Server Actions | Works | Nothing special |
Image optimization (next/image) | Works with zero configuration | Uses memory on the server; give it room |
| Proxy (called Middleware before Next.js 16) | Works with zero configuration | Runs on your server, not at an edge location |
| ISR and revalidation | Works; the cache lives on local disk | Several instances need a shared cache handler |
| Streaming and Suspense | Works | nginx must not buffer the response |
| Preview deploys per pull request | Not included | You build it yourself or use a staging server |
| Global edge network and autoscaling | Not included | One server in one region; add a CDN if you need it |
Nuxt is even simpler. nuxt build produces a standalone Node server in .output, and the Nuxt deployment docs show it running under PM2. If you are still deciding whether a VPS is right for you at all, read VPS vs shared vs managed hosting first.
What you need before you start
- A VPS with Ubuntu 24.04 and its own public IP address. Hetzner, DigitalOcean, Vultr and similar providers all work.
- At least 2 GB of memory for a typical Next.js build. Builds on 1 GB servers can run out of memory; add swap or pick a bigger plan.
- A domain with an A record pointing at the server.
- SSH access with a key, and your app in a Git repository the server can clone.
Harden the server before you put an app on it. Our secure Ubuntu server checklist covers key-only SSH, the firewall, fail2ban and automatic security updates.
Step 1: Install Node.js, nginx and PM2 on Ubuntu 24.04
Install nginx and Git, open only SSH, HTTP and HTTPS in the firewall, then add Node.js 24 (the current LTS line) from NodeSource. Ubuntu's own nodejs package is older.
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx git build-essential
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
sudo npm install -g pm2Run the app as its own user, not as root. Create a deploy user and a folder layout with room for releases:
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app/releases /var/www/app/shared
sudo chown -R deploy:deploy /var/www/appStep 2: Build the first release and run it with PM2
Switch to the deploy user (sudo -iu deploy), give it read access to your repository with a deploy key, and put your production variables in /var/www/app/shared/.env.local. Then build the first release:
RELEASE=/var/www/app/releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 --branch main git@github.com:you/your-app.git "$RELEASE"
cd "$RELEASE"
ln -s /var/www/app/shared/.env.local .env.local
npm ci
npm run build
ln -sfn "$RELEASE" /var/www/app/currentNow describe the process in /var/www/app/ecosystem.config.cjs. Pointing cwd at the current symlink is what lets later deploys swap releases. Cluster mode with one instance lets pm2 reload start the new process before stopping the old one.
module.exports = {
apps: [{
name: 'app',
cwd: '/var/www/app/current',
// Next.js:
script: 'node_modules/next/dist/bin/next',
args: 'start -H 127.0.0.1 -p 3000',
// Nuxt instead: script: '.output/server/index.mjs', no args
exec_mode: 'cluster',
instances: 1,
max_memory_restart: '800M',
env: { NODE_ENV: 'production', PORT: '3000', HOST: '127.0.0.1' },
}],
}Start it, save the process list, and make PM2 start on boot. pm2 startup prints one sudo command; run it exactly as printed, as the PM2 startup docs describe.
pm2 start /var/www/app/ecosystem.config.cjs
pm2 save
pm2 startupTipNuxt doesn't read .env files once it is built. Put Nuxt's runtime values (for example NUXT_ variables for runtimeConfig) in the env block of the ecosystem file instead. Next.js does load .env.local from the project folder.
Step 3: Put nginx in front as a reverse proxy
Next.js recommends a reverse proxy such as nginx instead of exposing the Node server directly. nginx deals with malformed requests, slow connections and payload limits so the app can spend its time rendering. Create /etc/nginx/sites-available/app:
server {
listen 80;
listen [::]:80;
server_name example.com www.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;
}
}sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxIf you use streaming or loading.js in the App Router, nginx buffering will hold the response back until it is complete. Next.js suggests sending an X-Accel-Buffering: no header from next.config.js, which tells nginx to pass the stream through.
Step 4: Add free HTTPS with Let's Encrypt
Once DNS points at the server, install Certbot and let it edit the nginx site. The Certbot instructions use the snap package:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runCertbot adds the certificate, a redirect from HTTP to HTTPS and a renewal timer. The dry run confirms renewal will work before the first certificate expires.
Step 5: Zero-downtime deploys from Git
Building inside the live folder means visitors hit a half-built app for a minute. Build next to it instead, then switch over in one step:
- Clone the new commit into a fresh folder under
releases. - Install dependencies and build there while the old release keeps serving.
- Point
currentat the new folder with an atomic rename. - Run
pm2 reload, which in cluster mode starts the new process before the old one stops. - Check that the app answers; if it does not, point
currentback and reload again. - Delete old releases, keeping a few for rollbacks.
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
PREVIOUS=$(readlink -f "$APP/current")
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main git@github.com:you/your-app.git "$RELEASE"
cd "$RELEASE"
ln -s "$APP/shared/.env.local" .env.local
npm ci
npm run build
ln -s "$RELEASE" "$APP/current.tmp" && mv -T "$APP/current.tmp" "$APP/current"
pm2 reload "$APP/ecosystem.config.cjs" --update-env
sleep 5
if ! curl -fsS -o /dev/null http://127.0.0.1:3000/; then
echo "Health check failed, going back to $PREVIOUS"
ln -s "$PREVIOUS" "$APP/current.tmp" && mv -T "$APP/current.tmp" "$APP/current"
pm2 reload "$APP/ecosystem.config.cjs" --update-env
exit 1
fi
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rfRun the script over SSH from a GitHub Actions job, or from a small webhook listener on the server, and every push to main becomes a release. A rollback is the same symlink switch pointed at an older folder.
ImportantFiles your app writes at runtime, such as uploads or an SQLite database, must live in shared and be linked into each release. Otherwise the next deploy leaves them behind in the old folder.
Scaling past one instance changes the picture. The Next.js docs say every instance then needs the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, a deploymentId to handle version skew, and a cache handler that stores ISR entries somewhere all instances can reach, such as Redis.
How Nuvlo deploys Next.js and Nuxt for you
Everything above is what Nuvlo sets up for you, on a server in your own cloud account. Nuvlo doesn't host or resell servers: the provider, for example Hetzner, bills you directly at its own price.
- The server: choose Node.js apps when you create a server and it gets Node.js, PM2 and Redis on top of nginx, a firewall and SSH hardening, on Ubuntu 24.04 by default. You can also connect any Ubuntu server with one command.
- Detection: connect GitHub, pick the repository, and Nuvlo recognises Nuxt, Next.js, Remix, SvelteKit, Astro, Express and Hono, plus npm, pnpm, Bun or Yarn and monorepo folders. Anything else on Node works with Other and your own commands. See Deploy a Node.js app.
- Releases: every deploy builds in its own release folder and goes live with an atomic switch. A failed build never goes live, and a release that fails its health check is replaced by the previous one. Rollbacks reuse the releases kept on disk.
- Data between releases: mark folders as persistent. If the app writes to a folder you didn't mark, the deploy pauses before anything changes.
- Deploy on push: a webhook on your GitHub repository deploys every push to the branch, and a secret deploy hook URL lets any CI pipeline start one.
- HTTPS and checks: a certificate is issued as soon as the domain points at the server, and a launch check looks for leaked keys and public files after every deploy.
Some limits worth knowing: apps deploy from GitHub, GitLab.com or Bitbucket Cloud (not self-hosted GitLab), and there are no preview deploys for pull requests yet. The Free plan covers one server and one site; Starter is €12 a month for up to 10 servers. See pricing and the Git deploys feature.
When a VPS is the wrong choice
A single server is a good fit for most Next.js and Nuxt apps: marketing sites, SaaS dashboards, shops and APIs with steady traffic. It is a poor fit in a few cases:
- Your audience is spread worldwide and you need pages rendered close to every visitor.
- Your team relies on a preview URL for every pull request as part of review.
- Traffic jumps by orders of magnitude without warning and you want capacity to follow on its own.
- You don't want to own the server at all: OS updates, disk space and the occasional reboot.
In those cases a platform like Vercel earns its price. For everything else, a VPS gives you predictable costs and a server you can SSH into. If you want a panel to manage it, compare the options in Nuvlo vs Coolify.



