Nuxt 3 is unusually pleasant to deploy because of Nitro, its server engine. nuxt build produces a single self-contained .output/ directory — server code, client assets, and dependencies bundled together. No node_modules install on the production box, no build step at container start. But there are a few Nuxt-specific things that trip people up: the runtime env var naming convention, the host binding, and the decision between SSR and static generation. Here's the whole path, end to end.
SSR server or static site? Decide first
Nuxt gives you two production modes and they deploy completely differently:
# Universal rendering — a Node server (Nitro) renders pages on request
npx nuxi build
# Full static generation — pre-renders every route to HTML at build time
npx nuxi generatenuxi generate writes a static site to .output/public. If your app is a content site, docs, or marketing page with no per-request server logic, deploy that directory as a static site and skip everything below about ports and databases — it's cheaper and there's nothing to keep warm.
If you use server routes (server/api/), authenticated SSR, or a database, you want nuxi build and a running Node server. The rest of this guide assumes that.
The production build
npm ci
npm run build # runs `nuxt build`The output lands in .output/:
.output/
├── nitro.json
├── public/ # client assets, served by Nitro
└── server/
├── index.mjs # the entrypoint
└── ... # bundled server code + depsStart it with:
node .output/server/index.mjsThat's the entire production runtime. The directory is portable — you can build in CI and copy only .output/ to the runtime image.
Port and host
Nitro's Node server reads NITRO_PORT or PORT (defaulting to 3000), and NITRO_HOST or HOST. Two things matter in a container:
- Set
HOST=0.0.0.0(orNITRO_HOST=0.0.0.0) so the server binds to all interfaces, not localhost. This is the single most common "it builds but the healthcheck fails" cause. - Respect the platform's injected
PORTrather than hardcoding one. Nitro already does this — just don't override it with a fixed value innuxt.config.ts.
Runtime config: the NUXT_ env var convention
This is the Nuxt gotcha worth understanding properly. Environment variables in Nuxt don't magically appear at runtime — only keys declared in runtimeConfig can be overridden by env vars, and the naming is mechanical:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// server-only — override with NUXT_DATABASE_URL
databaseUrl: '',
// exposed to the client — override with NUXT_PUBLIC_API_BASE
public: {
apiBase: '/api',
},
},
})The rule: NUXT_ + the key path in SCREAMING_SNAKE_CASE, with public. becoming PUBLIC_. So runtimeConfig.databaseUrl maps to NUXT_DATABASE_URL, and runtimeConfig.public.apiBase maps to NUXT_PUBLIC_API_BASE.
Because these are read at runtime, you can change NUXT_PUBLIC_API_BASE in your platform's env settings and restart — no rebuild. Compare that with values inlined at build time via process.env in components, which are frozen into the bundle. If a value might differ between environments, put it in runtimeConfig.
Access it in server routes with useRuntimeConfig():
// server/api/health.get.ts
export default defineEventHandler(() => {
const config = useRuntimeConfig()
return { db: Boolean(config.databaseUrl) }
})Wiring up PostgreSQL with Prisma
For the database layer, Prisma is the common choice in Nuxt projects. It reads DATABASE_URL by default, which is convenient because that's exactly what managed platforms inject.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
createdAt DateTime @default(now())
}Create a single shared client so hot paths don't open a new connection per request:
// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prismaAnything in server/utils/ is auto-imported in Nuxt server routes, so handlers can just use prisma.post.findMany().
Two production rules:
- 1Run
npx prisma generateas part of the build (add it to apostinstallscript) so the client exists inside the bundle. - 2Apply migrations with the forward-only command, as a separate step before the new version takes traffic:
npx prisma migrate deployNever run migrations inside a Nitro plugin at boot. If two instances start simultaneously during a rolling deploy, they race on the same migration lock, and a failed migration takes your app down with it instead of failing the release step cleanly.
A Dockerfile, if you want one
You don't strictly need a Dockerfile on a platform with Node buildpack detection, but if you prefer explicit control:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0
COPY --from=build /app/.output ./.output
EXPOSE 3000
USER node
CMD ["node", ".output/server/index.mjs"]Note what's *not* in the final stage: no npm ci, no node_modules. Nitro bundles the server dependencies into .output/server, which keeps the runtime image small and the cold start short.
Deploying on PandaStack
The Git-push flow looks like this:
- 1Push your Nuxt repo to GitHub and connect it as a container app in the PandaStack dashboard. The platform auto-detects the framework and build command; npm, yarn, pnpm, and bun are all supported for the install step.
- 2If auto-detection needs correcting, the commands are: build
npm run build, startnode .output/server/index.mjs.PORTis injected and Nitro picks it up automatically — just make sureHOST=0.0.0.0is set in the app's environment variables. - 3Provision a managed PostgreSQL instance (14.x and 16.x are available) and attach it to the app.
DATABASE_URLis injected automatically, which is precisely the variable Prisma's datasource reads — no copying credentials. - 4Add your other env vars (
NUXT_PUBLIC_API_BASEand friends) in the dashboard. - 5Push. Builds run in rootless BuildKit inside ephemeral Kubernetes Job pods, and the build logs stream live, so when
nuxi buildfails on a type error you see it as it happens rather than five minutes later.
From then on, every git push builds and deploys. Rollbacks and deployment history are built in if a release goes sideways.
One honest note on the free tier: apps scale to zero when idle, so the first request after a quiet period pays a cold start while the container spins back up. For a portfolio or side project that's a fine trade for $0; for a production app with latency expectations, a paid tier keeps instances warm on stable nodes.
The checklist
npx nuxi buildfor SSR,npx nuxi generate+ static hosting for content sites- Start command:
node .output/server/index.mjs— nonode_modulesneeded at runtime HOST=0.0.0.0, let the platform'sPORTflow through- Runtime-changeable values go in
runtimeConfig, overridden viaNUXT_*env vars prisma generateat build time,prisma migrate deployas a release step — never at bootDATABASE_URLcomes from the platform; Prisma reads it with zero glue code
That's a production Nuxt 3 deployment without surprises. If you want to see the git-push-to-live loop with the database wired in for you, give it a run at https://pandastack.io.