Deploy your Nuxt app to production

Deploy your Nuxt app to production

·47 min read

In 2020 I wrote a post on deploying a Nuxt app with PM2. A lot of it no longer applies. nuxt start does not exist any more, the config file is nuxt.config.ts instead of nuxt.config.js, and the build output is no longer something you serve with a hand-written process command — it is a self-contained server you run with one line. This post replaces it. If you landed here from a search, use this one and ignore anything about target: "server".

Everything below was verified against Nuxt 4.5.2 and Nitro 2.13.4 on Node 24, the current LTS line.

What the build actually produces

Start with a production build the way you always have.

bash
npm ci
npm run build

The interesting part is the directory it leaves behind. Nuxt no longer hands you a dist/ folder to serve and a nuxt start command to run it. nuxt build produces .output/, and everything the server needs is inside it.

text
.output/
├── nitro.json      # preset, framework + engine versions, the preview command
├── public/          # hashed client assets, static files
└── server/
    ├── index.mjs    # the entrypoint — 491 bytes
    ├── chunks/      # server-rendered routes and runtime
    ├── node_modules/# 93 production dependencies, already bundled
    └── package.json

server/node_modules is the part people miss. Nitro does not expect you to run npm install on the server, because it has already resolved and vendored every production dependency into the output. You can copy this directory to a bare machine with nothing but Node installed and it will run.

nitro.json is a small manifest that records how the build was made:

json
{
  "date": "2026-09-28T13:46:15.494Z",
  "preset": "node-server",
  "framework": { "name": "nuxt", "version": "4.5.2" },
  "versions": { "nitro": "2.13.4" },
  "commands": { "preview": "node server/index.mjs" },
  "config": {}
}

Running it

One command. This is the whole production start procedure:

bash
NODE_ENV=production node .output/server/index.mjs

The server listens on port 3000 and serves both the rendered HTML and the static assets in public/, so you do not need nginx to serve any files itself.

Keep NODE_ENV=production in there. The Nuxt docs call this out specifically: without it, Vue Router keeps its development warnings enabled and your logs fill with messages like [Vue Router warn]: No match found for location with path … for every unmatched route.

There is a nuxt preview command, and it is genuinely useful — but not for this. Its own output says so, and the wording is worth reading:

text
You are previewing a Nuxt app. In production, do not use this CLI.
Instead, run node server/index.mjs directly.

It exists to give you a .env-loaded, rebuilt-on-demand local environment. There is also no nuxt start command in Nuxt 4 any more — if a tutorial tells you to run it, that tutorial is at least five years out of date.

Environment variables

The server reads these directly:

  • NITRO_PORT or PORT — defaults to 3000
  • NITRO_HOST or HOST — defaults to 0.0.0.0
  • NITRO_SSL_CERT and NITRO_SSL_KEY — set both and it serves HTTPS itself. Do not do this in production; terminate TLS at the proxy instead.

Anything in runtimeConfig can be overridden by prefixing the key with NUXT_ or NITRO_. The mapping is mechanical: camelCase becomes SNAKE_CASE, and nesting is joined with a single underscore.

ts
runtimeConfig: {
  apiSecret: "...",              // NUXT_API_SECRET
  public: {
    siteUrl: "https://example.com" // NUXT_PUBLIC_SITE_URL
  }
}

This is worth internalising because a wrong name fails silently. Nothing warns you, runtimeConfig just keeps its build-time value and your app quietly talks to the wrong API.

The trap that catches everyone

A .env file is not read in production. nuxt preview loads it and says so; node .output/server/index.mjs does not, because by then there is no build tooling left to read it. This is not a Nuxt bug, it is the intended behaviour — but it means a .env that works perfectly in npm run dev and npm run preview will be silently absent the moment you deploy.

The usual failure is not a crash. It is a green deploy that returns empty content, because a missing project ID or API base silently produces an empty query result. So:

  • Put production secrets in the environment your process manager provides, not in a .env next to the build.
  • Or bake non-secret values into the build with NUXT_PUBLIC_* at build time.
  • After deploying, curl a real route and check the response body. A 200 with the wrong content is still a 200.

