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
- OSUbuntu 26.04 LTS (resolute)
- Podman5.7.0
- Runtimecrun 1.21
- Networknetavark 1.16.1
- Buildah1.42.1
- TimeAbout 15 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
- Podman installed - see guide 1.
- Outbound access to
dl-cdn.alpinelinux.org, because one instruction installs a package.
-
A Containerfile, not a Dockerfile
Podman looks for
Containerfilefirst, then falls back toDockerfile. They are the same format - the name is the only difference, and an existingDockerfileneeds no changes.Five instructions, chosen so each one shows something different later:
FROM- the base, fully qualified as alwaysRUN apk add- an expensive layer, the one you want cachedCOPY- brings a file in from the build contextRUN chmod- a cheap layer that depends on the one above itCMD- metadata only, no filesystem change
Note
CMDin 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 -cand 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.shExpected resultA Containerfile and a two-line shell script in
~/app.Success conditionA build context exists with exactly two files in it.
-
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:1localhost/. You asked forapp:1and gotlocalhost/app:1. Podman fully qualifies every image name, including ones you built, and since a local build came from no registry it inventslocalhostas the registry. This is the same strictness as guide 12 - Podman does not keep unqualified names.It matters in two places:
podman imageslists it underlocalhost/app, and pushing it requires re-tagging with a real registry first, becauselocalhostis 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 4df9fe30acd3Expected resultFive STEP lines,
Successfully tagged localhost/app:1, and the script's greeting.Success conditionYou have built an image and run it.
-
What the layers actually are
podman historyreads the image bottom-up and shows what each layer cost:ADD alpine-minirootfs...8.58 MB - the base imageapk add --no-cache curl5.22 MB - the package installCOPY file:...3.58 kB - your scriptchmod +x4.1 kB - a copy of the file with new permissionsCMD [...]0 B - metadata
Two things to take from that.
CMDis free - metadata-only instructions add no bytes. Andchmodcost 4.1 kB to change one bit, because a layer stores the whole file again rather than a diff of its permissions. That is whyCOPY --chmod=755in one instruction beatsCOPYthenRUN chmod.Also note
in the ID column for some rows, andbuildkit.dockerfile.v0in 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.Layersreports 4, not 5, because the metadata-onlyCMDcontributes 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
Error: no Containerfile or Dockerfile specified or found in context directory.Why: You are not in the directory you think you are, or the file has a different name.
Fix:
podman build -f path/to/Containerfile .names it explicitly. The final.is the CONTEXT, not the file - they are separate arguments.podman push app:1fails to resolve a registry.Why: The image is
localhost/app:1andlocalhostis not a real registry.Fix:
podman tag localhost/app:1 registry.example.com/team/app:1then push that.The build sends an enormous context and takes ages before STEP 1.
Why: The context directory contains things you did not mean to send - a
.git, anode_modules, build output.Fix:Add a
.containerignore(or.dockerignore) file. Both are honoured.COPYcannot find a file that is definitely there.Why:
COPYpaths are relative to the build context, and nothing outside it is reachable -../will not work.Fix:Move the file into the context, or build from a directory high enough to contain everything you need.