Podman Multi-Stage Builds
The same Containerfile produces an 8.66 MB image and a 182 MB one, and the difference is which stage you stop at. `--target` builds the discarded half so you can look at what was thrown away.
Building Images Guide 19 of 47 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Podman5.7.0
- Runtimecrun 1.21
- Networknetavark 1.16.1
- Buildah1.42.1
- TimeAbout 14 min
- Reviewed22 August 2026
Written against the versions above. Podman follows the distribution here rather than a vendor repository, so the version you get is the one Ubuntu shipped. The commands are stable across 5.x.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| PODMAN01 | 192.168.0.24 | Ubuntu 26.04 LTS | Primary Container Host | 2 Core | 4 GB | 50 GB |
Before you start
- guide 30 for the basics.
- Nothing to install - the compiler is installed inside the build stage.
-
Two FROMs in one file
The file has two
FROMinstructions. The first is named -FROM alpine:3.22 AS build- installs a C compiler and produces a static binary. The second starts over from a cleanalpineand copies only the binary across:COPY --from=build /hello /usr/local/bin/hello--from=buildreaches into the earlier stage's filesystem. Nothing else crosses: notgcc, notmusl-dev, not the source file, not the package index. The final image has no idea it was compiled.-staticmatters here. A dynamically linked binary would need its libraries copied too, and the usual multi-stage mistake is copying a binary that then cannot find its.sofiles at runtime.bash Example session mkdir -p ~/multi && rm -rf ~/multi/* && cd ~/multi && cat > hello.c <<'CC'#include <stdio.h>int main(void){ printf("compiled in a stage that was thrown away\n"); return 0; }CCcd ~/multi && cat > Containerfile <<'CF'FROM docker.io/library/alpine:3.22 AS buildRUN apk add --no-cache gcc musl-devWORKDIR /srcCOPY hello.c .RUN gcc -static -o /hello hello.c FROM docker.io/library/alpine:3.22COPY --from=build /hello /usr/local/bin/helloCMD ["/usr/local/bin/hello"]CFExpected resultA C file and a two-stage Containerfile in
~/multi.Success conditionThe build context has two stages defined in one file.
-
Build it and run it
One
podman buildruns both stages in order. The STEP numbering continues across them - it does not restart at the secondFROM- so a seven-instruction two-stage file reportsSTEP 1/7throughSTEP 7/7.The result runs and prints its line. The binary works, and the image it lives in never had a compiler.
bash Example session cd ~/multi && podman build -t multi:1 .[1/2] STEP 1/5: FROM docker.io/library/alpine:3.22 AS build[1/2] STEP 2/5: RUN apk add --no-cache gcc musl-devfetch https://dl-cdn.alpinelinux.org/alpine/v3.22/main/x86_64/APKINDEX.tar.gzfetch https://dl-cdn.alpinelinux.org/alpine/v3.22/community/x86_64/APKINDEX.tar.gz(1/13) Installing libgcc (14.2.0-r6)(2/13) Installing jansson (2.14.1-r0)(3/13) Installing libstdc++ (14.2.0-r6)(4/13) Installing zstd-libs (1.5.7-r0)(5/13) Installing binutils (2.44-r3)(6/13) Installing libgomp (14.2.0-r6)(7/13) Installing libatomic (14.2.0-r6)(8/13) Installing gmp (6.3.0-r3)(9/13) Installing isl26 (0.26-r1)(10/13) Installing mpfr4 (4.2.1_p1-r0)(11/13) Installing mpc1 (1.3.1-r1)(12/13) Installing gcc (14.2.0-r6)(13/13) Installing musl-dev (1.2.5-r12)Executing busybox-1.37.0-r20.triggerOK: 172 MiB in 29 packages--> 1fe9fb4f605epodman run --rm multi:1compiled in a stage that was thrown awayExpected resultSTEP lines spanning both stages, then
compiled in a stage that was thrown away.Success conditionA binary built in one stage runs from an image built in another.
-
Measure what you saved
--target buildstops at the first stage and tags it, which lets you see the half that is normally discarded:localhost/multi 1 8.66 MB localhost/multi builder 182 MB8.66 MB against 182 MB - a factor of 21, for the same program. The 173 MB difference is
gcc,musl-dev, their dependencies and the package index: everything needed to *produce* the binary and nothing needed to *run* it.Size is the obvious win and the least important one. The real arguments:
- Attack surface. A compiler, a package manager and a shell in a production image are tools available to anyone who gets a foothold.
- Patching. Every package in that 173 MB generates CVE reports you have to triage for software you do not run.
- Pull time, multiplied by every node and every deploy.
--targetis also the practical debugging tool here. When a multi-stage build fails, building the earlier stage on its own and running a shell in it is far faster than addingRUN lslines and rebuilding.Note every step reported
Using cacheon this second build - the stages are cached like any other layers, so inspecting the builder cost nothing.bash Example session cd ~/multi && podman build --target build -t multi:builder .STEP 1/5: FROM docker.io/library/alpine:3.22 AS buildSTEP 2/5: RUN apk add --no-cache gcc musl-dev--> Using cache 1fe9fb4f605e5148e5c9a034eee52ed8da10efbd2b2718539c3b89226ec215d9--> 1fe9fb4f605eSTEP 3/5: WORKDIR /src--> Using cache 09cf1f1842262e9a4175206d7f7e8c3ab23bb1050ccf5959709a265a7b2e33a2--> 09cf1f184226STEP 4/5: COPY hello.c .--> Using cache 134b9b79679557840b32fb6162f63decf60cb774273dab9fc56880f31c36a817--> 134b9b796795STEP 5/5: RUN gcc -static -o /hello hello.c--> Using cache 4eda8120774c7a4af02bc2ae5e2d14fd86cf72eef4d52676950cc0134338da8dCOMMIT multi:builder--> 4eda8120774cpodman images --format 'table {{.Repository}} {{.Tag}} {{.Size}}' multiREPOSITORY TAG SIZElocalhost/multi 1 8.66 MBExpected resultA cached build of the first stage, then 8.66 MB beside 182 MB.
Success conditionYou have both halves of the same build on disk and can compare them.
Troubleshooting
The copied binary fails with
not foundalthough the file is clearly there.Why: It is dynamically linked and its interpreter or libraries are missing in the final stage.
Fix:Build statically, or copy the libraries too.
podman run --rm multi:1 ldd /usr/local/bin/hellotells you what it wants - iflddis absent, that is its own answer about how minimal the image is.COPY --from=buildcannot find the stage.Why: The name must match the
ASlabel exactly, and the stage must appear earlier in the file.Fix:Names are case sensitive.
--from=0by index also works but breaks the moment someone inserts a stage.The final image is still large.
Why: Something large is being copied across, or the final
FROMis not a small base.Fix:
podman history multi:1shows which layer carries the bytes. It is usually oneCOPYthat took a directory rather than a file.You want the builder stage cached in CI but it rebuilds every time.
Why: CI starts with an empty store, so there is nothing to cache against.
Fix:Push the builder stage as its own image and
--cache-fromit, or keep a persistent build cache volume on the runner.