Skip to main content
DevOps from zero · Lesson 4 of 8 · 12 min

Docker Multi-Stage Build: Shrink a Real Next.js Image

Learn the Docker multi-stage build from a real Dockerfile: named stages, COPY --from, a non-root user, --target, and why only the final stage ships.

By Matthews WongReviewed on

Key takeaway

A Docker multi-stage build is a Dockerfile with several FROM instructions: each FROM starts a new stage, and COPY --from carries only the files you choose from one stage to the next. Build tools stay behind in the early stages, and only the final stage becomes the image you run, so it is smaller and holds no source code or dev dependencies.

You will be able to

  • Split a Dockerfile into named stages and copy only the build output into the final one.
  • Explain what Next.js standalone output ships, and why public and .next/static are copied by hand.
  • Run the final image as a non-root user, and build a stage that never ships with --target.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

How a multi-stage build ships only what runs

A single-stage image ships everything the build used. With deps, builder and runner stages, node_modules moves into the builder, three output folders move into the runner, and only the runner ships.

Step 1 of 7. Left, a single-stage image: one FROM builds and ships everything. Right, the same app in three stages. Every FROM starts a fresh stage, and only the runner ships.

Keys: ← → step · Home / End · K play or pause

  1. Left, a single-stage image: one FROM builds and ships everything. Right, the same app in three stages. Every FROM starts a fresh stage, and only the runner ships.

  2. With one stage, everything the build needed ships too: the source, dev dependencies, npm's download cache and Next's build cache. None of it is needed to run the app.

  3. The deps stage installs packages. The builder copies node_modules across with COPY --from=deps, so a source edit leaves the npm ci layer cached.

    RUN --mount=type=cache,target=/root/.npm npm ci COPY --from=deps /app/node_modules ./node_modules
  4. The builder copies the source, public/ included, and runs next build. With output: standalone, Next.js writes a minimal server.js and only the node_modules it traced.

    COPY . . RUN npm run buildPredict the next step The runner starts fresh. What does it take from the builder? (The whole builder, source included · Only three output folders · node_modules and the source code)
  5. The runner starts from a fresh base and copies three folders from the builder. Source code, the full node_modules and the build tools stay behind.

    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
  6. Before CMD, the runner creates a system user with uid 1001 and switches to it, so the server process does not run as root inside the container.

    RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs USER nextjsPredict the next step Which of the three stages is pushed and run? (Only the runner · All three, stacked into one image)
  7. Only the runner is pushed and run. With no source or dev dependencies it is smaller than the single-stage image, though the base image still sets the floor.

What is a Docker multi-stage build?

A multi-stage build is a Dockerfile with more than one FROM instruction. Each FROM starts a new stage on a fresh base image, and you can name a stage with AS, as in FROM node:20-alpine AS builder. Unnamed stages are numbered from 0, but names survive reordering, so use them.

Read more (1)

A later stage reaches back with COPY --from plus a stage name, as in COPY --from=deps, and takes only the paths it names. Docker's documentation puts the point in one line: you copy artifacts from one stage to another, leaving behind everything you don't want in the final image. The stages you leave behind can be as heavy as you like, because nobody downloads them.

Why does a multi-stage build make the image smaller?

Building an app needs far more than running it: a compiler, type packages, linters, the full source tree, npm's download cache. In a single-stage image all of it ships, because the image that built the app is the image you run. This site splits the work three ways. deps installs packages, builder compiles, and runner receives exactly three folders and nothing else.

Read more (2)

Those three folders come from Next.js standalone output. With output set to standalone, next build traces which files each page loads and writes a minimal server.js plus only those node_modules into .next/standalone. The server does not copy public or .next/static by default, which is why the runner copies them separately.

Multi-stage removes the build tools, but the base image sets the floor. In a small test app the multi-stage image was smaller by only tens of megabytes, because node:22-alpine itself was most of the image. Pick the base deliberately, then measure with docker image ls rather than trusting anyone's numbers.

How does the final stage run as a non-root user?