Keeping it running

systemd

On a single server, systemd is the supervisor. It is already running, it restarts on boot, and it needs no global install. Put a unit next to the build:

ini
[Unit]
Description=Nuxt production server
After=network-online.target

[Service]
Type=simple
User=nuxt
WorkingDirectory=/srv/nuxt-app
Environment=NODE_ENV=production
EnvironmentFile=/etc/nuxt-app.env
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=always
RestartSec=5

# Nitro drains in-flight requests on SIGTERM, which is systemd's default,
# and gives up after 30s. Give systemd a little more room than that.
KillSignal=SIGTERM
TimeoutStopSec=35

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Then:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now nuxt-app
systemctl status nuxt-app

Three of those lines are doing real work, and two of them are the opposite of common advice:

  • Do not set KillSignal=SIGINT. The usual Node-on-systemd folklore says to, because plain Node exits abruptly on SIGTERM. Nitro does not — it traps SIGTERM SIGINT by default, stops accepting connections, and waits for in-flight requests. systemd's default SIGTERM is already the right signal.
  • RestartSec defaults to 100ms, which turns a crash loop into a spin. Five seconds is a saner floor.
  • TimeoutStopSec defaults to 90s and Nitro's own shutdown timeout is 30s, so the defaults already line up. The explicit 35 only matters if you raise NITRO_SHUTDOWN_TIMEOUT past 30s — set that above 90 and systemd will SIGKILL the process mid-drain.

You can retune Nitro's half of this with NITRO_SHUTDOWN_TIMEOUT, NITRO_SHUTDOWN_SIGNALS, NITRO_SHUTDOWN_DISABLED, and NITRO_SHUTDOWN_NO_FORCE_EXIT.

systemctl reload nuxt-app gives you a zero-downtime restart; journalctl -u nuxt-app -f follows the logs.

PM2

PM2 is not deprecated and I am not going to pretend otherwise — it is maintained, and Nuxt's own deployment docs still recommend it as the quick option. What has changed is the config. The old approach of pointing script at a Nuxt CLI binary and passing start as an argument is gone, because that binary and that subcommand are gone.

The current version Nuxt documents is a single process, no arguments, running the built entrypoint:

javascript
module.exports = {
  apps: [
    {
      name: 'nuxt-app',
      script: './.output/server/index.mjs',
      env: { NODE_ENV: 'production' },
    },
  ],
}

Two things to note. Use ecosystem.config.cjs, not .js — if your package.json has "type": "module", a CommonJS config will not load. And if you want PM2 to handle multiple workers, the ecosystem file can do it natively:

javascript
module.exports = {
  apps: [
    {
      name: 'nuxt-app',
      script: './.output/server/index.mjs',
      exec_mode: 'cluster',
      instances: 'max',
      env: { NODE_ENV: 'production' },
    },
  ],
}

One caution specific to PM2. Its pm2 deploy <config> <env> command — the git-push-over-SSH mechanism my 2020 post used — still exists and is still documented, but its documentation is visibly stale: the current PM2 docs still show "ref": "origin/master" as the example. If you are starting fresh, pushing a build artifact from CI is both simpler and easier to audit than letting a laptop SSH into production and run a build there.

Many workers without a supervisor

If you want more than one process and do not want a proxy doing the load balancing, Nitro has a cluster mode built in. Build with the node-cluster preset:

bash
NITRO_PRESET=node_cluster npm run build

A master process forks workers and re-forks any that die. The count comes from NITRO_CLUSTER_WORKERS and defaults to your core count:

bash
NITRO_CLUSTER_WORKERS=3 node .output/server/index.mjs

The preset name is node-cluster but the env var in the Nuxt docs is written node_cluster. Both work — Nitro normalises the name before looking it up — so do not be alarmed by the mismatch.

Presets, and the one that bites

