Why Self-Host Nuxt on a VPS?
With Nitro, Nuxt generates a portable build (preset: node-server) that starts as a simple Node server listening on a port. This portability is ideal for a VPS: you benefit from server-side rendering, server/api routes, and route caching without depending on a proprietary edge runtime.
Self-hosting removes function quotas and lets you tune caching, compression, and the number of Node instances. For a production Vue 3 application with authentication, server API calls, and careful SEO, the VPS offers the stability of a persistent server and a fixed cost, with no per-invocation billing surprises.
You still need a server that accepts a permanent Node.js process: most shared hosting does not allow it, which rules out SSR and server routes from the start. On a VPS you also choose the Node.js version, the reverse proxy (Nginx or Caddy) and how TLS certificates are managed, with no artificial cap on CPU or memory.
Concrete Benefits of a Self-Hosted Nuxt
server/apiserver routes with no per-call billing.- SSR and Vue 3 hydration served from a persistent, stable Node server.
- Portable Nitro build (
node-server) easy to containerize and reproduce. - Route caching and
routeRulesrules (SSR, SSG, ISR, SWR) controlled on the server side. - Several Nuxt apps on a single VPS behind Nginx, each on its own internal port (3000, 3001…).
- Runtime environment variables (
runtimeConfig) managed directly on your own server. - Full control over HTTP headers (CSP, HSTS, cache-control) via Nitro middleware.
- A site prerendered with
nuxt generate, served by Nginx alone, with no permanent Node.js process.
SSR, SSG, SPA or Hybrid: Which Rendering Mode Should You Choose?
Scroll the table
| Mode | Rendering | Node.js process | Best for |
|---|---|---|---|
| SSR (default) | Per request, on the server | Yes, supervised by PM2 | Dynamic app, SEO, personalized content |
| SSG (`nuxt generate`) | At build time, as static HTML | No, Nginx serves `.output/public` | Blog, showcase site, rarely changing content |
| SPA (`ssr: false` + `nuxt generate`) | In the browser | No, Nginx is enough | Internal dashboard, app behind authentication |
| Hybrid (`routeRules`) | Per route: SSR, prerender, SWR, ISR | Yes, Nitro server | App mixing static pages and API routes |
Hardware and Software Prerequisites
As with any modern framework, the Nuxt build consumes memory: aim for at least 2 GB of RAM (the build can be done in CI if your VPS is more modest). At runtime, 1 vCPU and 1 GB are enough for the Nitro server under light load; move up to 2 vCPU / 2 GB for applications with many simultaneous server/api routes, and to 2 vCPU / 4 GB if several Nuxt applications share the VPS.
On the version side, start from Nuxt 4: Nuxt 3 reached end of life on 31 July 2026 and no longer receives bug fixes or security patches. The deployment described here is the same for both versions, since Nitro is what produces the server. Install a Node.js LTS line that is still maintained, Node.js 24 or 26 (Node.js 20 has been end-of-life since 30 April 2026), via nvm or a node:24-alpine image, along with PM2 for process supervision. node-server is the preset Nitro applies by default to a production build: declaring it in nuxt.config.ts simply makes it explicit. A domain pointing to your VPS is required for the TLS certificate. Ubuntu 22.04 or 24.04 LTS remains the recommended base.
Starting from the Nuxt Stack Template
The Nuxt Stack template in the ServOrbit Marketplace prepares an Ubuntu 24.04 VPS with Node.js on the current LTS line of the NodeSource repository, PM2 installed globally, Nginx and PostgreSQL. It saves you installing the runtime: the steps below then start at the project configuration.
It is a runtime environment, not an application with a login screen: you get in over SSH (ssh root@<vps-ip>) to upload your code. On first access, Nginx serves its default page on port 80; your application's virtual host, the Let's Encrypt certificate (Certbot is not preinstalled) and your code are still to be set up. PostgreSQL listens only locally, with peer authentication for the postgres system user (sudo -u postgres psql): enough to plug a DATABASE_URL into 127.0.0.1 without exposing the database.
Deploy Nuxt Step by Step
Configure the Project for Node
Set the Nitro preset in
nuxt.config.ts:export default defineNuxtConfig({ nitro: { preset: 'node-server' } })Declare your secrets in
runtimeConfigon the server side only — never inruntimeConfig.public. On the VPS, install Node.js 24 LTS vianvm install 24 && nvm use 24 && nvm alias default 24, then PM2 globally:npm install -g pm2. On the Nuxt Stack template, Node.js and PM2 are already in place.Build the Application
Clone the repository and install dependencies:
git clone https://git.yourdomain.com/your-org/your-app.git /srv/nuxt-app cd /srv/nuxt-app npm ci npm run buildNitro produces a self-contained
.outputfolder containingserver/index.mjsand static assets in.output/public. This folder is all the VPS needs to serve the application in production — you can also assemble it in CI and transfer it by rsync to avoid building on the server.Create the Environment File
Create
/srv/nuxt-app/.envwith your runtime variables. All variables prefixedNUXT_override the corresponding keys inruntimeConfigat startup:NUXT_MY_SECRET_KEY=production-value NITRO_HOST=127.0.0.1 NITRO_PORT=3000Never commit this file. Make sure it belongs to the user running PM2 and is readable only by that user (
chmod 600).With
NITRO_HOST=127.0.0.1, Nitro listens only on the loopback interface: only Nginx can reach it, and port 3000 is not exposed to the Internet.Launch the Nitro Server with PM2
Start the application in cluster mode, asking Node to load the environment file: Nuxt does not read
.envin production, and the PM2 command line has no dedicated option for it.pm2 start /srv/nuxt-app/.output/server/index.mjs \ --name nuxt-app -i max \ --node-args="--env-file=/srv/nuxt-app/.env" pm2 save pm2 startup-i maxlaunches one instance per vCPU; this cluster mode is what makes the zero-downtime reloads of the last step possible. Thepm2 startupcommand displays a line to copy-paste to create the systemd service. Run it with administrator rights. Verify the application is running:pm2 statusshould show theonlinestate.Configure Nginx as a Reverse Proxy
Create
/etc/nginx/sites-available/nuxt-app:server { listen 80; server_name yourdomain.com www.yourdomain.com; # Hashed assets: long cache, served directly location /_nuxt/ { root /srv/nuxt-app/.output/public; expires 1y; add_header Cache-Control "public, immutable"; } # Static public files location /favicon.ico { root /srv/nuxt-app/.output/public; } # Everything else → Nitro 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; proxy_cache_bypass $http_upgrade; } }Enable the site and test the configuration:
ln -s /etc/nginx/sites-available/nuxt-app /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx.Enable HTTPS with Let's Encrypt
Install Certbot and the Nginx plugin, then generate the certificate:
apt install certbot python3-certbot-nginx certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot automatically modifies the Nginx block to listen on port 443 and force HTTP → HTTPS redirect. Verify that
X-Forwarded-Protois correctly forwarded to Nitro — without it,useRequestHeaders()on the server side and protocol-based redirects may behave incorrectly. Automatic renewal is managed by Certbot's systemd timer, checkable withsystemctl status certbot.timer.Update Without Service Interruption
For an update, PM2 reload (not restart) guarantees continuity. Group the commands in a
/srv/nuxt-app/deploy.shscript, whichset -estops at the first error, before any reload:#!/bin/bash set -e cd /srv/nuxt-app git pull origin main npm ci npm run build pm2 reload nuxt-appMake it executable (
chmod +x deploy.sh) and call it from your CI/CD pipeline over SSH. In cluster mode, PM2 replaces the instances one by one: the new version takes over before the old one stops. For a rollback, revert to the previous commit, rebuild and reload. If the build happens in CI, transfer only the.outputfolder via rsync and reload without rebuilding on the server.
Environment Variables: Public, Private and Reloading
Nuxt organizes its runtime configuration on two levels. Keys placed under runtimeConfig.public are sent to the browser and remain readable on the server: public API URL, analytics identifier. The other runtimeConfig keys never leave the server: secret keys, database access.
At startup, an environment variable overrides the matching key if it follows the NUXT_<NAME> convention, or NUXT_PUBLIC_<NAME> for a public key: NUXT_PUBLIC_API_BASE replaces runtimeConfig.public.apiBase. A value read elsewhere, for example process.env.OTHER_VARIABLE written in nuxt.config.ts, is only picked up at build time and stays frozen in the artifact.
In practice, changing an API URL or a key therefore does not require a rebuild: you edit the .env on the server, then reload PM2 (pm2 reload nuxt-app). The same .output folder can thus serve staging and production with two different environment files. The only condition is to have the process load that file, since Nuxt no longer reads .env once the application is built — hence the --env-file option passed to Node when starting with PM2.
Optimize Rendering with routeRules
One of Nuxt's strengths on a VPS is the granularity of per-page rendering strategies, without changing infrastructure. In nuxt.config.ts, routeRules allow you to mix SSR, SSG, ISR, and SWR on a single Nitro deployment.
Static pages (blog, legal notices, landing pages) can be pre-generated at build time with prerender: true: Nitro serves them from disk, without Node. Semi-dynamic pages (product listings, public dashboards) benefit from swr: 60 for a one-minute cache invalidated in the background. Full SSR remains reserved for personalized pages that depend on session or user profile.
This approach reduces the load on Node without requiring a paid CDN or an external cache layer — your Nginx caches what Nitro pre-generates.
Nitro server vs Docker vs Vercel/Netlify for hosting Nuxt
Scroll the table
| Approach | Advantages | Constraints |
|---|---|---|
| Nitro `node-server` + PM2 | Simple, lightweight, zero-downtime reload, fixed VPS cost | Supervision to manage, single server without native high availability |
| Docker on VPS | Isolation, reproducibility, rollback by image, multi-app compose | Image to build and store, additional memory per container |
| Vercel / Netlify | Zero configuration, global edge CDN, PR previews | Function quotas, variable cost, no infrastructure control |
| Kamal (zero-downtime deployment) | Orchestrates Docker and kamal-proxy, one-command deployment (`kamal deploy`) | Ruby tool (gem or Docker image), longer initial config, suited for teams |
Logs: Reading, Rotating, Cross-Checking with Nginx
PM2 centralizes the application's output: pm2 logs nuxt-app follows the live stream (standard output and errors), and pm2 logs nuxt-app --lines 100 shows the latest lines. The files are written to ~/.pm2/logs/, one -out.log and one -error.log per application, suffixed with the instance number in cluster mode.
These files do not rotate on their own. The pm2-logrotate module rotates them past 10 MB and keeps 30 by default; adjust these thresholds to the size of your disk:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7When the browser gets a 502 or a 504, look at Nginx first: /var/log/nginx/error.log tells you whether it could not reach 127.0.0.1:3000 (process stopped, wrong port) or whether Nitro took too long to respond.
Troubleshooting: Common Production Errors
Error: listen EADDRINUSE: address already in use :::3000
A process is already holding port 3000. Identify it with ss -tlnp | grep 3000 and stop the orphaned PM2 instance: pm2 delete nuxt-app followed by a clean restart. Avoid running multiple instances without a load balancer — configure PM2's cluster_mode instead.
502 Bad Gateway in Nginx
Nitro is not started or is listening on the wrong port. Check pm2 status and review the logs: pm2 logs nuxt-app --lines 50. Also verify that the proxy_pass directive in Nginx points to http://127.0.0.1:3000 (not localhost which may resolve to IPv6 if the process only listens on IPv4).
useRuntimeConfig() returns undefinedNUXT_* variables are only read when the Nitro process starts: after changing .env, reload with pm2 reload nuxt-app. Check that the file is actually loaded through --node-args="--env-file=…" rather than exported in the current shell, which disappears with the SSH session. If the value is missing in the browser only, the key must be declared under runtimeConfig.public and the variable prefixed NUXT_PUBLIC_.
server/api routes return 404 in production
Check that your build includes the server/ folder in .output. A misconfigured nitro.preset (for example static instead of node-server) generates a static site without a server. Rebuild with npm run build after verifying nuxt.config.ts.
X-Forwarded-Proto ignored, redirect loop
If Nuxt redirects HTTP → HTTPS in a loop, it means the redirect middleware sees http despite the client-side HTTPS. Make sure Nginx forwards proxy_set_header X-Forwarded-Proto $scheme and that your nuxt.config.ts does not force HTTPS redirect twice alongside Certbot.
Monitoring and Metrics with PM2
Beyond simple pm2 status, PM2 exposes a real-time dashboard with pm2 monit: CPU, memory, restarts, and process logs. For a Nuxt application under load, enable cluster mode to use all available vCPUs:
pm2 start .output/server/index.mjs --name nuxt-app -i maxPM2 automatically distributes requests across workers. Export these metrics to Prometheus and Grafana to get alerted on unexpected restarts — a sign of a bug or memory leak to fix before it affects users.
The Official Documentation
For advanced configuration and version changes, refer to the official Nuxt documentation (routeRules, runtimeConfig, modules), the Nitro Node.js runtime page for the NITRO_PORT and NITRO_HOST variables, and the PM2 cluster mode documentation. This guide covers going live on a VPS; the vendor documentation remains the reference for fine-tuning.