# Cara Membuat Multi Stage Build Docker dari Dockerfile Biasa

> How to build a Docker multi-stage image for beginners: split a Dockerfile into stages, copy only what runs, drop root, and measure the image size yourself.

URL: https://www.matthewswong.com/en/blog/cara-membuat-multi-stage-build-docker/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: DevOps

> **Key takeaway:** To build a Docker multi-stage image, name a stage with FROM node:22-alpine AS builder, build the app there, then start a new stage with FROM and take only the build output using COPY --from=builder. Only the last stage becomes the image. Measure before and after with docker image ls instead of guessing.

Your first Dockerfile works. The app runs in a container, then you run docker image ls and the SIZE column is far larger than you expected. That is normal: in a single-stage Dockerfile, the image that builds the app is the same image you ship, so the compiler, the source code, the dev dependencies and the download cache all come along. Beginners usually get stuck in three places: not knowing how to separate build from runtime, writing COPY --from wrongly, and having no way to prove the size really dropped.

This guide turns a single-stage Dockerfile into a multi-stage one in seven steps, with a measuring command at every stage. I checked every command and flag against the official Docker and npm documentation. The app is deliberately tiny, a one-file TypeScript server, so you can try everything on a laptop. The same pattern is used by this site's own Dockerfile: deps, builder and runner stages, with only the runner shipped to the server.

Want to understand first what an image actually ships and why the other stages are left behind? Take the free lesson

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

 in the DevOps from zero track, with an interactive explainer.

## What you need

These three things are enough. If you have never written a Dockerfile at all, read the

learn Docker from zero guide

 first, then come back.

Docker installed and the docker command working in your terminal. The builds in this guide use BuildKit, Docker's current default builder.

Node.js and npm installed, only to create the package-lock.json file once in step 1.

One empty folder to practise in. Every name below (devops-lab, runner, builder) is a placeholder, rename freely.

## Step 1: Set up the app and a single-stage Dockerfile

We need a picture of the before state, so start from an ordinary Dockerfile that builds and runs the app in one image. Create the files below. The .dockerignore file matters: it keeps node_modules and dist on your computer from being copied in by COPY . . inside the image.

Save that single-stage Dockerfile as Dockerfile.single. One FROM line means one stage: everything needed to build, including TypeScript and the whole node_modules, stays in the final image.

## Step 2: Measure the image size before changing anything

Change nothing until you have a number to compare against. Build the single-stage image, then read the SIZE column. Docker describes that column as the image's disk size. Your number will differ from other people's, because it depends on the base image, the CPU architecture and package versions, so record your own.

Run docker history as well. It prints one row per layer with the instruction that made it and its size. Look at the layers from COPY . . and RUN npm ci: that is where the source code and node_modules pile up, and you need neither just to run the compiled output.

## Step 3: Split into two stages, builder and runner

Now change it to two stages. Every FROM starts a new stage on a clean base image, and AS names that stage. A named stage is safer than a number (0, 1, ...), because COPY stays correct if the stage order changes. The runner stage has no source and no TypeScript. It takes one folder from the builder with COPY --from.

Two things to understand. First, the path after --from=builder is read from the root of the builder stage's filesystem, not from your folder, so it must be an absolute path inside that stage. Second, only the last stage ships. The builder stage is still built, but it does not become the image you run or push.

## Step 4: Split out a deps stage so the cache gets used

Add a third stage that only installs packages. It copies just two files, package.json and package-lock.json, then runs npm ci. The builder then takes its node_modules with COPY --from=deps. The npm documentation says npm ci is meant for automated environments such as CI and deployment, and that it requires a package-lock.json.

The payoff shows on the next build. If you only edit src/server.ts, the deps layer is unchanged, so npm ci does not run again. This is also the layout of this site's Dockerfile, with one addition: there, npm ci uses a cache mount for npm's download folder.

> If your app needs runtime packages when it runs (express, for instance), the runner still needs node_modules. Do not copy the whole node_modules from the builder. Make a separate stage that runs npm ci with the --omit=dev option (the omit option is listed in the npm ci documentation) and copy from that stage only.

## Step 5: Copy only what runs, and do not run as root

This is where beginners slip most, so compare the three COPY lines below. COPY without --from reads from your folder (the build context), not from another stage. And copying the whole node_modules from the builder brings back every dev dependency you just wanted to leave behind.

Then finish the runner with a non-root user. Docker's best-practice guide recommends a non-root USER when a service can run without privileges, and recommends an explicit UID and GID. Watch the order: COPY without --chown creates files owned by UID and GID 0, which is root, so add --chown before the USER line takes effect.

This runner stage now holds one build folder and one user. If your app later needs to write to a folder, create that folder and give it to the app user in a COPY or RUN before USER, because once USER takes effect you are no longer root.

## Step 6: Rebuild, compare, then run it

Build the multi-stage version under another tag, then compare the two tags in docker image ls. Also run id inside the container to prove the user, and curl to prove the app is still alive. Expected results: uid=1001 from the id command, and the text hello from curl.

Be honest about the numbers. Multi-stage removes the build tools, but the base image still sets the floor on size. For an app this small the difference may not be dramatic. The big gap shows up on real apps with many dev dependencies, such as Next.js, and the related lesson below shows how.