The preset decides what shape the build takes. It is set in config, or per build via the environment:

ts
export default defineNuxtConfig({
  nitro: { preset: 'node-server' },
})
bash
NITRO_PRESET=node-server npm run build

There are around thirty — bun, deno, cloudflare, vercel, netlify, aws-lambda, static and so on. The one to know about is static, which switches Nitro into pre-render-only mode.

Be careful with typos here. Nitro normalises the name and, if nothing matches, quietly falls back to auto-detection rather than failing. A build with NITRO_PRESET=node-serve does not error — it produces a perfectly normal node-server build, and you find out at deploy time. The one case that does throw is a real preset name blocked by an older compatibilityDate, and the error message tells you exactly which date it needs.

TLS and the reverse proxy

Nitro can serve HTTPS itself, but you should not. Terminate at a proxy, proxy to the Node process over loopback, and let the proxy handle certificates and HTTP/2 or 3.

Caddy is the shortest path — automatic certificates, no renewal cron, no certbot:

text
example.com {
  reverse_proxy 127.0.0.1:3000
}

The nginx equivalent, if you already run it:

nginx
server {
  listen 443 ssl http2;
  server_name example.com;

  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

  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;
  }
}

The X-Forwarded-* headers matter. Without X-Forwarded-Proto, Nuxt cannot tell that the original request was HTTPS and will generate http:// URLs for assets and redirects on a site served over TLS.

Deploying without downtime

Build once, ship the output, swap a symlink. This keeps the old release serving traffic until the new one is verified, and makes a rollback a symlink change.

  1. Build in CI and archive .output/.
  2. Upload to a new timestamped release directory on the server.
  3. Point the current symlink at it and reload the service.
bash
# on the server
release=/srv/nuxt-app/releases/$(date +%s)
mkdir -p "$release"
tar -xzf .output.tar.gz -C "$release"
ln -sfn "$release" /srv/nuxt-app/current
systemctl reload nuxt-app

Keep the last few release directories. If the new build is serving errors, ln -sfn back to the previous one and reload.

Build on the same architecture you deploy to

This one has bitten me and it is worth its own section. If your app uses @nuxt/image, the build bakes in prebuilt sharp binaries for whichever machine ran it. Build on linux/amd64, deploy to an arm64 VPS, and image processing fails at runtime in a way that looks nothing like an architecture problem.

The build tells you, at the end, exactly what it bundled:

text
[@nuxt/image] i sharp binaries have been included in your build for darwin-arm64.
Make sure you deploy to the same architecture.

So read the build log. If CI runs on a different architecture from production, set it explicitly, or build inside the target architecture — a container image, or a runner matching the server.

A short checklist

  • Build with npm ci && npm run build; deploy only .output/.
  • Start it with NODE_ENV=production node .output/server/index.mjs.
  • Secrets go in the process manager's environment, never a .env in the release.
  • Use a supervisor — systemd, PM2, or a container runtime — so it restarts on crash and on reboot.
  • Terminate TLS at a proxy and forward X-Forwarded-Proto.
  • Check that the build architecture matches the server.
  • curl a real route after deploying and read the body, not just the status code.

References

Nuxt deployment documentation

Nitro deployment targets

Nitro `node-server` runtime

Nuxt runtime config

PM2 ecosystem file

systemd.service(5)

1 / 5

nuxt build — a self-contained .output/ directory

More Articles

Deploy your Nuxt app using PM2 (outdated)

This post is out of date. Nuxt 4 removed nuxt start, the config file is nuxt.config.ts, and the target: "server" option no longer exists. The pm2 deploy workflow below still exists, but it is no longer how I would set this up. My current replacement: Deploy your Nuxt app to production →

Using Sanity.io with Nuxt 4

This site used to keep its blog posts as Markdown files and read them at build time. It now keeps them in Sanity and queries them at runtime. That migration is mostly plumbing, but a handful of things about it are genuinely counter-intuitive, and each one cost me time, so this post is about those rather than about how to install a module.