Docker Multi-Stage Builds
Compilers, headers and package managers belong in the build, not in the thing you ship. One Dockerfile, two stages, and a measured result: a 253MB builder producing a 112kB runtime image.
Dockerfiles and Builds Guide 19 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Shellbash
- Architectureamd64
- TimeAbout 15 min
- Reviewed21 August 2026
Tested on the versions above. Image sizes depend on the base image and toolchain versions at build time. Step numbers, digests and timings vary per run. Match the shape of the output, not the exact strings.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- You can build an image from a Dockerfile - guide 14.
- Understanding that each instruction creates a layer and layers are additive - see the layers and cache guide.
- A writable working directory. This guide uses /tmp/cg-ms.
-
The problem, stated as a number
Compiling anything needs a toolchain. If the toolchain is in your final image, you ship it to every host that pulls the image, and every byte of it is attack surface for something that only ever needs to run one binary. A single-stage build of a C program carries gcc and musl-dev forever. The fix is to throw the builder away once it has produced the artifact.
bash cat hello.c#include <stdio.h> int main(void){ printf("built in a builder stage\n"); return 0;}Expected resultA trivial program - the point is the packaging, not the code.
Success conditionThe file exists. Any compiled language behaves the same way; C just makes the size difference obvious.
-
Two stages in one Dockerfile
Each FROM starts a new stage. Naming a stage with AS lets a later stage copy from it. The second stage here starts from
scratch- a completely empty image, no shell, no libc, nothing - which is only viable because the binary is statically linked. COPY --from=build is the whole trick: it reaches into the previous stage's filesystem and takes only the file named.bash cat DockerfileFROM alpine:3.22 AS buildRUN apk add --no-cache gcc musl-devWORKDIR /srcCOPY hello.c .RUN gcc -static -O2 -o hello hello.c FROM scratch AS runtimeCOPY --from=build /src/hello /helloENTRYPOINT ["/hello"]Expected resultTwo FROM lines, each beginning a stage, and a COPY --from that crosses between them.
Success conditionThe file has two FROM instructions. Without
-staticthe binary would need libc at runtime andscratchwould fail with a missing-loader error. -
Build and run it
The build runs both stages, but only the last one becomes the tagged image. The builder stage is still in the build cache, which is why a rebuild is fast, but it is not part of what you ship.
bash Example session docker build -t cg-multi:1 .#11 naming to docker.io/library/cg-multi:1 done#11 DONE 0.3sdocker run --rm cg-multi:1built in a builder stageExpected resultA normal build, then the program's output.
Success conditionThe binary runs from an image with no operating system in it at all.
-
Measure both stages
--targetbuilds a named stage and stops, which is how you inspect the intermediate. Tagging both makes the comparison concrete. This is the number that justifies the technique - the runtime image is roughly one two-thousandth the size of the builder, and the difference is entirely toolchain that the running program never needs.bash Example session docker build --target build -t cg-multi:builder .docker images --filter reference=cg-multi --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"REPOSITORY:TAG SIZEcg-multi:1 112kBcg-multi:builder 253MBExpected resultTwo rows: the runtime image at kilobytes, the builder at hundreds of megabytes.
Success conditionThe runtime image is dramatically smaller. If both are similar sizes, the final stage is probably inheriting from the builder rather than from a clean base.
-
Push it and confirm the layer count
A small image is not just a disk saving - it is what gets transferred on every pull, on every node, on every deploy. Pushing to a local registry shows exactly how few layers travel. Three layers here, and the whole thing is a rounding error on the wire.
bash Example session docker tag cg-multi:1 localhost:5000/cg-multi:1docker push localhost:5000/cg-multi:1The push refers to repository [localhost:5000/cg-multi]44136fa355b3: Pushed33332d5fcf3a: Pushedbc3d926f773a: Pushed1: digest: sha256:e2afd891162845f0908563e47ab6194b57d3ed8bb7ea7eacd83d6be5b6680720 size: 855Expected resultThree layers pushed and a manifest digest.
Success conditionA digest is returned. Compare with pushing the builder stage - it would move hundreds of megabytes for the same program.
-
Clean up
Remove the images you tagged. The build cache still holds the builder stage, so an immediate rebuild stays fast.
bash # removes only the images this guide createddocker rmi cg-multi:1 cg-multi:builder localhost:5000/cg-multi:1Untagged: cg-multi:1Untagged: cg-multi:builderDeleted: sha256:2ecd09948ebd7303294fe200e2a3e91d22bada99628b8ee8e4e0607f906ed10dUntagged: localhost:5000/cg-multi:1Deleted: sha256:9bc07684f095417540cc470ac05604508ca8380be19b5fa75b712bf5cc723b5bExpected resultUntagged and deleted lines.
Success condition
docker images --filter reference=cg-multireturns nothing.
Troubleshooting
The scratch-based image exits immediately with no such file or directory
Why: The binary is dynamically linked and its loader is not present.
scratchcontains nothing at all, so anything the binary needs must be copied in or compiled in.Fix:Link statically, or use a minimal base that provides libc such as alpine or a distroless image. Check what the binary needs before choosing.
bash docker run --rm cg-multi:builder ldd /src/hello# "not a dynamic executable" means it is safe for scratchCOPY --from=build cannot find the file
Why: The path is relative to the builder stage's filesystem, not to your build context. A WORKDIR in the builder changes where the artifact landed.
Fix:Build the builder stage with --target and inspect it directly to confirm the path.
bash docker build --target build -t cg-multi:builder .docker run --rm cg-multi:builder ls -l /srcThe final image is still large
Why: The last stage inherits from the builder, or artifacts are copied from a stage that still contains the toolchain.
Fix:Confirm the final FROM starts from a clean base, and copy only the specific files you need rather than a whole directory.
bash docker history cg-multi:1 --format "table {{.Size}}\t{{.CreatedBy}}"