> Do not conclude multi-stage worked just because the build is green. Prove it with three checks: docker image ls compares the size of two tags, docker run --rm image-name id shows the user is not root, and the app answers on its port. One COPY --from with the wrong path can produce a small image that does not run at all.

## Step 7: Build a single stage with --target

Sometimes you want to look inside a middle stage, such as builder, without building all the way to the end. Use docker build --target followed by a stage name: the build stops at that stage. The Docker documentation says BuildKit builds only the stages the target depends on, while the legacy builder processes every stage up to the target. A FROM scratch stage is an empty image, a good fit for a stage that exists only to be exported, and the local exporter writes its contents to a plain folder instead of making an image.

One note from this site's own Dockerfile. A build with no --target produces the last stage in the file, so I put helper stages such as cache-export before runner, not after, to keep runner the stage that ships. A helper stage is also not always free: if the build cache is gone, every stage above it runs again.

## Common errors and how to fix them

Six symptoms that appear most often the first time you write a multi-stage Dockerfile, most frequent first.

| Symptom | Cause | Fix |
| --- | --- | --- |
| The build stops at a COPY line with a not found message, though the folder exists on your computer | COPY without --from reads from the build context, and that folder is excluded by .dockerignore or only exists in the builder stage | Add --from=builder and write the absolute path inside that stage, for example COPY --from=builder /app/dist ./dist |
| The build fails because a stage name is not found, or the image pulled is not the one you meant | The name after --from is mistyped or does not match AS. An unknown name can be treated as an image name from a registry | Match the name exactly to the one after AS, and use stage names, not numbers |
| The multi-stage image is barely smaller than the single-stage one | The whole node_modules is copied from the builder, or the base image already makes up most of the size | Copy only the build output, compare docker history for both tags, and consider a smaller base image |
| The container dies at once with a module not found error when it runs | The runner only received the compiled output, but the app needs runtime packages in node_modules | Make a stage that runs npm ci --omit=dev and copy node_modules from that stage into the runner |
| The app fails to write a file or folder, with a permission denied error | Files were copied without --chown so root owns them, while the process runs as the app user after USER | Add --chown=app:app to the COPY, or create the writable folder before the USER line |
| Every small source change makes npm ci run again from scratch | COPY . . sits before npm ci, so the install layer is invalidated too | Copy package.json and package-lock.json first, run npm ci in a deps stage, and copy the source in the builder stage |

## Checklist before you push the image

Every stage is named with AS, and every COPY --from uses that name, not a number.

The runner holds only the build output and runtime packages, with no source, TypeScript or dev dependencies.

You compared the size of both tags with docker image ls and wrote down your own numbers.

docker run --rm image-name id shows a non-root user with the UID you set.

The app answers on its port, and helper stages sit before the stage that ships.

## Go deeper

An interactive walkthrough of what each stage carries is in the free lesson

. If your app is Next.js, see how standalone output decides what the runner holds in

, and if you want a leaner base image, read

.

A rule you can carry: build in one stage, run in another, and copy between stages only what actually runs. Then prove it with the numbers from your own docker image ls, not from an article.

## Sources and references

- Docker docs: Multi-stage builds
- Docker docs: Dockerfile reference (COPY --from, --chown, USER)
- Docker docs: docker image ls
- Docker docs: build context and .dockerignore
- Docker docs: Local and tar exporters

## Source links

- https://docs.docker.com/build/building/multi-stage/
- https://docs.docker.com/reference/dockerfile/
- https://docs.docker.com/reference/cli/docker/image/ls/
- https://docs.docker.com/build/concepts/context/
- https://docs.docker.com/build/exporters/local-tar/

## FAQ

### What is a Docker multi-stage build and why use it?

A multi-stage build is a Dockerfile with more than one FROM instruction, where each FROM starts a new stage. You build the app in an early stage and copy only the result into the last one, which is the only stage that becomes the image. The image is smaller and carries no source code or build tools.

### Why does my multi-stage Dockerfile say not found on COPY?

COPY without --from reads from your build context, not from another stage. If the folder is excluded by .dockerignore or only exists inside the builder stage, the build stops with not found. Write COPY --from=builder and use the absolute path inside that stage.

### Does a multi-stage build always make the image much smaller?

No. It removes build tools and source from the final image, but the base image still sets the minimum size. The gain is large for apps with many dev dependencies and small for tiny apps. Measure with docker image ls on both tags rather than trusting a number from an article.

### Can I build only one stage of a multi-stage Dockerfile?

Yes. Run docker build --target followed by the stage name and the build stops at that stage. BuildKit only builds the stages that target depends on. This is handy for looking inside the builder stage or for a stage that only exports files.

### Why should the final stage use a non-root USER?

If the app is compromised, a process running as root inside the container has far more power than one running as an unprivileged user. Docker's best-practice guide recommends a non-root USER with an explicit UID and GID. Remember COPY creates files owned by root unless you add --chown.

---

- HTML version: https://www.matthewswong.com/en/blog/cara-membuat-multi-stage-build-docker/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-membuat-multi-stage-build-docker/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
