How to Deploy a Nuxt App Without Docker
Nuxt 3 is one of the friendlier frameworks to deploy, and most of the difficulty people hit is self-inflicted — a Dockerfile written from a half-remembered template, then a week of debugging why the app builds fine and serves nothing. The underlying reality is simple: `nuxt build` produces a self-contained Node server via Nitro, and running it is one command. Everything else is details, but the details are where deploys die.
I'm Ajay, I build PandaStack. This walks through deploying Nuxt without a container, the four things that actually break, and how it looks on a platform that builds straight from your repo.
First: know what the build actually produces
Run `nuxt build` with the default (Node) preset and you get a `.output/` directory. Inside it, `.output/server/index.mjs` is a complete Node HTTP server, and `.output/public/` holds the static assets it serves. That directory is the deployable artifact — it doesn't need `node_modules`, it doesn't need your source, and it doesn't need Nuxt installed to run.
npm ci
npm run build # -> .output/server/index.mjs + .output/public/
# That's the whole runtime command. No Docker, no PM2 required.
node .output/server/index.mjsThe number one failure: binding the wrong host
Nitro's server reads `PORT` and `HOST` from the environment. In development it binds `localhost`, which is exactly right on your laptop and exactly wrong in production: every managed platform health-checks your app over the network, from outside the container or VM. An app listening only on 127.0.0.1 answers your own curl inside the box and refuses every external connection — so the deploy goes red while the logs cheerfully claim the server started.
# Correct in production: bind all interfaces, take the port from the env.
HOST=0.0.0.0 PORT=$PORT node .output/server/index.mjs
# Wrong, and the single most common cause of a failed health check:
HOST=127.0.0.1 node .output/server/index.mjsGood platforms export both variables for you before running your start command, so plain `node .output/server/index.mjs` picks them up. Verify that's the case rather than assuming it — it's the difference between a deploy that works and thirty minutes of confusion.
Runtime config vs build-time env vars
This trips up nearly everyone once. Nuxt's `runtimeConfig` is the supported way to pass configuration, and it has a specific rule: keys are read from environment variables at server startup, using the `NUXT_` prefix, with nesting flattened by underscores. Anything under `public` ships to the browser; anything else stays server-side.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only. Read from NUXT_API_SECRET at startup.
apiSecret: "",
public: {
// Sent to the browser. Read from NUXT_PUBLIC_API_BASE at startup.
apiBase: "/api",
},
},
});// In a server route -- values come from the environment at RUNTIME,
// so you can change them without rebuilding.
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event);
return fetch(upstream, {
headers: { authorization: `Bearer ${config.apiSecret}` },
});
});Deploying it, concretely
On any platform that runs a Node process, the recipe is the same three commands. Here's what that looks like on PandaStack, where the app runs in a Firecracker microVM built directly from your repo — no Dockerfile and no registry.
pandastack app create --name storefront \
--git-url https://github.com/acme/storefront \
--git-branch main
# Nuxt is auto-detected: install and build come from your package manager,
# and the start command defaults to node .output/server/index.mjs
pandastack app deploy <app-id> --followNode version comes from your repo the idiomatic way — a `.nvmrc`, `.tool-versions`, or `mise.toml` file — rather than from a platform-specific setting, which means the same file that controls your local version controls production. If Nuxt 4 or a native dependency needs something specific, pin it there and both environments follow.
# .nvmrc -- one line, respected locally and in the build
22Memory: the build is heavier than the app
A running Nuxt server is modest — a few hundred megabytes for most apps. The build is not. Vite plus TypeScript on a large app can comfortably exceed 2 GiB, and the failure mode is a build that dies partway through with an out-of-memory error or, worse, a bare 'Killed' with no explanation at all.
Two practical responses. First, give the build room: 4 GiB is a sensible floor for a non-trivial Nuxt app, and on PandaStack builds run inside the app's own VM at the same rate as runtime, so a ten-minute build costs about half a cent — there are no separate build minutes to ration. Second, if you're still hitting the ceiling, raise Node's heap limit explicitly rather than guessing.
# When the build dies with a heap OOM (or a bare "Killed"), raise the
# ceiling explicitly -- the default is well below what a big Vite build wants.
NODE_OPTIONS="--max-old-space-size=4096" npm run buildThe pre-deploy checklist
- `npm ci && npm run build && node .output/server/index.mjs` works on a clean checkout — no stale node_modules, no local-only files.
- The server binds 0.0.0.0 and reads PORT from the environment.
- Every environment-varying value goes through runtimeConfig, not inlined process.env in component code.
- Build-time variables are available during the build step, not only at start.
- Node version is pinned in the repo (.nvmrc or .tool-versions), not configured only on the platform.
- The build has enough memory — 4 GiB is a safe floor for a real app.
The summary
Nuxt doesn't need a container. It needs a Node runtime, the `.output` directory, a correct host binding, and clarity about which configuration is resolved at build time versus at startup. Get those four things right and deploying is genuinely `git push`; get the host binding wrong and you'll spend an afternoon reading logs from an app that started perfectly and can't be reached.
Frequently asked questions
Do I need a Dockerfile to deploy Nuxt?
No. `nuxt build` produces a self-contained Node server at .output/server/index.mjs that runs with a single node command and doesn't need node_modules or your source at runtime. A container is one way to package that, and it's useful if you need specific system libraries or you're deploying into Kubernetes, but plenty of platforms build straight from the repository and run the output directly. If you're writing a Dockerfile purely because a tutorial had one, you can usually skip it.
Why does my Nuxt app build successfully but return nothing in production?
The overwhelmingly common cause is host binding: the server is listening on 127.0.0.1 instead of 0.0.0.0, so it's unreachable from outside the container or VM even though the process is healthy and the logs look fine. Set HOST=0.0.0.0 and read PORT from the environment. The second most common cause is a runtime-config value that was actually inlined at build time, leaving the client bundle with undefined where an API base URL should be — that one usually shows as a blank page with a fetch error in the console.
How do I pass environment variables to Nuxt in production?
Use runtimeConfig in nuxt.config.ts and set the matching NUXT_-prefixed environment variables on your platform. Nuxt reads them at server startup, so you can change a value and restart without rebuilding — the whole point of the mechanism. Anything you place under runtimeConfig.public is exposed to the browser, so keep secrets out of it. Directly referencing process.env in component code is the pattern to avoid, because those values are resolved when the bundle is built rather than when the server starts.
What's the difference between nuxt build and nuxt generate for deployment?
`nuxt build` produces a Node server that renders pages on request and can run server routes and API handlers — that's what you deploy for SSR or anything with a backend. `nuxt generate` pre-renders every page to static HTML at build time and emits only .output/public, which you can serve from any static host or CDN with no Node process at all. Static is cheaper and simpler when your content doesn't change per request; the moment you need server routes, authentication, or per-user rendering, you want the server build.
How much memory does a Nuxt build need?
More than the running app, often several times more. A modest site builds fine in 2 GiB, but a large app with many routes and heavy TypeScript can exceed that and die with a heap out-of-memory error or a bare 'Killed' message from the OOM killer. 4 GiB is a safe floor for a real application. If you're still hitting the wall, set NODE_OPTIONS=--max-old-space-size explicitly rather than hoping the default is enough — the default heap limit is frequently below what a large Vite build wants.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.