So you have a Nuxt 4 application with server-side rendering and you want to ship it in a Docker container using pnpm as the package manager. That's exactly what this post covers.

What are we doing?

Nuxt 4 builds your app into a self-contained .output directory that contains the Nitro server runtime. Our goal is a two-stage build:

  1. A builder stage that installs dependencies and runs the build with pnpm.
  2. A runtime stage that only contains the compiled output and the Node.js runtime.

The result is a small, production-ready image with no build tooling or dev dependencies in it.

Prerequisites

  • Docker installed and running
  • Node.js 24+ and pnpm installed locally (for building the lockfile)
  • A Nuxt 4 project that builds with pnpm build

Files that matter

Your project already has most of what we need:

  • package.json — scripts and, ideally, a packageManager field pinning pnpm
  • pnpm-lock.yaml — committed to the repo
  • pnpm-workspace.yaml — more on this in a second
  • nuxt.config.ts — your Nuxt configuration
  • .dockerignore and Dockerfile — created in the next steps

Step 1: Configure pnpm to allow esbuild

This one is easy to miss. Since pnpm 10, build scripts of dependencies are blocked by default for security reasons. Nuxt uses esbuild, which has an install script, so you need to explicitly allow it in your pnpm-workspace.yaml:

allowBuilds:
  esbuild: true

If you skip this, you'll see an Ignored build scripts: esbuild warning and the build will fail later with something like The esbuild binary for platform "linux-musl" cannot be found.

Step 2: Create the .dockerignore

Keep the build context small and, more importantly, avoid sending local dependencies or secrets into the image:

node_modules
.git
.output
.nuxt
.env
*.log
Dockerfile
.dockerignore

Step 3: The multi-stage Dockerfile

Here is the full Dockerfile:

FROM node:24-alpine AS builder

RUN corepack enable && corepack prepare pnpm@11.21.0 --activate

WORKDIR /app

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

FROM node:24-alpine

USER node
WORKDIR /app
COPY --from=builder /app/.output ./.output

COPY --from=builder /app/posts ./posts

EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

Let's go through it piece by piece.

The builder stage

FROM node:24-alpine AS builder

We start from the same Node major version used locally. Alpine keeps the image small.

RUN corepack enable && corepack prepare pnpm@11.21.0 --activate

Corepack ships with Node and lets us pin the exact pnpm version. You can skip the version and rely on the packageManager field of your package.json instead, but pinning here makes the build deterministic regardless of the base image.

WORKDIR /app

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile

We copy the lockfile before the rest of the source. This way Docker can cache the dependency installation layer, and pnpm install only re-runs when the lockfile actually changes. --frozen-lockfile makes sure CI and Docker use the exact locked versions and fail if the lockfile is out of date. Notice that pnpm-workspace.yaml is copied too — if you don't, pnpm will complain about the missing workspace file.

COPY . .
RUN pnpm build

Only now we copy the full source and run the build. pnpm build runs nuxt build and produces the .output directory.

The runtime stage

FROM node:24-alpine

USER node
WORKDIR /app
COPY --from=builder /app/.output ./.output

A fresh stage means the final image only has the compiled output — no node_modules, no source code, no build tooling. The USER node directive switches to the built-in non-root user, so the server doesn't run with root privileges. The files copied from the builder stage are still readable by this user.

COPY --from=builder /app/posts ./posts

If your app reads files at runtime, copy those directories here too. For example, a file-based CMS might keep its content in a content/ or posts/ directory. Adapt it to your own data directories or remove it entirely.

EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

Nuxt 4 runs on port 3000 by default, and the Nitro server entry point is .output/server/index.mjs.

Step 4: Build and run

docker build -t my-nuxt-app .

docker run -d -p 3000:3000 --name my-nuxt-app my-nuxt-app

Then visit http://localhost:3000. To check the logs:

docker logs -f my-nuxt-app

Step 5: docker-compose example

For orchestration, restart policies and health checks, a docker-compose.yaml is handy:

services:
  app:
    build: .
    container_name: nuxt-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - HOST=0.0.0.0
      - PORT=3000
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3000/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Run it with:

docker compose up -d

If your app writes data at runtime, mount a volume for it:

    volumes:
      - app-data:/app/data

Step 6: Reverse proxy

The container exposes port 3000, but you usually want it behind a reverse proxy on 443. A minimal Nginx site:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

The Upgrade and Connection headers keep WebSocket connections working. If you prefer Caddy, the same result in one line:

example.com {
    reverse_proxy 127.0.0.1:3000
}

Notes

  • Layer caching: copy package.json, pnpm-lock.yaml and pnpm-workspace.yaml before the rest of the source, or every code change re-installs everything.
  • Build scripts: don't forget allowBuilds: esbuild: true in pnpm-workspace.yaml when using pnpm 10+.
  • Frozen lockfile: always use --frozen-lockfile; it fails fast if the lockfile and package.json disagree.
  • Runtime data: copy the directories your app reads at runtime (posts, static uploads, etc.) into the final stage.
  • Node version: keep the base image in sync with the Node version you develop on, and with the engines field of your package.json.
  • Non-root user: the runtime stage runs as the unprivileged node user via USER node, so the server doesn't run with root privileges. If you add it, remember any data directory you write to must be writable by that user.

References