Podman images are built using a layered filesystem, and understanding these layers is key to optimizing image size.
Let’s see what a typical image looks like under the hood. We’ll use alpine as our example, a popular minimal Linux distribution.
podman history alpine:latest
This command shows us the history of an image, listing each layer, its size, and the command that created it. You’ll see a series of RUN commands, each creating a new layer. For alpine, it might look something like this (sizes will vary):
IMAGE ID CREATED CREATED BY SIZE
abcdef123456 2 weeks ago /bin/sh -c #(nop) ADD file:a1b2c3d4e5f6... 2.5 MB
fedcba654321 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0 B
...
Each RUN command, or even ADD or COPY instructions, can result in a new layer. When you RUN apt-get update && apt-get install -y some-package, that’s one layer. If you then RUN rm -rf /var/lib/apt/lists/* in a separate RUN command, you’ve created a new layer that still contains the downloaded package lists, even though they’re deleted in the final filesystem. The deleted data still occupies space in the layer beneath.
The core problem Podman (and Docker) solves here is efficient storage and distribution. Instead of shipping the entire filesystem for every image change, only the differences between layers are stored. This is incredibly efficient when multiple images share common base layers, like alpine or ubuntu. When you pull my-app:latest which is based on alpine, and you already have alpine locally, Podman only downloads the layers unique to my-app.
The levers you control are the instructions in your Containerfile (or Dockerfile). Each RUN, COPY, ADD, and even FROM directive can influence the number and size of layers. The goal is to minimize both the number of layers and the size of each layer, especially the ones that contain data you’ll later remove.
The most surprising true thing about image layers is that a RUN rm -rf /tmp command doesn’t actually shrink the image size if it’s in a separate layer from where the files were created. The files still exist in the layer below, and the rm command in the current layer just marks them as deleted for that specific layer’s view of the filesystem. The underlying data remains until the image is garbage collected or rebuilt from scratch.
To analyze image size, podman images gives you an overview, but podman history <image> is where you dive deep. You can see precisely which commands are responsible for which chunks of data.
A common pitfall is creating too many intermediate layers. Each RUN command, by default, creates a new layer. If you have:
RUN apt-get update
RUN apt-get install -y some-package
RUN rm -rf /var/lib/apt/lists/*
This results in three layers. The apt-get update downloads package lists, the install adds packages, and the rm deletes the lists. However, the data from apt-get update is still present in the second layer, even though it’s removed in the third.
The fix is to chain commands within a single RUN instruction using &&:
RUN apt-get update && apt-get install -y some-package && rm -rf /var/lib/apt/lists/*
This single RUN instruction creates only one layer. The rm -rf /var/lib/apt/lists/* command executes within the same layer where the lists were downloaded and packages installed, effectively cleaning up the downloaded lists before the layer is finalized. This dramatically reduces the size of that specific layer and, consequently, the overall image.
Another common cause of bloat is copying unnecessary files into the image. This often happens with build tools or development dependencies that aren’t needed at runtime.
The diagnosis is to look at your COPY and ADD instructions in the Containerfile. Are you copying your entire project directory, including .git folders, node_modules, build artifacts, or test files?
The fix is to use a .containerignore file (similar to .dockerignore) to exclude files and directories that aren’t needed in the final image. For example, a .containerignore file might contain:
.git
*.md
node_modules
build/
test/
Then, in your Containerfile, you’d use COPY . /app. This ensures that only the necessary application code and dependencies are copied, significantly reducing the layer size.
When dealing with package managers, always clean up cache directories. For apt (Debian/Ubuntu):
RUN apt-get update && apt-get install -y --no-install-recommends package-name && rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag prevents installation of optional dependencies, further reducing size. The rm -rf /var/lib/apt/lists/* cleans up the package lists.
For apk (Alpine):
RUN apk update && apk add package-name && rm -rf /var/cache/apk/*
This updates the index, installs the package, and then removes the apk cache.
For yum/dnf (CentOS/Fedora/RHEL):
RUN yum update -y && yum install -y package-name && yum clean all
yum clean all removes cached package data.
Multi-stage builds are your best friend for reducing image size, especially for compiled languages. The idea is to use one Containerfile to build your application (with all the build tools, compilers, SDKs) and then copy only the artifacts (executables, compiled libraries) into a new, minimal base image for the final runtime image.
# Build stage
FROM golang:1.20 as builder
WORKDIR /app
COPY . .
RUN go build -o myapp .
# Runtime stage
FROM alpine:latest
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
Here, the builder stage has the Go toolchain and source code. The final image is based on alpine and only contains the compiled myapp binary. This can shrink images from hundreds of MBs to just a few MBs.
Another subtle point: COPYing a file from a host into an image creates a layer. If you COPY the same file again in a later instruction, and it’s modified on the host between COPYs, it will create another layer. If the file on the host doesn’t change, Podman’s build cache will often reuse the existing layer, avoiding a new one. This is why rebuilding an image with identical COPY instructions when the source file hasn’t changed can be very fast.
The next error you’ll likely encounter after optimizing image size is related to build cache invalidation, where a seemingly small change upstream causes a much larger rebuild than expected.