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 — alias path wrong or missing trailing slash.
  • Env vars ignored — must use the NUXT_ prefix and exist in runtimeConfig.
  • 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.

  • Nuxt
  • Vue
  • Deployment