Guides · Deployment
Next.js Dockerfile: a production image with standalone output
A tested three-stage Dockerfile for Next.js, what each line does, and the mistakes that make a container build fine and then fail in production.
By Jonathan Pitter ·
The short answer
Turn on standalone output in next.config.ts, then build in three stages: install dependencies, run next build, and copy only the standalone server into a small runtime image.
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "standalone",
}
export default nextConfig
# syntax=docker/dockerfile:1
# 1. Install dependencies
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 2. Build the app
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* values are inlined into the browser bundle by next build,
# so they must exist now, not when the container starts.
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Run it
FROM node:24-alpine 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"]
The rest of this guide explains each part, and the mistakes that make a Next.js container build fine and then fail in production.
Why standalone output
By default next build expects to be started with next start from a folder that still has all of node_modules. That works in a container, but you ship every dependency, including the ones only the build needed.
With output: "standalone", Next.js traces which files the server actually imports and writes a self-contained server to .next/standalone, with a minimal node_modules and a server.js that replaces next start. In our test app, the standalone image came out at 292 MB against 716 MB for the same app run with next start, and most of the remaining size is the Node.js base image.
Two folders are deliberately left out of the standalone output, because a CDN usually serves them: public and .next/static. If you serve them from the container, which is the simplest option, you have to copy them in yourself. That's what the two extra COPY lines do.
What each stage does
deps installs dependencies from the lockfile with npm ci. Because it copies only package.json and package-lock.json, Docker caches this layer until your dependencies change, so editing a page doesn't reinstall everything.
builder copies the installed modules and your source, then runs next build. This is the only stage that needs your build-time variables.
runner starts from a clean Node.js image and copies in three things: public, the standalone server and .next/static. It creates an unprivileged nextjs user and runs as it, so a compromised dependency doesn't get root inside the container. HOSTNAME=0.0.0.0 makes the server listen on every interface. Without it, the server binds to whatever the container's hostname resolves to, and anything connecting another way (a health check on localhost, a sidecar proxy) is refused.
Add a .dockerignore
Without one, COPY . . sends your local node_modules, .next and .env files into the build. That makes builds slow and can bake local secrets into an image layer.
node_modules
.next
.git
.env*
npm-debug.log*
Dockerfile
.dockerignore
Build and run it
docker build -t my-app --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
docker run -p 3000:3000 -e DATABASE_URL=postgres://... my-app
Open http://localhost:3000. If the page loads but has no styling or JavaScript, see "Static files return 404" below.
Build-time vs runtime environment variables
This is the part that catches most people. Next.js treats the two kinds of variable differently:
NEXT_PUBLIC_*variables are build-time.next buildreplaces everyprocess.env.NEXT_PUBLIC_...in browser code with its literal value. Setting one when the container starts does nothing for code that already ran through the build.- Everything else is runtime. Server code reads
process.env.DATABASE_URLwhen it runs, so pass those withdocker run -eor your platform's environment settings.
A --build-arg is only visible to the build if the Dockerfile declares it with ARG. Pass --build-arg NEXT_PUBLIC_API_URL=... to a Dockerfile without the ARG line and the build still succeeds, with the value silently missing from your app. We checked: the page rendered the variable as unset. Declare one ARG and ENV pair per public variable.
Never pass a secret as a build argument. Build arguments aren't designed to be secret: they appear in build logs and caches, and can end up in image metadata. Docker's own documentation points to build secrets instead. Public variables are public by definition, since they end up in the JavaScript every visitor downloads, but database passwords and API keys belong in runtime variables only.
Because public values are fixed at build time, an image built for staging contains staging URLs. Build once per environment, or have the browser fetch its configuration from an API route at runtime.
pnpm and Yarn
Swap the install step for your package manager's lockfile and frozen install:
# pnpm
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# Yarn
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
Keep npm run build or change it to pnpm build or yarn build; the build and runtime stages don't change.
Without standalone output
If you can't use standalone output (a custom server, for example), copy the build and the production dependencies and start it with next start:
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["npm", "start"]
It works, and the image is more than twice the size.
Common problems
The container starts but nothing answers
The server is listening on the wrong interface. Set HOSTNAME=0.0.0.0 in the runtime stage, and make sure the port you publish matches PORT.
Static files return 404
The page's HTML loads but CSS, JavaScript and images don't. The standalone output doesn't include .next/static or public; copy both into the runtime image, at those exact paths.
COPY fails on /app/public
Your project has no public folder. Create an empty one (mkdir public and commit a .gitkeep), or remove that COPY line.
A NEXT_PUBLIC variable is undefined in the browser
It wasn't present when next build ran. Declare it with ARG, copy it to ENV in the build stage, and pass it with --build-arg. Setting it at runtime is too late.
Image optimisation errors
Next.js uses sharp to optimise images in production. Recent versions install and use it automatically; if your logs complain that it's missing, add it as a dependency (npm install sharp) and rebuild.
Builds are slow
Check that .dockerignore excludes node_modules and .next, and that dependency installation happens before COPY . . so its layer stays cached.
Deploying the image
The image runs anywhere that runs containers: a VM with Docker, Kubernetes, or a container platform. It needs one port (3000), the runtime environment variables, and a health check pointed at a route that returns 200, such as / or a small app/api/health/route.ts.
On Staxa you can skip writing the Dockerfile. Connect the GitHub repository, and Staxa detects Next.js from next.config or the next dependency, checks whether standalone output is on, and generates the matching Dockerfile (standalone or next start) for npm, pnpm or Yarn. The generated Dockerfile declares an ARG for each NEXT_PUBLIC_* variable you set, so public values are inlined at build time without editing anything. If your repository has its own Dockerfile, Staxa uses that instead, and it needs its own ARG lines, as above. Each deployment gets its own subdomain with TLS and, if you ask for one, its own PostgreSQL or MySQL database. Because every tenant is isolated, the same app can run once per customer.
Frequently asked questions
Do I need output: "standalone" to run Next.js in Docker?
No, but you should use it. Without it you have to ship the full node_modules and start with next start, which makes the image much larger.
Can I run a Next.js app without Node.js?
Only if it's fully static. With output: "export", next build writes plain HTML, CSS and JavaScript to out/, which any web server can serve. Server components that render per request, API routes, middleware and image optimisation all need the Node.js server.
Which Node.js version should I use?
An active LTS release. Next.js 16 needs Node.js 20.9 or later; the examples here use Node.js 24 on Alpine.
Why does my NEXT_PUBLIC variable work locally but not in Docker?
Locally, next dev and next build read .env files from your project. In Docker, those files are excluded by .dockerignore (as they should be), so the build only sees what you pass with --build-arg and declare with ARG.
How do I add a health check?
Add a route that returns 200 without touching the database, and point your platform's readiness check at it:
// app/api/health/route.ts
export function GET() {
return Response.json({ ok: true })
}