CertGrid CertGrid
Hands-on Lab·Podman

Building Images with Containerfiles

Five instructions, five STEP lines, and an image tagged `localhost/app:1` - Podman prefixes locally built images with a registry that does not exist, which is the first thing that will confuse you when you try to push it.

Building Images Guide 16 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. A Containerfile, not a Dockerfile

    Podman looks for Containerfile first, then falls back to Dockerfile. They are the same format - the name is the only difference, and an existing Dockerfile needs no changes.

    Five instructions, chosen so each one shows something different later:

    • FROM - the base, fully qualified as always
    • RUN apk add - an expensive layer, the one you want cached
    • COPY - brings a file in from the build context
    • RUN chmod - a cheap layer that depends on the one above it
    • CMD - metadata only, no filesystem change

    Note CMD in exec form - a JSON array, not a bare string. That matters for signal handling, as guide 15 shows: the shell form wraps your command in /bin/sh -c and the shell becomes PID 1.

    bash Example session
    mkdir -p ~/app && rm -rf ~/app/* && cd ~/app && cat > Containerfile <<'CF'FROM docker.io/library/alpine:3.22RUN apk add --no-cache curlCOPY hello.sh /usr/local/bin/helloRUN chmod +x /usr/local/bin/helloCMD ["/usr/local/bin/hello"]CFcd ~/app && printf '#!/bin/sh\necho "hello from $(hostname)"\n' > hello.sh && cat hello.sh#!/bin/shecho "hello from $(hostname)"ls ~/appContainerfilehello.sh

    Expected resultA Containerfile and a two-line shell script in ~/app.

    Success conditionA build context exists with exactly two files in it.

  2. Build it, and read the STEP lines

    podman build -t app:1 . and the output is a numbered walk through your file: STEP 1/5, STEP 2/5, and so on. Each one ends with a short hex ID - that is the layer it just produced, and remembering that these are IDs is what makes the caching in guide 32 legible.

    Then the part that surprises everyone:

    Successfully tagged localhost/app:1

    localhost/. You asked for app:1 and got localhost/app:1. Podman fully qualifies every image name, including ones you built, and since a local build came from no registry it invents localhost as the registry. This is the same strictness as guide 12 - Podman does not keep unqualified names.

    It matters in two places: podman images lists it under localhost/app, and pushing it requires re-tagging with a real registry first, because localhost is not somewhere you can push to.

    13.8 MB, and it runs.

    bash Example session
    cd ~/app && podman build -t app:1 .STEP 1/5: FROM docker.io/library/alpine:3.22Trying to pull docker.io/library/alpine:3.22...Getting image source signaturesCopying blob sha256:f7ee36c9aa34bbb665f975c76e5c0d1607f0674b94c84cfb0061f87006ea5d10Copying config sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365Writing manifest to image destinationSTEP 2/5: RUN apk add --no-cache curlfetch 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/9) Installing brotli-libs (1.1.0-r2)(2/9) Installing c-ares (1.34.8-r0)(3/9) Installing libunistring (1.3-r0)(4/9) Installing libidn2 (2.3.7-r0)(5/9) Installing nghttp2-libs (1.69.0-r0)(6/9) Installing libpsl (0.21.5-r3)(7/9) Installing zstd-libs (1.5.7-r0)(8/9) Installing libcurl (8.14.1-r3)(9/9) Installing curl (8.14.1-r3)Executing busybox-1.37.0-r20.triggerOK: 12 MiB in 25 packages--> 6180d06f43b1STEP 3/5: COPY hello.sh /usr/local/bin/hello--> 1f4fdfd3f228STEP 4/5: RUN chmod +x /usr/local/bin/hello--> ab7f293673bfSTEP 5/5: CMD ["/usr/local/bin/hello"]COMMIT app:1--> c154b5c3995cSuccessfully tagged localhost/app:1c154b5c3995cb4c11724e001b4be0eec6051a98131ed502f345445293ac8f92fpodman images appREPOSITORY     TAG         IMAGE ID      CREATED                 SIZElocalhost/app  1           c154b5c3995c  Less than a second ago  13.8 MBpodman run --rm app:1hello from 4df9fe30acd3

    Expected resultFive STEP lines, Successfully tagged localhost/app:1, and the script's greeting.

    Success conditionYou have built an image and run it.

  3. What the layers actually are

    podman history reads the image bottom-up and shows what each layer cost:

    • ADD alpine-minirootfs... 8.58 MB - the base image
    • apk add --no-cache curl 5.22 MB - the package install
    • COPY file:... 3.58 kB - your script
    • chmod +x 4.1 kB - a copy of the file with new permissions
    • CMD [...] 0 B - metadata

    Two things to take from that. CMD is free - metadata-only instructions add no bytes. And chmod cost 4.1 kB to change one bit, because a layer stores the whole file again rather than a diff of its permissions. That is why COPY --chmod=755 in one instruction beats COPY then RUN chmod.

    Also note in the ID column for some rows, and buildkit.dockerfile.v0 in the comment column of the base layers. The base was built by Docker's BuildKit, and Podman is reading its metadata perfectly - an OCI image does not care which engine made it.

    RootFS.Layers reports 4, not 5, because the metadata-only CMD contributes no filesystem layer.

    bash Example session
    podman history app:1ID            CREATED       CREATED BY                                     SIZE        COMMENTab7f293673bf  1 second ago  /bin/sh -c #(nop) CMD ["/usr/local/bin/hel...  0B<missing>     1 second ago  /bin/sh -c chmod +x /usr/local/bin/hello       4.1kB1f4fdfd3f228  1 second ago  /bin/sh -c #(nop) COPY file:76f30fd149afbb...  3.58kB6180d06f43b1  1 second ago  /bin/sh -c apk add --no-cache curl             5.22MB      FROM docker.io/library/alpine:3.22b66e0ce64844  2 months ago  CMD ["/bin/sh"]                                0B          buildkit.dockerfile.v0<missing>     2 months ago  ADD alpine-minirootfs-3.22.5-x86_64.tar.gz...  8.58MB      buildkit.dockerfile.v0podman image inspect app:1 --format 'layers={{len .RootFS.Layers}} cmd={{.Config.Cmd}}'layers=4 cmd=[/usr/local/bin/hello]

    Expected resultSix history rows with sizes, and layers=4.

    Success conditionYou can say which instructions cost disk and which are free.

Troubleshooting

Official sources