The runner creates a system group and user with id 1001, copies the build output with --chown=nextjs:nodejs, and then switches with USER nextjs. From that line on, every instruction in the stage and the running server use that user. Docker's best-practice guide is short on this: if a service can run without privileges, use USER to change to a non-root user.

Read more (1)

The order matters. COPY without --chown creates files owned by uid and gid 0, which is root. That is harmless for public, which the server only reads, but anything the app must write needs to belong to the runtime user before USER takes effect.

Why does this Dockerfile have a stage that never ships?

docker build --target followed by a stage name builds up to that stage and stops, and BuildKit only builds the stages the target depends on. This site uses that for cache-export: a stage built FROM scratch, an empty image, that holds nothing but Next's compilation cache. CI builds it with target: cache-export and a local exporter, which writes the files to a folder instead of making an image, and hands them to actions/cache for the next run.

Read more (1)

Be honest about what it costs. The Dockerfile says building it reuses every builder layer, so it costs close to nothing. On 2026-10-03 BuildKit had dropped those layers: the step re-ran npm ci and the whole build, then died exporting a 572 MB cache, and the failure skipped a deploy. The step is now best-effort, with continue-on-error.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

Dockerfile:13-23
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# This carries .next/cache in with it — see the negation in .dockerignore. That
# cache is what makes `next build` incremental instead of compiling ~1,486 pages
# from cold on every deploy.
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Typechecking and linting are disabled in next.config.ts and run exactly once,
# in CI's `verify` job, which gates this build through `needs: verify`.
RUN npm run build

The builder stage. It starts from a fresh base, takes node_modules from deps, then the whole source. The last comment is a deliberate trade-off: type checking runs once in CI, so a local next build no longer catches type errors. next.config.ts names the exit condition: if verify ever stops gating the build, those two settings must go in the same change.

Dockerfile:58-66
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
CMD ["node", "server.js"]

The end of the runner, the only stage that ships. Three copies and nothing else: public without --chown because it is only read, the standalone server and the static files owned by the app user. Above these lines the stage sets HOSTNAME=0.0.0.0, so server.js listens on every interface in the container and Docker's port mapping can reach it.

Dockerfile:25-34
# Nothing but Next's compilation cache, so CI can pull it back out of the build
# and hand it to actions/cache for the next run. Without this the cache is a
# one-way street: the restore would warm the build context, the build would run
# inside Docker, and the runner's copy would never be updated — a cache step
# that looks like an optimisation and silently does nothing.
#
# This is a separate target, never part of the runner image. Building it reuses
# every layer from `builder`, so it costs close to nothing.
FROM scratch AS cache-export
COPY --from=builder /app/.next/cache /

A stage that exists only to be exported. It sits before runner on purpose: in a test, a build with no --target produced the last stage, so a CI-only stage placed last would have become the shipped image. The last line of the comment is the optimistic part; the incident above shows when it is not true.

.github/workflows/deploy.yml:142-150
      - name: Extract Next.js build cache
        id: extract-next-cache
        continue-on-error: true
        uses: docker/build-push-action@v6
        with:
          context: .
          target: cache-export
          outputs: type=local,dest=/tmp/next-cache
          cache-from: type=gha

How CI builds that stage. target picks the stage, and outputs: type=local writes its files to a folder on the runner instead of pushing an image. continue-on-error is the lesson from 2026-10-03: the image is already pushed by this point, so a failed cache export must not block the deploy.

What breaks

Mistakes that cost real time

  • Unstyled pages: public and .next/static left out

    Copy only .next/standalone and the container starts, the health check passes, and every page renders as bare text, because each stylesheet request is a 404. Standalone's server.js does not copy public or .next/static. Copy both, and put static at ./.next/static, not beside it.

  • COPY --from without --chown, then USER

    Files copied without --chown belong to root. After USER switches to an unprivileged account, the app can read them but cannot write next to them, and the error only shows up at runtime. Give writable paths to the runtime user in the same COPY, or create them before USER.

  • Copying the whole node_modules into the runner

    A common half-way Dockerfile drops the source but still copies node_modules from the builder, so every dev dependency ships again. With standalone output there is no need: the trace already put the modules server.js uses into .next/standalone.

  • A CI-only stage that is not free

    A helper target is cheap only while the build cache still holds the layers it depends on. When the cache is gone, building it re-runs everything before it. Make such steps best-effort when they run after the image is pushed, and never place them last in the Dockerfile.

