Fullstack with Nitro

Deployment and operations

Nitro presets and what each implies — Node in Docker, Cloudflare Workers with bindings, Vercel and Netlify, static — plus runtime configuration and secrets, health checks and graceful shutdown, structured logging with request ids, error and performance monitoring, migrations in the release, zero-downtime deploys and rollbacks, cost, and the NuxtHub shift to self-hosting.

Shipping is half of fullstack. "How is it deployed, how do you know it is healthy, how do you roll back" are the questions that reveal whether someone has operated what they built. Nuxt's answer starts with a Nitro preset and ends with the same operational discipline any service needs; this app itself is the static case, deployed to Cloudflare's asset hosting with wrangler.

Know

  • Presets decide the runtime: node-server (default; a self-contained .output/server/index.mjs, no node_modules, ideal in a small Docker image), node-cluster (multi-process), cloudflare_module (Workers with static assets; bindings for KV, D1, R2, Queues, Durable Objects via event.context.cloudflare.env; Hyperdrive for Postgres; nodejs_compat for Node APIs; CPU-time limits), vercel (serverless functions, ISR support, vercel-edge for edge), netlify, aws-lambda, azure, deno-deploy, bun, static (nuxt generate; this app, on Cloudflare asset hosting), and more; most platforms are auto-detected, or set nitro.preset/NITRO_PRESET. nuxt build --preset overrides.
  • Configuration at runtime: runtimeConfig values are read from NUXT_* environment variables when the server starts; secrets come from the platform's secret store (Docker secrets, Cloudflare secrets, Vercel env), never from a committed .env; compatibilityDate pins behaviour; one build serves every environment.
  • Health and lifecycle: a /api/health (liveness: process up) and optionally /api/ready (readiness: database reachable) for orchestrators; graceful shutdown on SIGTERM (stop accepting, finish in-flight, close pools; Nitro's close hook), timeouts on everything.
  • Logging: structured JSON lines with level, message, requestId, route, duration and user id where allowed, no secrets or PII; consola (Nitro's logger) or pino; a request-id middleware that also returns x-request-id; log aggregation (Loki, CloudWatch, Datadog) with retention.
  • Monitoring: error tracking with @sentry/nuxt (client and server, source maps uploaded at build, releases tagged with the git SHA), performance tracing (OpenTelemetry where the platform supports it), uptime checks from outside, Core Web Vitals RUM (web-vitals → your endpoint or the vendor), dashboards for p95 latency, error rate, cache hit ratio, and alerts with owners.
  • Releases: build once in CI, run migrations (expand-then-contract) before the new version takes traffic, deploy with zero downtime (rolling or blue-green; serverless does it inherently), keep the previous artifact for rollback (redeploy the last good image or Worker version), gate risky features behind flags, and purge CDN caches for changed HTML after security fixes.
  • Static and hybrid split: marketing and docs as static or swr pages on a CDN; the app as a server or edge deployment; both from one repository via route rules. A CDN (Cloudflare, Fastly) in front of a Node server for TLS, caching, WAF and rate limiting is normal.
  • Containers: multi-stage Dockerfile (install and build in one stage, copy .output into node:24-alpine), non-root user, NITRO_PORT/HOST, a HEALTHCHECK, resource limits; images are tiny because .output bundles dependencies.
  • Cost model: edge and serverless bill per request and scale to zero (good for spiky, cheap for small), Node servers are predictable and better for long-lived connections and heavy CPU; database connections are the usual serverless surprise (poolers, Hyperdrive).
  • NuxtHub (Cloudflare-native database, KV, blob, cache helpers): the hosted Admin was sunset on 31 December 2025 and CLI deployments stopped in February 2026; the module continued as self-hosted and multi-vendor (v0.10+), deploying with Wrangler or the platform's own CI. Say "self-hosted NuxtHub or plain Wrangler" for Cloudflare.
  • Preview and staging: a preview deployment per pull request with seeded data and sandboxed third parties; environment parity through the same build and runtimeConfig overrides; nuxt preview to run the production build locally.

How it works

Node in a container, with health, shutdown and structured logs:

Dockerfile
FROM node:24-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm nuxt build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production NITRO_PORT=3000 HOST=0.0.0.0
USER node
COPY --from=build --chown=node:node /app/.output ./.output
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", ".output/server/index.mjs"]
server/api/health.get.ts
export default defineEventHandler(() => ({ status: 'ok', version: useRuntimeConfig().public.version, uptime: process.uptime() }))
server/api/ready.get.ts
export default defineEventHandler(async (event) => {
  try {
    await useDb().execute(sql`select 1`)
    return { status: 'ready' }
  }
  catch {
    throw createError({ status: 503, statusText: 'Database unavailable' })
  }
})
server/plugins/lifecycle.ts
export default defineNitroPlugin((nitroApp) => {
  const log = (level: string, msg: string, extra: Record<string, unknown> = {}) => console.log(JSON.stringify({ level, msg, time: new Date().toISOString(), ...extra }))

  nitroApp.hooks.hook('request', (event) => {
    event.context.requestId = getHeader(event, 'x-request-id') ?? crypto.randomUUID()
    event.context.startedAt = performance.now()
    setResponseHeader(event, 'x-request-id', event.context.requestId)
  })
  nitroApp.hooks.hook('afterResponse', (event) => {
    log('info', 'request', { requestId: event.context.requestId, method: event.method, path: event.path, status: getResponseStatus(event), ms: Math.round(performance.now() - event.context.startedAt) })
  })
  nitroApp.hooks.hook('error', (error, { event }) => {
    log('error', 'unhandled', { requestId: event?.context.requestId, path: event?.path, error: String(error) })
  })
  nitroApp.hooks.hook('close', async () => {
    log('info', 'shutting down: closing pools')
    await useDb().$client.end?.() // drain connections on SIGTERM
  })
})

Cloudflare Workers with bindings, and the release pipeline order:

wrangler.jsonc
{
  "name": "shop",
  "main": "./.output/server/index.mjs",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "directory": "./.output/public", "binding": "ASSETS" },
  "kv_namespaces": [{ "binding": "CACHE", "id": "..." }],
  "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "shop-uploads" }],
  "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "..." }],
  "observability": { "enabled": true }
}
nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare_module',
    cloudflare: { deployConfig: true, nodeCompat: true },
    storage: { cache: { driver: 'cloudflare-kv-binding', binding: 'CACHE' }, uploads: { driver: 'cloudflare-r2-binding', binding: 'UPLOADS' } },
  },
  sourcemap: { server: true, client: 'hidden' }, // uploaded to Sentry, not served
  modules: ['@sentry/nuxt/module'],
})
.github/workflows/deploy.yml (excerpt)
- run: pnpm nuxt build
- run: pnpm drizzle-kit migrate            # expand-only migrations, before traffic switches
  env: { DATABASE_URL: ${{ secrets.DATABASE_URL }} }
