The Next.js Dockerfile I Wish Someone Had Handed Me Three Years Ago
The first Dockerfile I wrote for a Next.js app was, in hindsight, an act of violence against my own build times. Single stage, npm install, npm run build, npm start, done. It worked, technically, in the sense that the container ran and served pages. It also weighed in at around 1.4GB, took nearly four minutes to build even with a warm npm cache, and rebuilt from scratch on every single code change because I hadn't thought about layer ordering at all. It took a genuinely frustrating on-call incident — a staging deploy silently serving an old build because of a caching issue I didn't understand yet — to make me sit down and actually learn how Docker layers, Next.js's build output, and environment variables interact. Here's what I landed on, and why each piece is there.
Start with standalone output mode
The single highest-leverage change is enabling output: "standalone" in next.config.js. Without it, deploying Next.js in a container means shipping the entire node_modules directory, because the default output assumes a full Node environment with all dependencies present at runtime. With standalone mode, Next.js traces exactly which files and dependencies your app actually needs to run, and copies only those into a .next/standalone folder, complete with a minimal server.js you can run directly with node.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;
This alone took my final image from carrying a full node_modules — hundreds of megabytes of dev dependencies, type definitions, and packages only needed at build time — down to a runtime footprint of a few dozen megabytes of actual production code and its resolved dependencies.
The multi-stage Dockerfile
Here's the full Dockerfile I currently use, which I've iterated on across a few real projects at this point:
syntax=docker/dockerfile:1
FROM node:20-alpine AS base
---- Dependencies stage ----
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
---- Build stage ----
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
Public env vars needed AT BUILD TIME (baked into the JS bundle)
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm build
---- Runtime stage ----
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN 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"]
Three stages, each doing one job. deps installs dependencies and nothing else, so Docker can cache that layer independently of your application code — if you haven't touched package.json or the lockfile, this entire stage is a cache hit, which on a typical CI runner saves you anywhere from thirty seconds to a couple of minutes depending on how bloated your dependency tree is. builder copies in the actual source and runs the build. runner is the only stage that ends up in your final image, and it only pulls in the standalone server, the static assets, and the public folder — none of the source TypeScript, none of the dev dependencies, none of the build tooling ever makes it into what actually ships.
Running as a non-root nextjs user isn't decoration either. I got dinged on this in a security review once for running as root inside the container, which is a completely unnecessary risk for a process that just needs to serve HTTP — a handful of lines fixes it permanently.
The environment variable gotcha that got me
This is the part that actually caused an incident, so I want to be specific about it. Next.js has two categories of environment variables that behave completely differently, and conflating them is an extremely easy mistake to make in a containerized setup. Variables prefixed with NEXT_PUBLIC_ get inlined directly into the client-side JavaScript bundle at build time — they become string literals baked into the code Next.js produces when you run next build. Every other server-only variable is read at runtime, when node server.js actually starts.
The incident: I had NEXT_PUBLIC_API_URL set correctly in my runtime environment — injected via my orchestration platform's secrets manager — but the Docker build itself, run inside GitHub Actions, had no access to that variable at build time. The result was a production bundle with NEXT_PUBLIC_API_URL baked in as undefined, shipped to every client, and no runtime environment variable could fix it after the fact because that value was already frozen into static JavaScript files served to the browser. Changing the runtime env var did precisely nothing, which was deeply confusing until I understood why.
The fix is passing public variables in as build args, explicitly, at image build time:
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
-t my-nextjs-app .
And in GitHub Actions, that means threading it through explicitly rather than assuming your deploy-time secrets are magically available during the build step:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry/my-nextjs-app:${{ github.sha }}
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
Server-only secrets — database URLs, API keys used only in server components or route handlers — should never be passed as build args at all, since build args and intermediate layers can leak into image history more easily than you'd like. Those belong in the runtime environment only, injected by whatever's actually running the container — Vercel does this transparently if you use their platform, but if you're rolling your own with something like ECS or a plain Docker Compose setup on a VPS, that's an -e flag or an env file passed at docker run time, kept well away from the build step entirely.
A .dockerignore that actually matters
The other silent killer of build times and image size is not having a proper .dockerignore. Without one, Docker's build context includes your entire node_modules, your .next cache from local development, your .git history — all of which gets sent to the Docker daemon before the build even starts, which is slow, and some of which can end up baked into layers you didn't intend.
node_modules
.next
.git
.env*.local
npm-debug.log
Dockerfile
.dockerignore
README.md
Caching layers correctly matters more than people think
One thing I got wrong for a long time was copying the entire project before installing dependencies, which meant any single source file change invalidated the dependency install cache and forced a full pnpm install on every build. The ordering in the Dockerfile above — copying only package.json and the lockfile into the deps stage before copying the rest of the source in builder — is what actually makes Docker's layer cache useful instead of decorative. On a CI runner with layer caching enabled (BuildKit's cache mount or a registry cache), this is the difference between a two-minute build and a forty-second one for the common case of "I only changed application code."
Optional: BuildKit cache mount for pnpm's store, further speeding up installs
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
None of this is exotic. It's mostly just taking seriously the fact that a Dockerfile executes top to bottom, layer by layer, and every layer you can keep stable between builds is time you get back. The version of this Dockerfile I use today is maybe thirty lines longer than my original single-stage disaster, and it builds faster, ships an image roughly a tenth the size, and hasn't caused a repeat of that stale-environment-variable incident since I actually understood what standalone mode and build-time versus runtime env vars were doing under the hood.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.