Deployment
TL;DR
Nuxt deploys anywhere via Nitro presets. Set NITRO_PRESET (or configure in nuxt.config.ts) and Nitro compiles your app for that target: Node.js server, Vercel serverless, Cloudflare Workers, AWS Lambda, Docker, etc. Same code, different output. Environment variables split into build-time (baked in) and runtime (injected at startup without rebuilding).
Mental Model
Source Code β nuxt build (with preset) β .output/ β Deploy
The .output/ directory is self-contained:
.output/server/β compiled server (Nitro, entry point:index.mjs).output/public/β static assets + prerendered HTML
For the Node.js preset: node .output/server/index.mjs starts the production server. Nothing else needed β no node_modules required at runtime (dependencies are bundled).
Preset Overview
| Preset | Target | Server Required | Cold Start |
|---|---|---|---|
node-server | Node.js (VM, Docker) | β | None |
vercel | Vercel Serverless | Auto-managed | ~200ms |
vercel-edge | Vercel Edge Functions | Auto-managed | ~50ms |
netlify | Netlify Functions | Auto-managed | ~200ms |
cloudflare-pages | Cloudflare Workers | Auto-managed | ~20ms |
aws-lambda | AWS Lambda | Auto-managed | ~300ms |
deno-server | Deno runtime | β | None |
bun | Bun runtime | β | None |
static | Static hosting (S3, etc.) | β | None |
Set the preset:
# Via environment variable (recommended for CI)
NITRO_PRESET=node-server npx nuxi build
# Or in config
export default defineNuxtConfig({
nitro: { preset: 'node-server' },
})
Auto-detection: On Vercel, Netlify, and Cloudflare, Nitro auto-detects the platform and selects the correct preset. You usually donβt need to set it explicitly for these platforms.
Node.js Server Deployment
The default and most flexible option:
# Build
npx nuxi build
# Preview locally
npx nuxi preview
# Production start
node .output/server/index.mjs
The server listens on PORT (default: 3000) and HOST (default: 0.0.0.0):
PORT=8080 HOST=0.0.0.0 node .output/server/index.mjs
Process management with PM2:
// ecosystem.config.cjs
module.exports = {
apps: [{
name: 'my-nuxt-app',
script: '.output/server/index.mjs',
instances: 'max',
exec_mode: 'cluster',
env: {
PORT: 3000,
NUXT_PUBLIC_API_BASE: 'https://api.production.com',
},
}],
}
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup # Auto-start on reboot
Docker Deployment
Multi-stage Dockerfile for minimal production image:
# Build stage
FROM node:20-slim AS build
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Production stage
FROM node:20-slim AS production
WORKDIR /app
# Copy only the self-contained output
COPY --from=build /app/.output .output
# Set environment
ENV PORT=3000
ENV HOST=0.0.0.0
ENV NODE_ENV=production
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start
CMD ["node", ".output/server/index.mjs"]
Key points:
- The runtime stage has NO
node_modulesβ everything is bundled in.output/ - Image size: ~150MB (node:20-slim base) vs ~1GB (full node image with source)
.output/is fully self-contained β copy it anywhere
docker build -t my-nuxt-app .
docker run -p 3000:3000 \
-e NUXT_PUBLIC_API_BASE=https://api.prod.com \
-e NUXT_API_SECRET=s3cr3t \
my-nuxt-app
Environment Variables
Two categories:
Build-time variables β used during nuxt build, baked into the output:
NUXT_PUBLIC_*values used in config but NOT inruntimeConfigare build-time- Viteβs
import.meta.env.VITE_*are build-time - Changing these requires a rebuild
Runtime variables β override runtimeConfig at startup:
NUXT_*variables matching the runtimeConfig structure- Read when the server starts, no rebuild needed
- The primary way to configure per-environment
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // β NUXT_API_SECRET
database: { url: '' }, // β NUXT_DATABASE_URL
public: {
apiBase: 'http://localhost:3000', // β NUXT_PUBLIC_API_BASE
appVersion: '1.0.0', // β NUXT_PUBLIC_APP_VERSION
},
},
})
# Production deployment
NUXT_API_SECRET=prod-secret-key \
NUXT_DATABASE_URL=postgres://prod:5432/app \
NUXT_PUBLIC_API_BASE=https://api.myapp.com \
node .output/server/index.mjs
.env for local development:
# .env (loaded automatically by Nuxt in dev mode)
NUXT_API_SECRET=dev-secret
NUXT_PUBLIC_API_BASE=http://localhost:3000/api
Note: .env is loaded in nuxi dev and nuxi build but NOT at runtime in production. For production, set env vars through your platform (Docker, Vercel dashboard, systemd environment, etc.).
Vercel / Netlify / Cloudflare
Vercel β zero-config:
# Just push to git β Vercel auto-detects Nuxt
git push origin main
Or via CLI:
npx vercel
Vercel auto-detects the Nuxt framework, selects the vercel preset, and deploys. Set env vars in the Vercel dashboard.
Cloudflare Pages:
npx nuxi build --preset cloudflare-pages
npx wrangler pages deploy .output/public
# wrangler.toml
name = "my-nuxt-app"
compatibility_date = "2024-01-01"
[site]
bucket = ".output/public"
Edge vs Node.js tradeoffs:
| Β | Node.js (Serverless) | Edge (Workers) |
|---|---|---|
| Cold start | ~200-300ms | ~20-50ms |
| Node APIs | Full | Limited (no fs, no native modules) |
| npm compat | Excellent | Limited (some packages fail) |
| Memory | Up to 3GB | 128MB |
| Execution time | 5-60 minutes | 30 seconds |
| Best for | Complex apps, DB access | Simple APIs, auth, redirects |
Health Check Endpoint
Every production app needs a health check:
// server/api/health.get.ts
export default defineEventHandler(async (event) => {
const checks: Record<string, 'ok' | 'error'> = {}
// Check database connectivity
try {
await db.execute('SELECT 1')
checks.database = 'ok'
} catch {
checks.database = 'error'
}
// Check external service
try {
await $fetch('https://api.external.com/status', { timeout: 2000 })
checks.external = 'ok'
} catch {
checks.external = 'error'
}
const healthy = Object.values(checks).every(v => v === 'ok')
if (!healthy) {
setResponseStatus(event, 503)
}
return {
status: healthy ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
checks,
version: useRuntimeConfig().public.appVersion,
}
})
Load balancers, Kubernetes, and Docker use this endpoint to determine if the instance should receive traffic.
Graceful Shutdown
Nitro handles SIGTERM gracefully by default β it stops accepting new connections and lets in-flight requests complete. For custom cleanup:
// plugins/shutdown.server.ts
export default defineNuxtPlugin((nuxtApp) => {
// Custom cleanup on shutdown
nuxtApp.hook('close', async () => {
console.log('Shutting down...')
await closeDbConnections()
await flushLogs()
})
})
In Docker/Kubernetes, send SIGTERM and allow a grace period:
# Kubernetes
terminationGracePeriodSeconds: 30
# Docker β proper signal handling
STOPSIGNAL SIGTERM
Build Output Structure
.output/
βββ server/
β βββ index.mjs β Entry point
β βββ chunks/ β Server code chunks
β βββ package.json β Minimal (type: module)
βββ public/
β βββ _nuxt/ β Client JS/CSS chunks (hashed)
β βββ index.html β Prerendered pages (if any)
β βββ favicon.ico β Static assets
βββ nitro.json β Build metadata
The entire .output/ directory is your deployable artifact. Copy it to any server with Node.js and run.
Walkthrough
- Build with node-server preset:
npx nuxi build ls -la .output/ - Run production locally:
NUXT_PUBLIC_API_BASE=http://localhost:3000/api node .output/server/index.mjs -
Create a Dockerfile β multi-stage build as shown above. Build and run the container.
- Test env var override:
# Change a public value without rebuilding NUXT_PUBLIC_APP_VERSION=2.0.0 node .output/server/index.mjs # Verify the change in the running app - Add health check β create
/api/health. Test it:curl http://localhost:3000/api/health.
Gotchas
NITRO_PRESETmust match your target. Building withnode-serverand deploying to Cloudflare Workers will fail. The preset determines the output format.- Runtime config vars must follow the naming convention.
NUXT_PUBLIC_API_BASEmaps toruntimeConfig.public.apiBase. The path is SCREAMING_SNAKE_CASE. Nested objects use underscore separators. public/assets are served directly. Donβt put secrets inpublic/. Files there are accessible to anyone at the URL path.- Cloudflare Workers have no Node.js APIs.
fs,path,crypto(partially), native addons β none available. Many npm packages fail. Test on the target platform early. - Docker:
.output/is self-contained. Donβt copynode_modulesto the runtime stage β itβs unnecessary and bloats the image. Everything is bundled. - Build-time env vars are permanent. If you set
NUXT_PUBLIC_API_BASE=http://staging.api.comduring build, itβs baked in. Runtime override works forruntimeConfigvalues only β not for all env vars. .envis NOT loaded in production. Itβs a dev convenience. In production, set env vars through your platformβs configuration (Docker env, Vercel settings, systemd EnvironmentFile, etc.).- Cold starts in serverless. First request after idle period takes longer (Lambda: ~300ms, Workers: ~20ms). Keep functions warm for latency-sensitive applications, or use edge runtime.
- Multiple instances donβt share memory. In-memory caches, rate limiting state, and WebSocket connections are per-instance. Use external stores (Redis, database) for shared state in clustered deployments.
Exercise
- Build for production:
NITRO_PRESET=node-server npx nuxi build -
Create a Dockerfile using the multi-stage pattern. Build and run it.
- Configure environment:
- Define
runtimeConfigwith:apiSecret(private),public.apiBase,public.appVersion - Override all three via Docker env vars at runtime
- Verify the private key is NOT accessible from the client
- Define
- Add health check:
GET /api/healthreturns{ status, timestamp, version }- Returns 503 if a simulated dependency check fails
- Document your env vars:
- Create a table listing each var, whether itβs build-time or runtime, and its purpose
- This table should live in your README or deployment documentation