Nuxt 3 builds with Nitro, which produces a self-contained server in .output/ — no node_modules needed on the server. That makes deploying Nuxt to your own VPS simple: build, copy .output, run it with Node.
Choose a rendering mode
- SSR / hybrid (default) — a Node server renders pages on request. Needs Node on the server.
- Static generation (
nuxi generate) — pre-rendered HTML; serve with Nginx alone, no Node process. Ideal for content sites without per-request data.
Build
On your machine or in CI:
npm ci
npm run build # nuxi build
ls .output
# public/ server/ nitro.json
Copy the whole .output directory to the server:
rsync -az --delete .output/ [email protected]:/srv/nuxt/.output/
The server needs only Node.js (a version supported by your Nuxt release) — see installing Node.js.
Run with PM2
// /srv/nuxt/ecosystem.config.cjs
module.exports = {
apps: [{
name: 'nuxt',
script: '.output/server/index.mjs',
cwd: '/srv/nuxt',
exec_mode: 'cluster',
instances: 2,
env: {
NODE_ENV: 'production',
HOST: '127.0.0.1',
PORT: 3000,
NUXT_API_SECRET: 'from-a-secret-store',
NUXT_PUBLIC_API_BASE: 'https://api.example.com',
},
}],
};
cd /srv/nuxt
pm2 start ecosystem.config.cjs
pm2 save && pm2 startup
Cluster mode with two instances allows zero-downtime pm2 reload nuxt after deploying a new .output. See the PM2 guide.
Or with systemd: ExecStart=/usr/bin/node /srv/nuxt/.output/server/index.mjs and the same environment variables.
Runtime config: environment variables
Nuxt’s runtimeConfig values can be overridden at runtime with NUXT_-prefixed variables:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // NUXT_API_SECRET (server only)
public: { apiBase: '' }, // NUXT_PUBLIC_API_BASE (exposed to the browser)
},
});
So one build can be deployed to staging and production with different variables. Never put secrets under public.
Nginx in front
server {
listen 80;
server_name example.com;
location /_nuxt/ {
alias /srv/nuxt/.output/public/_nuxt/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Serving /_nuxt/ (hashed build assets) directly from disk is faster and lets you cache them for a year. Nginx needs read access to /srv/nuxt/.output/public.
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com
Static generation instead
npx nuxi generate
rsync -az --delete .output/public/ deploy@server:/var/www/example.com/
Nginx serves the files directly:
location / {
try_files $uri $uri/ $uri.html /200.html;
}
Nuxt generates 200.html/404.html fallbacks for client-side navigation to non-prerendered routes. See deploying a static site for caching and compression tips.
Deploy script
#!/bin/bash
set -euo pipefail
npm ci && npm run build
rsync -az --delete .output/ deploy@server:/srv/nuxt/.output.new/
ssh deploy@server 'cd /srv/nuxt && rm -rf .output.old && mv .output .output.old && mv .output.new .output && pm2 reload nuxt'
Keeping .output.old makes rollback a rename away.
Troubleshooting
- Blank page, 404 for
/_nuxt/files —aliaspath wrong or missing trailing slash. - Env vars ignored — must use the
NUXT_prefix and exist inruntimeConfig. - Hydration errors after deploy — browsers holding old HTML referencing removed assets; keep previous assets briefly or use a CDN.
Related: deploying Next.js on a VPS and deploying a Vue app with Nginx.