Skip to main content
ASoc
Tutorial

Next.js Docker: The Standalone Output and the Env Var That Bites

output: "standalone" keeps the image small; the trap is that NEXT_PUBLIC_ vars are baked in at build time, not read at container start. This repo's own env-var warning shows exactly why that matters.

The ASoc Team7 min read

Dockerizing a Next.js 16 App Router site takes three things most tutorials skip in order: output: "standalone" in next.config.ts (or the image ships your whole node_modules), a multi-stage Dockerfile that copies only the standalone output, and — the one that actually breaks production — knowing that NEXT_PUBLIC_* variables are baked into the build, not read at container start. Get the third one wrong and docker run -e does nothing.

What changes between a Vercel deploy and a container

Vercel (this site today)Docker
Build triggergit push, Vercel buildsYour CI builds the image
NEXT_PUBLIC_* varsSet in the dashboard before build, baked inMust be present at docker build time — not at docker run
Server-only env varsRead per-request from the platformInjected at docker run -e or via your orchestrator — this half works as expected
Output shapeVercel's own build pipeline, no config neededNeeds output: "standalone" explicitly, or you ship node_modules
Image optimizationVercel's built-in optimizer (or none, if you opt out)You provide a loader, or serve pre-built assets like this site does
Where it runsVercel's edge/serverless networkAny container runtime — Cloud Run, Fargate, your own cluster

The row worth reading twice is the second one. It's the difference between "works on my machine, breaks in the image" and a deploy that just works.

This repo, as the worked example

This storefront doesn't ship a Dockerfile — it deploys to Vercel, and next.config.ts has no output field set at all, which is correct for that target. Adding Docker support to a real Next.js 16 App Router app means starting from what's actually here.

public/ is 95 MB, almost entirely public/images at 93 MB — 111 products' screenshots and gallery slides. A naive COPY . . Dockerfile ships all of it in the image, plus every devDependency, plus the pre-build source. That's the "next.js dockerfile example" most tutorials show, and it's also why the image ends up 3-4x larger than it needs to be. The fix is the same for any Next.js app this size: multi-stage build, output: "standalone", and copy only what the standalone output actually needs.

# deps: install once, cached separately from source changes
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# builder: needs the full node_modules and source to run `next build`
FROM node:22-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# runner: only the standalone output, the static assets, and public/
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

That last stage only exists at all because of one line this repo's next.config.ts doesn't currently have:

const nextConfig: NextConfig = {
  output: "standalone",
  // ...the CSP headers() this repo already has
};

Without it, .next/standalone never gets generated, server.js doesn't exist, and the runner stage has nothing to run — the container starts and immediately exits. That's one real path into "nextjs dockerfile not working," and it's a config diff, not a Docker problem.

The env-var trap, and why this repo already documents hitting its Vercel equivalent

This is the "next.js docker not working" and "next.js docker best practices" search behind most of this cluster's volume, and it's worth stating precisely because the fix is not what the error message suggests.

Next.js inlines every NEXT_PUBLIC_* variable into the JavaScript bundle at build time — client-side, yes, but also into server and edge code that reads process.env.NEXT_PUBLIC_X, because the compiler doesn't distinguish "this file might run on the server" from "this variable is public." This repo's own src/proxy.ts (42 lines, runs on every request) reads two of them:

const supabase = createServerClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  { /* cookies... */ },
);

CLAUDE.md — this repo's own architecture doc — carries a standing warning about exactly this class of failure, written for the Vercel deploy this site actually runs:

Do NOT vercel --prod the current main until the Supabase env vars are set in the Vercel project: src/proxy.ts runs on every request and will error site-wide without NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEY.

On Vercel, the fix is procedural: set the vars in the dashboard before the next build. In Docker, the same root cause has a sharper edge, because CI pipelines routinely separate "build the image" from "run the image" into different jobs, different secrets scopes, or different teams entirely. If NEXT_PUBLIC_SUPABASE_URL isn't present in the build step's environment — not the run step's — the value baked into server.js is undefined, and every request to this proxy throws. Setting the variable at docker run -e NEXT_PUBLIC_SUPABASE_URL=... does nothing, because the string is already frozen into the bundled JavaScript from the build that already finished. The container looks configured. It is not.

