CertGrid CertGrid
Hands-on Lab·Podman

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

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.

Every command on this page ran on podman01.
Server NameIP AddressOSRolesCPURAMHDD
PODMAN01192.168.0.24Ubuntu 26.04 LTSPrimary Container Host2 Core4 GB50 GB

Before you start

  1. Two FROMs in one file

    The file has two FROM instructions. 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 clean alpine and copies only the binary across:

    COPY --from=build /hello /usr/local/bin/hello

    --from=build reaches into the earlier stage's filesystem. Nothing else crosses: not gcc, not musl-dev, not the source file, not the package index. The final image has no idea it was compiled.

    -static matters 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 .so files 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"]CF

    Expected resultA C file and a two-stage Containerfile in ~/multi.

    Success conditionThe build context has two stages defined in one file.

  2. Build it and run it

    One podman build runs both stages in order. The STEP numbering continues across them - it does not restart at the second FROM - so a seven-instruction two-stage file reports STEP 1/7 through STEP 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 away

    Expected 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.

  3. Measure what you saved

    --target build stops 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 MB

    8.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.

    --target is 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 adding RUN ls lines and rebuilding.

    Note every step reported Using cache on 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 MB

    Expected 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

Official sources