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.
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 trigger | git push, Vercel builds | Your CI builds the image |
NEXT_PUBLIC_* vars | Set in the dashboard before build, baked in | Must be present at docker build time — not at docker run |
| Server-only env vars | Read per-request from the platform | Injected at docker run -e or via your orchestrator — this half works as expected |
| Output shape | Vercel's own build pipeline, no config needed | Needs output: "standalone" explicitly, or you ship node_modules |
| Image optimization | Vercel'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 runs | Vercel's edge/serverless network | Any 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 --prodthe currentmainuntil the Supabase env vars are set in the Vercel project:src/proxy.tsruns on every request and will error site-wide withoutNEXT_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
| Mistake | What happens | Fix |
|---|---|---|
No output: "standalone" | Image ships full node_modules; often 3-4x larger than needed | Add output: "standalone" to next.config.ts |
NEXT_PUBLIC_* vars set at docker run, not docker build | Values are undefined in the bundle; container "starts" but every request that reads them fails | Pass them as ARG/ENV in the build stage, or --build-arg |
| Server secrets passed as build args | Secret is baked into an image layer, extractable from any pulled image | Inject at docker run -e or via your orchestrator's secrets store |
Single-stage COPY . . build | Ships devDependencies, source maps, and the pre-build source in the final image | Multi-stage build; only the runner stage ships |
.dockerignore missing | node_modules, .next, .git all get sent to the Docker build context | Ignore them explicitly — a slow build is often just a huge context |
| Assuming a container needs no health check | Orchestrator can't tell a hung process from a healthy one | Add 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.
