# Production Next.js Dockerfile: Multi-Stage, Standalone and Non-Root

A **production Next.js Dockerfile** is a multi-stage build that installs dependencies, runs `next build` with `output: 'standalone'`, and copies only the traced server, static assets and `public/` folder into a small runtime image that runs `node server.js` as a non-root user. The result is an image that starts fast, carries no dev dependencies or source code, and runs the same way on any Docker host.

This guide gives you a complete, copy-paste Next.js Dockerfile for **Next.js 16 and Node.js 24 LTS**, explains every line, and shows how to handle the parts that usually go wrong: `sharp` for image optimization, build-time vs runtime environment variables, health checks, and file permissions. I built and ran this exact Dockerfile against a fresh `create-next-app` project before writing this, and the image sizes below are measured, not estimated.

If you deploy with Coolify, this Dockerfile drops straight into the Dockerfile build pack. For the platform side (server setup, domains, databases), see [How to Self-Host Next.js with Coolify](https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify); this post focuses on the image itself.

## What is the best Dockerfile for Next.js in production?

The best production Dockerfile for Next.js uses three stages (deps, builder, runner), enables `output: 'standalone'` in `next.config`, starts from an official Node.js LTS image such as `node:24-slim`, copies `.next/standalone`, `.next/static` and `public/` into the final stage, switches to the built-in `node` user, and starts with `node server.js` instead of `next start`.