The fix is the same shape for any app: pass build-time public vars as Docker build arguments, not run-time environment variables.

FROM node:22-slim AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
COPY . .
RUN npm run build
docker build \
  --build-arg NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co \
  --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... \
  -t storefront .

Server-only secrets — no NEXT_PUBLIC_ prefix — are the opposite: keep those out of the build stage entirely and inject them at docker run -e or through your orchestrator's secrets manager, exactly as the "works as expected" row in the table above says. Baking a server-only secret into an image layer means anyone who can pull the image can read it back out.

Whether this repo should containerize at all

It shouldn't, and saying why is more useful than a generic yes. Every route here is prerendered at build time (static export candidates) except a handful of dynamic ones — the download API, the webhook, the auth callback — and Vercel's platform already gives static output a global CDN with zero server cost per request. Wrapping that in a long-running container process trades a free, globally-cached deploy for a server you now have to size, patch, and keep warm. Docker earns its cost when you need a runtime Vercel doesn't offer — a specific container runtime a client mandates, a background worker process, or infrastructure a self-hosted buyer already standardized on. "Because it's Next.js" is not a reason on its own.

Mistakes and how they show up

MistakeWhat happensFix
No output: "standalone"Image ships full node_modules; often 3-4x larger than neededAdd output: "standalone" to next.config.ts
NEXT_PUBLIC_* vars set at docker run, not docker buildValues are undefined in the bundle; container "starts" but every request that reads them failsPass them as ARG/ENV in the build stage, or --build-arg
Server secrets passed as build argsSecret is baked into an image layer, extractable from any pulled imageInject at docker run -e or via your orchestrator's secrets store
Single-stage COPY . . buildShips devDependencies, source maps, and the pre-build source in the final imageMulti-stage build; only the runner stage ships
.dockerignore missingnode_modules, .next, .git all get sent to the Docker build contextIgnore them explicitly — a slow build is often just a huge context
Assuming a container needs no health checkOrchestrator can't tell a hung process from a healthy oneAdd a /healthz-style route or rely on the platform's TCP check

Frequently asked questions

Do I need Docker to deploy Next.js at all? No. Vercel, Netlify and most Next.js-aware platforms build and run the app without a Dockerfile. Docker is for when you need portability across runtimes those platforms don't offer, not a default.

Does output: "standalone" change how the app behaves, or just how it's built? Just the build output. It traces the exact dependency graph your app uses at runtime and copies only that into .next/standalone, instead of the platform copying the whole node_modules. The running app is identical either way.

Can I use next start instead of the standalone server? Yes, but you lose the size benefit — next start needs the full node_modules present, which is the thing multi-stage builds with output: "standalone" exist to avoid.

Why does the same environment variable work differently in Vercel and Docker? It doesn't, technically — NEXT_PUBLIC_* is always inlined at build time on both platforms. Vercel just makes the build-vs-run distinction invisible, because it controls both steps together and lets you set the variable once in a dashboard before any build runs. Docker separates the two steps by design, which is what surfaces the trap.

Templates built for teams that self-host

ASoc Scholar Admin is a large-scale admin dashboard — 13 dashboards, 210+ pages — the kind of internal tool a team is more likely to run on its own infrastructure than hand to a generic PaaS. ASoc Vertex Admin is a sales-analytics dashboard, often deployed alongside a company's existing data warehouse rather than at the edge. ASoc Crest Admin is a sidebar-navigation admin template built the same way — a solid default to containerize if your deployment target is a Kubernetes cluster rather than Vercel.

Browse the full set of Next.js admin templates, the React admin templates, or the Tailwind admin templates.

Keep reading

Tutorial11 min read

Next.js Error Boundaries: One File, and a Digest Nothing Read

One error.tsx covering 27 page files, no global-error.tsx, and a digest the boundary declared but discarded — the audit, and both fixes that shipped with this post.

Read more