Skip to main content
BlogEngineering

Next.js 15 Standalone Docker Output: A Production Guide That Works

The exact Dockerfile, the gotchas with Tailwind 4 and pnpm, and the image sizes you should actually expect on Alpine.

Next.js 15 Standalone Docker Output: A Production Guide That Works

A 1.2 GB Docker image for a Next.js marketing site is a smell. The fix is one line in next.config.js and a Dockerfile that almost nobody copies correctly the first time. The Next.js 15 standalone Docker output is brilliant when it works, and infuriating for the 30 minutes after you discover that your build tracer missed one file. We run this setup on five production apps (draftedby.com, the three lesson-planning forks, and Carriva). Here is what actually ships, what breaks, and the image sizes you should expect.

What standalone output actually does

Standalone output mode is Next.js compiling your app into a self-contained directory under .next/standalone that contains only the files your server actually needs at runtime. The compiler traces every import, every dynamic require, every public asset, and copies just those into the standalone bundle.

The win is dramatic. A typical full node_modules for a Next.js 15 app with Tailwind 4 and a few dozen packages weighs 350 to 600 MB. The standalone output of the same app weighs 80 to 150 MB. Your final Docker image, on a node:20-alpine base, lands between 180 and 250 MB. That is a 5 to 7x reduction over the naive approach.

To turn it on, one line:

// next.config.js
export default {
  output: "standalone",
};

That is the entire configuration. Everything else is Dockerfile correctness.

The Dockerfile we ship

This is the exact pattern we use across draftedby.com and the lesson-planning monorepo. It works on Coolify, on plain Docker hosts, and on any Kubernetes cluster.

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base

# 1. Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  else echo "No lockfile found." && exit 1; \
  fi

# 2. Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
  if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm build; \
  else npm run build; \
  fi

# 3. Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

A few non-obvious things here that the official Vercel example does not always make obvious.

Public and static must be copied separately

The output: "standalone" directory contains the compiled server but not public/ and not .next/static/. Those have to be copied manually after the standalone copy. Forget either one and your CSS will not load and your favicon will 404. We have made this mistake on three different days.

HOSTNAME=0.0.0.0 is mandatory

Next.js's standalone server defaults to localhost, which means the container only listens on its loopback interface. From outside the container it looks dead. Setting HOSTNAME=0.0.0.0 is the fix. This used to be set automatically; in 15.x you need it explicitly.

libc6-compat is for sharp and friends

Alpine uses musl libc instead of glibc. A handful of native modules (the sharp image library is the most common offender) ship glibc binaries by default. The libc6-compat package provides a compatibility shim. Without it, your build either fails on sharp or fails on Prisma. We learned this the hard way with the Prisma engine: Alpine images need the linux-musl binary target in the Prisma schema and openssl installed in the runner stage. That is documented in our memory and worth flagging here.

Tailwind 4 and the standalone output

Tailwind 4 changed how CSS is generated. The @tailwindcss/postcss plugin needs Lightning CSS, which has a native binary. The standalone trace usually picks it up, but on some pnpm setups with strict hoisting, the binary is in a directory the trace skips.

The symptom is "build succeeds, container runs, all your styles are missing". The fix is to verify that node_modules/@tailwindcss/oxide-linux-x64-musl (or the equivalent for your platform) ends up inside .next/standalone/node_modules. If it does not, you have two options:

  1. Switch off pnpm's node-linker=isolated mode for that one project.
  2. Add an explicit outputFileTracingIncludes entry in next.config.js:
export default {
  output: "standalone",
  outputFileTracingIncludes: {
    "/**/*": ["./node_modules/@tailwindcss/**/*"],
  },
};

Option 2 is the one we ship. It is more honest about the dependency and survives lockfile churn.

What goes in outputFileTracingIncludes

The trace is good, not perfect. Anything you require dynamically (a config file loaded by path, an MDX content directory walked at runtime, a fonts directory in public/ consumed by your font loader) should be added explicitly.

We commonly add:

  • MDX content directories used at runtime (we serve content from /content/blog)
  • Fonts that are loaded dynamically rather than imported
  • Any internationalization message bundles loaded by locale

Forgetting these means a working dev build and a broken production build, every time.

Image sizes from real apps

Here is what we ship today, measured with docker images after docker build:

AppBaseStandalone sizeFinal imageRAM at idle
draftedby.com (marketing)node:20-alpine86 MB192 MB70 MB
PrepareMesCours (full app)node:20-alpine142 MB248 MB130 MB
Carriva (Postgres + RAG)node:20-alpine168 MB281 MB220 MB
jdchess.com (MDX-heavy)node:20-alpine98 MB211 MB95 MB

Without standalone output, the Carriva image was 1.4 GB. The standalone version is 5x smaller and starts in roughly half the time.

The trade with Alpine is debugging. When something breaks at 11pm, a node:20-slim base is friendlier (full Debian tooling, glibc, easier apt install for missing libs). Our compromise: Alpine for production, slim for staging if we suspect a libc issue.

CI considerations

Two things that bit us in CI:

Layer caching across pnpm versions

If your CI uses a fresh container each run, the deps layer rebuilds every time even when nothing changed. Use BuildKit's cache mounts:

RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    corepack enable && pnpm install --frozen-lockfile

Build time on a cold CI cache went from 4 minutes to 90 seconds for our larger app.

.dockerignore matters more than you think

A .dockerignore that does not exclude .next/, node_modules/, .git/, and large local directories will bloat the build context to several gigabytes and slow your builds. Ours is short and effective:

.git
.next
node_modules
.env*
*.log
.vscode
.idea

Standalone output is the difference between a Next.js Docker image you ship in a CI pipeline and one you avoid touching for fear of breaking it.

Coolify and the DNS quirk

If you deploy via Coolify on a homelab or VPS, there is a recurring class of build failure where Google Fonts, npm registry, or Resend API calls fail mid-build with EAI_AGAIN. The cause is your Docker daemon's DNS resolver pointing at a single internal AdGuard or Pi-hole that is occasionally unreachable. We documented this in our solo dev DevOps homelab write-up and it is the single most-reported issue we hit on a fresh server.

The fix is either to add a fallback resolver to /etc/docker/daemon.json or to whitelist the relevant domains in your DNS sinkhole. It is unrelated to the Dockerfile but it accounts for half of "my Next.js Docker build is broken" tickets we see.

Standalone vs serverful trade

Standalone output is the right default for self-hosted deployments. If you are deploying to Vercel, Cloudflare Workers, or another serverless target, you do not need it; their platform does its own packaging. Standalone is for Docker, for VPS, for Kubernetes, for Coolify, for Dokku, and any case where you bring your own runtime. We compared the self-hosted PaaS options in our Coolify vs Dokku vs CapRover piece, and all three benefit from a properly traced standalone build.

A subtle point: standalone output assumes a single-process model. Long-running background workers, cron jobs, and queue consumers are separate concerns. We split those into their own containers with their own minimal Dockerfiles, fed by the same Postgres instance described in our self-hosting Postgres post.

What we would test first

If you are setting this up fresh, here is the 30-minute path:

  1. Add output: "standalone" to next.config.js.
  2. Copy the Dockerfile above as a starting point.
  3. Run docker build -t myapp . locally.
  4. Run docker run -p 3000:3000 myapp and check that styles, fonts, and dynamic routes all render.
  5. Inspect docker images myapp and confirm the size is in the 200 to 300 MB range. If it is over 500 MB, your trace is missing something or your base is wrong.

If step 4 fails on missing styles, you are hitting the Tailwind 4 trace issue. If step 4 fails on missing public assets, your COPY lines are wrong. If step 5 fails on size, you forgot to use Alpine or you copied node_modules instead of the standalone bundle.

TL;DR

The Next.js 15 standalone Docker output cuts image size 5 to 7x and start time roughly in half. The catch is three lines: copy public/ separately, copy .next/static/ separately, set HOSTNAME=0.0.0.0. Everything else in the Dockerfile is supporting cast. If you ship Next.js to your own infrastructure, this is the production default. If you skip it, you are paying a 1 GB tax on every deploy for no reason.

We have run this exact pattern across five production apps for over a year. It survives Tailwind upgrades, Node minor versions, Prisma engine changes, and the occasional 11pm hotfix. Boring is the goal. Standalone gets you boring.

A small thing

Want to work with us?

We are a small studio shipping focused B2B SaaS for niche professional verticals. If your problem looks like one of ours, we would love to chat.