That is also the shape of the official [vercel/next.js with-docker example](https://github.com/vercel/next.js/tree/canary/examples/with-docker), which currently pins Node 24 slim. The version below keeps that structure and adds a health check, build cache mounts and a clear split between public and secret environment variables.

### Quick definitions

| Term | What it means |
| --- | --- |
| Multi-stage build | One Dockerfile with several `FROM` stages; only the last stage ships. Build tools stay behind. |
| `output: 'standalone'` | Next.js traces which files each route needs and copies them, plus a minimal `server.js`, into `.next/standalone`. |
| Output file tracing | The `@vercel/nft` analysis Next.js runs during `next build` to find every file a page can load. |
| Runner stage | The final, small image that actually runs in production. |
| `HEALTHCHECK` | A Dockerfile instruction that tells Docker how to test whether the container is still working. |

## TL;DR: the production Next.js Dockerfile

Enable standalone output first:

```ts
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;
```

Then add this `Dockerfile` at the project root:

```dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24-slim

# 1) Install dependencies (cached until the lockfile changes)
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

# 2) Build the app with output: "standalone"
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Public, build-time values only. They are inlined into the JS bundle.
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
RUN npm run build

# 3) Minimal runtime image
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static

USER node
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]

CMD ["node", "server.js"]
```

Add a `.dockerignore` so local junk and secrets never reach the build context:

```text
.git
.next
node_modules
npm-debug.log*
Dockerfile*
.dockerignore
.env*
!.env.example
coverage
README.md
```

Build and run:

```bash
docker build -t my-next-app --build-arg NEXT_PUBLIC_SITE_URL=https://example.com .
docker run -p 3000:3000 -e DATABASE_URL="postgres://..." my-next-app
```

![Next.js standalone output in a multi-stage Docker build](https://res.cloudinary.com/wasicloudinary/image/upload/v1792132656/fabwaseem/blog/nextjs-dockerfile/diagram-multistage.jpg)

## How do multi-stage Docker builds make Next.js images smaller?

A multi-stage build lets the build stages carry the full `node_modules`, TypeScript, ESLint and the compiler, while the final stage copies only the build output. Docker ships just the last stage, so none of the build tooling, source code or package cache ends up in the production image.

The [Docker multi-stage build docs](https://docs.docker.com/build/building/multi-stage/) describe this pattern: each `FROM` starts a fresh stage, and `COPY --from=<stage>` pulls selected files forward. For Next.js the three stages do one job each:

1. **deps** copies only `package.json` and the lockfile, then runs `npm ci`. Because source files are not copied yet, this layer stays cached until your dependencies change.
2. **builder** brings in `node_modules` and your source and runs `next build`.
3. **runner** starts from a clean Node image and copies the standalone server, static files and `public/`.

### How much smaller is the image? (measured)

I built a fresh Next.js 16.4.2 app from `create-next-app` (App Router, TypeScript, Tailwind) three ways on the same machine and compared `docker images` output:

| Build | Base image | Image size |
| --- | --- | --- |
| Single stage: `COPY . .`, `npm ci && npm run build`, `npm start` | `node:24` | 1.97 GB |
| Multi-stage + standalone (this guide) | `node:24-slim` | 287 MB |
| Multi-stage + standalone | `node:24-alpine` | 229 MB |

The application files inside the standalone runner (`/app`) took 59 MB; the rest is the Node.js base image. Your numbers will differ with your dependencies, but the order of magnitude is typical, because the standalone trace keeps only the packages the server actually imports.

## What does output: 'standalone' do in Next.js?

`output: 'standalone'` tells `next build` to create a `.next/standalone` folder that contains a minimal `server.js` and only the files from `node_modules` that your routes need. You can run that folder with `node server.js` and no `npm install`, which is why it is the foundation of every small Next.js Docker image.

From the [Next.js output docs](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), three details matter for Docker:

- **It does not copy `public/` or `.next/static`.** Those are expected to go to a CDN, so the Dockerfile copies them next to `server.js` manually. Forget this and your CSS, JS chunks and images return 404.
- **`PORT` and `HOSTNAME` control where the server listens.** Inside a container you want `HOSTNAME=0.0.0.0`, otherwise the server may bind only to the container's own interface name and the published port will not answer.
- **Monorepos need `outputFileTracingRoot`.** If your app imports packages from outside its folder, set it to the repository root so tracing can include them. If a file is still missing, `outputFileTracingIncludes` adds it explicitly.

### Should you use next start or node server.js in Docker?

Use `node server.js` with standalone output. It runs the same Next.js server without needing the full `node_modules` tree or the `next` CLI, and it runs directly as PID 1 rather than through `npm`. That means `docker stop` sends SIGTERM straight to Node instead of to an npm wrapper process.

## Which Node.js base image should you use for Next.js?

Use the current Node.js LTS line; as of October 2026 that is Node.js 24 ("Krypton"), per the [Node.js release schedule](https://nodejs.org/en/about/previous-releases). Pick `node:24-slim` (Debian, glibc) as the safe default, or `node:24-alpine` (musl) for a smaller image if your native dependencies support musl.

| Option | Pros | Watch out for |
| --- | --- | --- |
| `node:24-slim` | glibc, widest native-module compatibility; the official example's default | Larger than Alpine |
| `node:24-alpine` | Smallest official Node image | musl libc; some native modules need musl builds or extra packages |
| `node:24` (full) | Has build tools for compiling native modules | Very large; use only in a build stage if needed |

Pin the version with an `ARG` (as in the Dockerfile above) so upgrading Node is a one-line change, and rebuild regularly to pick up base image security patches.

## How do you run a Next.js container as a non-root user?

Switch to the non-root `node` user that ships in every official Node.js image with `USER node`, and give it ownership of the files it needs with `COPY --chown=node:node`. Create `.next` and `chown` it first, because Next.js writes its prerender and image cache there at runtime.

The [Node.js Docker best practices](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md) recommend exactly this: the official images already include a `node` user (UID 1000), so there is no need to create your own. I confirmed it in the built container: `id` returns `uid=1000(node) gid=1000(node)`.

Two permission mistakes cause most "EACCES" crashes:

1. Copying files without `--chown`, so they belong to root and the cache directory is read-only for `node`.
2. Mounting a volume over `/app/.next/cache` that is owned by root on the host. Fix the host directory's owner or skip the mount.

## How do you make sharp work in a Next.js Docker image?

With Next.js 15 and later you usually do nothing: `next` pulls in `sharp` as a dependency, and standalone tracing copies it into the image along with its prebuilt libvips binaries for Linux. Run `npm ci` inside the container (not on your Mac or Windows host) so the correct Linux binaries are installed.

In my test build, the runner's `node_modules` contained `sharp` plus `@img/sharp-linux-x64` and `@img/sharp-libvips-linux-x64` without any extra configuration, and calling `require('sharp')` inside the running container worked (sharp 0.35.5).

If you do see the [sharp missing in production](https://nextjs.org/docs/messages/sharp-missing-in-production) warning, check these in order:

1. **Do not copy a host `node_modules` into the image.** Keep `node_modules` in `.dockerignore`.
2. **Match the platform.** Building on Apple Silicon for an x64 server? Use `docker build --platform linux/amd64` so the right binaries are chosen.
3. **Force the files into the trace.** Add `outputFileTracingIncludes: { '/*': ['node_modules/sharp/**/*'] }` to `next.config`, as the Next.js docs show for native assets.

Remember the default image optimizer runs on your server's CPU. If you serve lots of images, the [self-hosting guide](https://nextjs.org/docs/app/guides/self-hosting) explains how to swap in a custom loader that points to an image CDN.

## How do you handle environment variables in a Next.js Docker build?

Treat them as two different things. Variables prefixed with `NEXT_PUBLIC_` are inlined into the browser JavaScript at `next build`, so they must be passed as build arguments. Everything else (database URLs, API keys, auth secrets) is read by the server at runtime, so pass it with `docker run -e` or your platform's environment settings and never bake it into the image.

The [Next.js environment variables guide](https://nextjs.org/docs/app/guides/environment-variables) spells this out: once built, a `NEXT_PUBLIC_` value is frozen into the bundle. Changing it later requires a rebuild.

| Variable type | Example | Where to set it | Rebuild needed to change? |
| --- | --- | --- | --- |
| Public (browser) | `NEXT_PUBLIC_SITE_URL` | `ARG` + `ENV` in builder stage, `--build-arg` | Yes |
| Server secret | `DATABASE_URL`, `AUTH_SECRET` | `docker run -e`, Compose `environment`, Coolify env vars | No |
| Server config read at request time | `REVALIDATE_TOKEN` | Runtime env | No |

### What if the build itself needs a secret?

Some builds need a secret, for example to fetch data during prerendering or to install a private package. Do not use `ARG` for it, because build arguments can show up in image history. Use a BuildKit secret mount, which exposes the value only for one `RUN` step:

```dockerfile
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ci
```

```bash
docker build --secret id=npm_token,env=NPM_TOKEN .
```

A simpler rule that avoids most of this: keep pages that need secrets dynamic, so they read runtime env on the request instead of at build time.

## How do you add a health check to a Next.js container?

Add a tiny route handler that returns 200, then point a Dockerfile `HEALTHCHECK` at it using Node's built-in `fetch`, because slim and Alpine images do not include `curl` by default. Docker marks the container healthy or unhealthy, and orchestrators like Coolify, Compose and Swarm use that status to decide when traffic can switch to a new deploy.

The health route (App Router):

```ts
// src/app/api/health/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection(); // opt out of prerendering: answer on every request
  return Response.json({ status: "ok", uptime: process.uptime() });
}
```

`await connection()` makes the handler run on every request, so a stuck server cannot hide behind a prerendered response. The [Dockerfile HEALTHCHECK reference](https://docs.docker.com/reference/dockerfile/#healthcheck) explains the options used above:

- `--start-period=20s` gives the server time to boot before failures count.
- `--interval=30s` and `--timeout=5s` set how often and how long each probe runs.
- `--retries=3` means three failures in a row flip the status to unhealthy.

Running the test image, `docker inspect` reported `healthy` and `/api/health` returned `{"status":"ok",...}`. Keep this endpoint cheap. If you also want a readiness check that pings the database, make it a separate route so a slow database does not get the whole container restarted.

![Where a production Next.js container gets its config, health checks and assets](https://res.cloudinary.com/wasicloudinary/image/upload/v1792132657/fabwaseem/blog/nextjs-dockerfile/diagram-runtime.jpg)

## How do you speed up Next.js Docker builds?

Order layers from least to most frequently changed, and use BuildKit cache mounts. Copy the lockfile and run `npm ci` before copying source, so dependency installs are reused on every code-only change. The `--mount=type=cache,target=/root/.npm` flag keeps npm's download cache between builds without putting it into the image.

More options, from Docker's [build cache optimization guide](https://docs.docker.com/build/cache/optimize/) and the official example:

1. **Keep `.dockerignore` tight** so a changed README or `.git` folder does not invalidate `COPY . .`.
2. **Cache `.next/cache`** with `RUN --mount=type=cache,target=/app/.next/cache npm run build` to reuse compiler output. The official example notes the trade-off: that cache is then not in the final image, so fetch responses cached during the build are not available at startup.
3. **Use the lockfile your project already has.** The official example detects npm, Yarn or pnpm; if you use pnpm, swap the install line for `corepack enable pnpm && pnpm install --frozen-lockfile` and copy `pnpm-lock.yaml`.

## How do you deploy this Next.js Dockerfile to Coolify or any host?

Because the image is self-contained, any Docker host can run it: a plain VPS with `docker run`, Docker Compose, Kubernetes, Fly.io, Railway or Coolify. Expose port 3000, set runtime environment variables on the platform, pass `NEXT_PUBLIC_` values as build arguments, and put a reverse proxy with HTTPS in front.

In Coolify specifically:

1. Choose the **Dockerfile** build pack and keep the Dockerfile at the repository root.
2. Set **Ports Exposes** to `3000`.
3. Add runtime variables (like `DATABASE_URL`) as normal environment variables, and make sure `NEXT_PUBLIC_` ones are available at build time so they reach the `ARG` in the builder stage.
4. Coolify can use the container's health check during deploys, so the `HEALTHCHECK` above helps it avoid routing traffic to a container that never became healthy.

I walk through the full Coolify setup, including domains and PostgreSQL, in the [self-hosting guide](https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify). If you are still deciding between platforms, [Coolify vs Vercel for Next.js](https://www.fabwaseem.com/blog/coolify-vs-vercel) compares cost and trade-offs. This same pattern runs this site and projects like [Foliora](https://www.fabwaseem.com/projects/foliora).

### Running more than one replica?

Each container has its own `.next/cache`, so ISR and image caches are not shared between replicas by default. The [Next.js self-hosting guide](https://nextjs.org/docs/app/guides/self-hosting) covers configuring a shared cache handler (for example, Redis-backed) when you scale horizontally. A single container per app does not need this.

## Common Next.js Docker errors and fixes

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| CSS/JS 404s, unstyled page | `.next/static` not copied | `COPY --from=builder /app/.next/static ./.next/static` |
| Images in `public/` return 404 | `public/` not copied | `COPY --from=builder /app/public ./public` |
| Container runs but port does not answer | Server bound to the wrong interface | `ENV HOSTNAME=0.0.0.0` |
| `EACCES` writing `.next/cache` | Files owned by root | `mkdir .next && chown node:node .next`, `COPY --chown` |
| `NEXT_PUBLIC_` value is empty or stale | Set only at runtime | Pass as `--build-arg` and rebuild |
| `Cannot find module` at runtime | File not traced (monorepo or dynamic `require`) | `outputFileTracingRoot` / `outputFileTracingIncludes` |
| Sharp warning in logs | Host-built `node_modules` or wrong platform | Ignore host `node_modules`, build with `--platform` |

**Related:** the builder stage runs your type check and any Prisma commands, so two toolchain changes affect it: [TypeScript 7](https://www.fabwaseem.com/blog/typescript-7) makes `next build` type checking faster, and [Prisma 8](https://www.fabwaseem.com/blog/prisma-8) changed what an unpinned `npx prisma` installs.

**Related:** thinking about Bun in your Docker image? In [Bun vs Node.js in 2026](https://www.fabwaseem.com/blog/bun-vs-node) a Next.js 16.4 app installed in 1.15 s with bun vs 15.4 s with npm, and `next build` worked on Bun 1.4.3.

## Key takeaways

- Enable `output: 'standalone'` and use a three-stage Dockerfile; in my measured test that cut a fresh Next.js 16 image from 1.97 GB to 287 MB on Debian slim.
- Standalone output does not include `public/` or `.next/static`; copy both into the runner yourself.
- Run as the built-in `node` user and give it ownership of `.next`, so the runtime cache can be written.
- `NEXT_PUBLIC_` variables are build-time and baked into the bundle; secrets belong in runtime env or BuildKit secret mounts, never `ARG`.
- Add a cheap `/api/health` route and a Node `fetch`-based `HEALTHCHECK` so your platform can roll out new containers safely.