Single-stage or multi-stage: what changes
FROM linesOneOne per stage
What shipsEverything the build left behindOnly what the final stage copies
Source and dev deps in productionYesNo
Extra outputs for CINot possibleSeparate targets, built with --target
What you must get rightLittleEvery COPY --from path and owner
Try it yourself

Do it on your own machine

You need Docker and Node, nothing else. Build the same small TypeScript server two ways, compare the sizes, then export a stage to a folder.

  1. Create the project files, then run npm install once to create package-lock.json.

    // package.json
    {
      "name": "devops-lab",
      "version": "1.0.0",
      "private": true,
      "scripts": { "build": "tsc" },
      "devDependencies": { "@types/node": "22.7.5", "typescript": "5.6.3" }
    }
    
    // tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
        "outDir": "dist", "strict": true
      },
      "include": ["src"]
    }
    
    // src/server.ts
    import { createServer } from "node:http";
    createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0");
    
    # .dockerignore
    node_modules
    dist
    out
    .git
  2. Write a multi-stage Dockerfile: deps, builder, an export-only stage, and a runner that runs as uid 1001.

    # syntax=docker/dockerfile:1
    FROM node:22-alpine AS deps
    WORKDIR /app
    COPY package.json package-lock.json ./
    RUN --mount=type=cache,target=/root/.npm npm ci
    
    FROM node:22-alpine AS builder
    WORKDIR /app
    COPY --from=deps /app/node_modules ./node_modules
    COPY . .
    RUN npm run build
    
    FROM scratch AS dist-export
    COPY --from=builder /app/dist /
    
    FROM node:22-alpine AS runner
    WORKDIR /app
    ENV NODE_ENV=production
    RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app
    COPY --from=builder --chown=app:app /app/dist ./dist
    USER app
    EXPOSE 3000
    CMD ["node", "dist/server.js"]
  3. Save the same app as a single stage in Dockerfile.single.

    FROM node:22-alpine
    WORKDIR /app
    COPY . .
    RUN npm ci && npm run build
    CMD ["node", "dist/server.js"]
  4. Build both and compare the size column. Then check which user the runner uses.

    docker build -t devops-lab:v1 .
    docker build -f Dockerfile.single -t devops-lab:single .
    docker image ls devops-lab
    docker run --rm devops-lab:v1 id
  5. Build only the export stage into a folder. No image is created; out holds just server.js.

    docker build --target dist-export --output type=local,dest=./out .
    ls out
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.The runner copies a data folder with COPY --from=builder and no --chown, then switches to USER nextjs. At runtime the app crashes when it writes there. Why?
  2. 2.A Dockerfile has deps, builder and runner stages, but the runner also runs COPY --from=builder /app/node_modules ./node_modules. With standalone output, what did that line undo?
  3. 3.CI builds a helper --target stage after the image is pushed, and it usually takes seconds. One day the build cache has lost the builder's layers. What happens?
Think it through

Answer in your head, then check

  1. 1.Is this site's TypeScript source code inside the image it ships?Show the answer

    Answer:No. Only the runner ships, and it copies just public, .next/standalone and .next/static from the builder. The source stays in the builder stage, which nobody pulls.

  2. 2.The container starts and the health check passes, but every page is unstyled. What did the Dockerfile forget?Show the answer

    Answer:To copy .next/static, and usually public too. Standalone's server.js does not copy them by default, so every hashed CSS and JavaScript file returns 404.

  3. 3.What does FROM scratch AS cache-export produce, and why does it sit before runner?Show the answer

    Answer:A filesystem holding only .next/cache, which CI exports to a folder with --target and a local exporter. It sits before runner because a build with no --target produces the last stage, and that must be the image you ship.

Go deeper

Related deep dives on this site