How to deploy Next.js or Nuxt on a VPS with Ubuntu 24.04

By Wolfgang Wachelhofer · · 9 min read

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 start on 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
  1. Can you host Next.js without Vercel?
  2. What you need before you start
  3. Step 1: Install Node.js, nginx and PM2 on Ubuntu 24.04
  4. Step 2: Build the first release and run it with PM2
  5. Step 3: Put nginx in front as a reverse proxy
  6. Step 4: Add free HTTPS with Let's Encrypt
  7. Step 5: Zero-downtime deploys from Git
  8. How Nuvlo deploys Next.js and Nuxt for you
  9. 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.

FeatureOn one VPS with next startWhat to watch
Server rendering, Route Handlers, Server ActionsWorksNothing special
Image optimization (next/image)Works with zero configurationUses memory on the server; give it room
Proxy (called Middleware before Next.js 16)Works with zero configurationRuns on your server, not at an edge location
ISR and revalidationWorks; the cache lives on local diskSeveral instances need a shared cache handler
Streaming and SuspenseWorksnginx must not buffer the response
Preview deploys per pull requestNot includedYou build it yourself or use a staging server
Global edge network and autoscalingNot includedOne 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.

Prepare the server
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 pm2

Run the app as its own user, not as root. Create a deploy user and a folder layout with room for releases:

App user and folders
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app/releases /var/www/app/shared
sudo chown -R deploy:deploy /var/www/app

Step 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:

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/current

Now 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.

ecosystem.config.cjs
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.

Start and persist
pm2 start /var/www/app/ecosystem.config.cjs
pm2 save
pm2 startup

TipNuxt 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:

/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;
    }
}
Enable the site
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

If 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:

Certificate and renewal test
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-run

Certbot 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:

  1. Clone the new commit into a fresh folder under releases.
  2. Install dependencies and build there while the old release keeps serving.
  3. Point current at the new folder with an atomic rename.
  4. Run pm2 reload, which in cluster mode starts the new process before the old one stops.
  5. Check that the app answers; if it does not, point current back and reload again.
  6. Delete old releases, keeping a few for rollbacks.
/var/www/app/deploy.sh
#!/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 -rf

Run 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.

How much memory does a Next.js app need on a VPS?

A small Next.js app often runs in a few hundred megabytes, but the build needs more than the running app. Plan for 2 GB of memory if you build on the same server, or add swap on a 1 GB server. Image optimization and several apps on one server raise the need further.

Should I use PM2 or systemd for Next.js?

Both work. systemd is already on Ubuntu and is fine for one app. PM2 adds a graceful reload in cluster mode, log handling, memory limits and a simple command set, which makes zero-downtime deploys easier. If you run several Node apps on one server, PM2 keeps them in one place.

Do I need Docker to self-host Next.js?

No. Next.js and Nuxt both run as plain Node.js processes behind nginx. Docker helps when you want the same image on many machines or several isolated services on one server. On a single VPS, Node, PM2 and nginx are fewer moving parts and easier to debug.

Does ISR work when Next.js is self-hosted?

Yes. With one next start instance and a persistent disk, ISR and on-demand revalidation work without changes, because the cache is stored on the local filesystem. If you run several instances, configure a shared cache handler, for example on Redis, so a revalidation on one instance reaches the others.

Is the deploy process different for Nuxt?

Only in the start command and environment variables. Nuxt builds to .output and starts with node .output/server/index.mjs, reading PORT and HOST. Its built server does not read .env files, so set runtime values in the PM2 ecosystem file or the service environment instead.

Nuvlo sets up Node, PM2, nginx and HTTPS on a server in your own cloud account.