Back to Blog
Tutorial7 min read2026-07-12

How to Deploy Nuxt 3 with a Managed PostgreSQL Database

Deploy a Nuxt 3 app to production: the .output build, Nitro port/host config, NUXT_ runtime env vars, Prisma migrations, and going live via Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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 generate

nuxi 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 + deps

Start it with:

node .output/server/index.mjs

That'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 (or NITRO_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 PORT rather than hardcoding one. Nitro already does this — just don't override it with a fixed value in nuxt.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 prisma

Anything in server/utils/ is auto-imported in Nuxt server routes, so handlers can just use prisma.post.findMany().

Two production rules:

  1. 1Run npx prisma generate as part of the build (add it to a postinstall script) so the client exists inside the bundle.
  2. 2Apply migrations with the forward-only command, as a separate step before the new version takes traffic:
npx prisma migrate deploy

Never 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:

  1. 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.
  2. 2If auto-detection needs correcting, the commands are: build npm run build, start node .output/server/index.mjs. PORT is injected and Nitro picks it up automatically — just make sure HOST=0.0.0.0 is set in the app's environment variables.
  3. 3Provision a managed PostgreSQL instance (14.x and 16.x are available) and attach it to the app. DATABASE_URL is injected automatically, which is precisely the variable Prisma's datasource reads — no copying credentials.
  4. 4Add your other env vars (NUXT_PUBLIC_API_BASE and friends) in the dashboard.
  5. 5Push. Builds run in rootless BuildKit inside ephemeral Kubernetes Job pods, and the build logs stream live, so when nuxi build fails 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 build for SSR, npx nuxi generate + static hosting for content sites
  • Start command: node .output/server/index.mjs — no node_modules needed at runtime
  • HOST=0.0.0.0, let the platform's PORT flow through
  • Runtime-changeable values go in runtimeConfig, overridden via NUXT_* env vars
  • prisma generate at build time, prisma migrate deploy as a release step — never at boot
  • DATABASE_URL comes 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also