- run: npx wrangler deploy                 # atomic version switch; `wrangler rollback` restores the previous version
- run: npx @sentry/cli releases finalize ${{ github.sha }}

Runtime configuration the same way everywhere:

# Docker / VM
NUXT_DATABASE_URL=postgres://... NUXT_SESSION_PASSWORD=... node .output/server/index.mjs
# Cloudflare
wrangler secret put NUXT_SESSION_PASSWORD
# Vercel / Netlify: project environment variables named NUXT_*
Gotcha· Building per environment

Baking API_URL into the client bundle at build time means a separate build for staging and production and a real risk of shipping the wrong one. runtimeConfig plus NUXT_* variables lets one artifact run everywhere; only truly public build-time constants belong in vite.define.

Gotcha· Migrations after the deploy

Deploying code that expects a new column before the migration ran is an outage; running a destructive migration before old instances stop is another. Expand first (add columns, backfill), deploy, then contract in a later release once nothing reads the old shape.

Verify before the interview:

The current preset names (cloudflare_module versus cloudflare-pages), Cloudflare's Nuxt guide for Workers with static assets, and NuxtHub's self-hosting instructions after the Admin sunset.

docs ↗

Exercise

Exercise
  • Build the Docker image above, run it with NUXT_* variables, hit /api/health and /api/ready, then stop the database and watch readiness fail while liveness stays green.
  • Deploy the same app to Cloudflare Workers with a KV cache binding; compare cold-start latency with the Node container behind a CDN.
  • Break a deploy on purpose (throw in a plugin), practise wrangler rollback or redeploying the previous image, and time the recovery.

Be able to say

Be able to say· How do you deploy and operate a Nuxt application?

"The Nitro preset decides the runtime: a self-contained Node server in a small Docker image behind a CDN when I need long-lived connections or heavy CPU; Cloudflare Workers with KV, R2 and Hyperdrive bindings when I want scale-to-zero at the edge; Vercel or Netlify when the team lives there; static for marketing and docs. One build serves every environment because configuration arrives through NUXT_* variables at start-up and secrets come from the platform's store. Operationally: liveness and readiness endpoints, graceful shutdown on SIGTERM, JSON logs with a request id that is also returned to the client, Sentry for errors on both sides with source maps and release tags, RUM for Core Web Vitals, and alerts on error rate and p95. Releases build once, run expand-only migrations before traffic switches, deploy with zero downtime, and keep the previous version one command away for rollback."

Be able to say· What changed with NuxtHub, and what would you use on Cloudflare today?

"NuxtHub started as a Cloudflare-specific hosting layer with a hosted Admin; the Admin was sunset at the end of 2025 and its CLI deployments stopped in February 2026, and the project pivoted to self-hosting and a multi-vendor direction. So on Cloudflare today I would deploy with Wrangler directly: the cloudflare_module preset, wrangler.jsonc with KV, R2, D1 or Hyperdrive bindings surfaced through event.context.cloudflare.env or Nitro storage drivers, Workers' own CI or GitHub Actions, and wrangler rollback for recovery, optionally keeping the NuxtHub module in self-hosted mode for its database, KV and blob helpers."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.