| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | podman 5.8.2 |
| Applies to | Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora |
| Cert prep | CKS |
| Lab environment | Workstation with Podman 5.8.2 — build and push container images for tag and push basics |
| Privilege | Normal user for podman build and podman run; sudo only if your site requires it for package installs |
| Scope | Runtime image goals, base image trade-offs, incremental hardening from a bloated image, multi-stage builds, package and cache cleanup, non-root users, COPY ownership, secret-free layers and build mounts, digest pinning, read-only runtime path planning, final image inspection, and before-and-after comparison. Assumes you already build and tag images. Does not cover registry installation, full Kubernetes SecurityContext, Trivy or Cosign tutorials, or language-specific dependency management. |
| Related guides | Kubernetes security architecture Harden Linux nodes for Kubernetes Verify Kubernetes release artifacts |
If you already know how to build, tag, and push images, the next step is shrinking what ships to production. This walkthrough starts with one intentionally bloated Go HTTP server image on Rocky Linux 10.2, then hardens it stage by stage with multi-stage builds, a distroless runtime, non-root execution, digest pins, and secret-safe builds. After each major change you inspect what actually landed in the final filesystem.
Define the runtime image goal
The runtime stage should contain only what the running process needs:
- Application binary or interpreted runtime artifacts
- Required shared libraries (or a fully static binary)
- CA certificates when the app calls HTTPS endpoints
- Minimal configuration files the process reads at start
- An explicit unprivileged user and file permissions
Compilers, Git, package managers, source trees, and build caches belong in an earlier builder stage that never ships to production.
Choose a minimal base
| Base type | Compatibility | Shell / debug | libc | Updates | Trade-off |
|---|---|---|---|---|---|
Full distribution (rockylinux:10) |
Highest | Full shell and tools | glibc | dnf update in image |
Large attack surface |
Minimal distribution (alpine:3.21) |
Good with musl caveats | /bin/sh present |
musl | apk upgrade |
Small, still has package manager |
Distroless (gcr.io/distroless/static-debian12) |
Static binaries or bundled libs | No shell | None required for a static binary; use base or cc for glibc-linked applications |
Rebuild from updated digest | Smallest runtime, harder to debug |
scratch |
Static binaries only | None | None baked in | Rebuild | Tiny, you supply everything |
This lab compiles a static Go binary and copies it into distroless static. If you need a shell and your application is compatible with musl, Alpine is a useful middle ground. For dynamically linked glibc applications, use a Debian-based runtime or the distroless base or cc variants. Distroless distinguishes static, base, and cc images based on runtime-library requirements.
Start with a bloated baseline image
Create a lab directory and a tiny HTTP server:
mkdir -p ~/secure-img-lab && cd ~/secure-img-labAdd a build-context ignore file so Git metadata, environment files, keys, and build output never enter the image:
# .containerignore
.git
.env
*.pem
*.key
node_modules/
dist/
bin/Podman supports both .containerignore and .dockerignore; when both exist, .containerignore takes precedence. Docker users rely on .dockerignore when .containerignore is absent.
Save main.go:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "secure-demo ok")
})
http.ListenAndServe(":8080", nil)
}Dockerfile.bloated installs build tools and compiles inside the same stage that runs in production:
FROM rockylinux:10
RUN dnf install -y gcc make git curl wget vim tar golang
WORKDIR /app
COPY main.go .
RUN go build -o server main.go
EXPOSE 8080
CMD ["./server"]Build and record the starting point:
podman build -f Dockerfile.bloated -t secure-demo:bloated .Successfully tagged localhost/secure-demo:bloatedCheck image size:
podman images secure-demo:bloated --format '{{.Size}}'868 MBThe bloated image includes the full Rocky userspace, dnf cache, and build toolchain even though runtime only needs the server binary.
Confirm it runs as root with a shell:
podman run --rm secure-demo:bloated iduid=0(root) gid=0(root) groups=0(root)podman run --rm secure-demo:bloated sh -c 'echo has_shell'has_shellStart the server and hit it from the host:
podman run --rm -d --name bloated-test -p 18080:8080 secure-demo:bloatedcurl -s http://127.0.0.1:18080/secure-demo okStop the test container when you finish inspecting:
podman rm -f bloated-testThat 868 MB root image is the baseline every hardening step compares against.
Use multi-stage builds
Move compilation into a builder stage and copy only the binary into a minimal runtime. Pin both stages by digest so the build matches what you tested:
FROM docker.io/rockylinux/rockylinux:10@sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834 AS builder
RUN dnf install -y golang && dnf clean all
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server main.go
FROM gcr.io/distroless/static-debian12@sha256:597c2b4bc7f353100af9b8b06bb4f126c4a45f9d8175e25d4f01f965d5d94396
COPY --from=builder /server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]Save that as Dockerfile.hardened. Resolve digests on your workstation before you pin them. Pair each digest with the fully qualified registry and repository Podman resolved — short names can map differently depending on registries.conf:
podman pull docker.io/rockylinux/rockylinux:10
podman inspect docker.io/rockylinux/rockylinux:10 \
--format '{{index .RepoDigests 0}}'
podman pull gcr.io/distroless/static-debian12
podman inspect gcr.io/distroless/static-debian12 \
--format '{{index .RepoDigests 0}}'Build the hardened tag:
podman build -f Dockerfile.hardened -t secure-demo:hardened .Successfully tagged localhost/secure-demo:hardenedCompare sizes on the same workstation:
podman images \
--filter 'reference=localhost/secure-demo:.*' \
--format 'table {{.Tag}}\t{{.Size}}'TAG SIZE
bloated 868 MB
hardened 2.44 MBOn this lab host the hardened tag dropped from 868 MB to single-digit megabytes while serving the same secure-demo ok response. Exact bytes vary with base digest and linker flags; always compare with podman images after your build.
Verify build tools never reached the runtime stage. The hardened image sets ENTRYPOINT ["/server"], so Podman passes extra arguments to /server unless you override the entrypoint:
podman run --rm \
--entrypoint sh \
secure-demo:hardened \
-c 'echo inside' 2>&1Error: runc: exec failed: unable to start container process: exec: "sh": executable file not found in $PATHExact wording varies between crun and runc. Podman requires --entrypoint to override an image's configured entrypoint.
Prove the hardened image still serves the same application:
podman run --rm -d \
--name hardened-test \
-p 18081:8080 \
secure-demo:hardened
curl -s http://127.0.0.1:18081/
podman rm -f hardened-testsecure-demo okNo shell in distroless is expected. The Go compiler and dnf metadata are not part of the final image's layer chain. Intermediate layers may remain in the local build cache, but they are not included when the final runtime image is pushed. Podman caches intermediate layers by default, while intermediate stage images are not preserved unless specifically requested.
Remove package and build waste
In the builder stage, install only what compilation needs and clean package metadata before the layer is committed:
RUN dnf install -y golang && dnf clean allAvoid dnf install @development or recommended bundles unless you truly need them. Do not copy /root/.cache, node_modules, or .git into the runtime stage.
The runtime distroless stage has no package manager at all, which removes an entire class of runtime package installs.
Run as non-root
Distroless ships with user 65532. For Alpine runtimes, create an explicit account in a complete multi-stage file:
FROM docker.io/rockylinux/rockylinux:10@sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834 AS builder
RUN dnf install -y golang && dnf clean all
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server main.go
FROM docker.io/library/alpine:3.21@sha256:2510911c71c2bb89162f020f825f4a8f4dcdc94d27fdef7b5d6e9f215c8c9e3a
RUN adduser -D -u 10001 -g '' appuser
COPY --from=builder /server /server
USER appuser
EXPOSE 8080
ENTRYPOINT ["/server"]Save as Dockerfile.hardened-alpine and build when you want a shell-capable runtime for permission checks:
podman build \
-f Dockerfile.hardened-alpine \
-t secure-demo:hardened-alpine .Check the effective user in the distroless image:
podman inspect secure-demo:hardened --format '{{.Config.User}}'65532:65532Distroless static images may not include the id binary. Rely on podman inspect and Kubernetes securityContext.runAsNonRoot at deploy time. For Alpine-based runtimes, podman run --rm --entrypoint id secure-demo:hardened-alpine should show uid=10001.
Writable paths must exist before you switch USER. A distroless final stage has no shell for RUN mkdir or RUN chown. Prepare writable directories in an intermediate stage and copy them with the required ownership, or mount writable paths at runtime.
Set file ownership during build
Keep immutable application binaries root-owned. The runtime user needs execute permission, not ownership of the binary:
COPY --from=builder /server /server
USER 65532:65532For directories the application must write at runtime, create and assign ownership in an Alpine or builder stage:
RUN mkdir -p /var/lib/secure-demo && \
chown 10001:10001 /var/lib/secure-demoKeep binaries and configuration root-owned. Only application data, cache, socket, upload, or temporary directories should normally be owned by the runtime UID.
A normal COPY creates root-owned files unless --chown is specified; USER selects the process identity but does not require that process to own its executable.
Inspect permissions on the Alpine variant:
podman run --rm --entrypoint id secure-demo:hardened-alpineuid=10001(appuser) gid=10001(appuser) groups=10001(appuser)podman run --rm --entrypoint ls secure-demo:hardened-alpine -la /server-rwxr-xr-x 1 root root ... /serverKeep application binaries and immutable configuration root-owned and not writable by the runtime user. Assign the runtime UID ownership only on directories that the application genuinely needs to modify. Secrets should be injected at runtime rather than copied into the image.
Keep secrets out of layers
These patterns still leak credentials into image history:
COPY fake-secret.txt /tmp/secret
RUN cat /tmp/secret && rm -f /tmp/secretARG API_KEY
ENV API_KEY=${API_KEY}Create a fake secret and build a demonstration leak image. podman history shows build instructions, not file contents in lower layers — inspect archived layers with podman save instead:
printf 'demo-api-key=not-real\n' > fake-secret.txtDockerfile.secret-leak:
FROM alpine:3.21
COPY fake-secret.txt /tmp/fake-secret.txt
RUN cat /tmp/fake-secret.txt && rm -f /tmp/fake-secret.txt
CMD ["sh", "-c", "echo done"]podman build \
-f Dockerfile.secret-leak \
-t secure-demo:secret-leak .
rm -rf /tmp/secret-leak
mkdir /tmp/secret-leak
podman save \
--format docker-archive \
-o /tmp/secret-leak.tar \
secure-demo:secret-leak
tar -xf /tmp/secret-leak.tar -C /tmp/secret-leak
for layer in /tmp/secret-leak/*/layer.tar; do
tar -xOf "$layer" tmp/fake-secret.txt 2>/dev/null
donedemo-api-key=not-realpodman save preserves the image's individual layers, unlike a flattened container export. A later RUN rm does not erase data from earlier layers.
Use build-time secret mounts instead. Podman supports the same RUN --mount=type=secret workflow as BuildKit:
Dockerfile.secret-safe:
FROM alpine:3.21
RUN --mount=type=secret,id=apikey \
test -s /run/secrets/apikey && \
printf 'secret was available during build\n' > /result
CMD ["cat", "/result"]podman build \
--secret id=apikey,src=./fake-secret.txt \
-f Dockerfile.secret-safe \
-t secure-demo:secret-safe .
podman run --rm secure-demo:secret-safesecret was available during buildSecrets mounted this way do not appear as permanent layers in the pushed image. The secret mount itself is temporary and is not committed to the image. The build command must still avoid copying the secret into generated files, image metadata, logs, or command output. Secret mounts prevent the mounted secret from automatically entering a layer, but build commands can still deliberately or accidentally persist its contents.
Pin base images by digest
Tags move. Digests do not. The Dockerfile.hardened shown earlier already pins both the Rocky builder and the distroless runtime by digest.
To resolve digests on your workstation:
podman pull docker.io/rockylinux/rockylinux:10
podman inspect docker.io/rockylinux/rockylinux:10 \
--format '{{index .RepoDigests 0}}'docker.io/rockylinux/rockylinux@sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834podman pull gcr.io/distroless/static-debian12
podman inspect gcr.io/distroless/static-debian12 \
--format '{{index .RepoDigests 0}}'gcr.io/distroless/static-debian12@sha256:597c2b4bc7f353100af9b8b06bb4f126c4a45f9d8175e25d4f01f965d5d94396Alpine runtimes follow the same pattern:
podman pull docker.io/library/alpine:3.21
podman inspect docker.io/library/alpine:3.21 \
--format '{{index .RepoDigests 0}}'docker.io/library/alpine@sha256:2510911c71c2bb89162f020f825f4a8f4dcdc94d27fdef7b5d6e9f215c8c9e3aPin in any Dockerfile stage:
FROM docker.io/library/alpine:3.21@sha256:2510911c71c2bb89162f020f825f4a8f4dcdc94d27fdef7b5d6e9f215c8c9e3a| Pin style | Reproducible | Can resolve differently on next pull/build |
|---|---|---|
:latest |
No | Yes |
Version tag (:3.21) |
No | Yes |
| Digest only | Yes | No |
| Tag plus digest | Yes | No, until the digest is changed |
Kubernetes likewise recommends digest references because tags can be changed at the registry.
Automate digest bumps in CI: rebuild, retest, rescan, then promote the new digest.
Design for read-only runtime
Before you mount readOnlyRootFilesystem: true in Kubernetes, inventory every path the process writes:
| Path | Typical need |
|---|---|
/tmp |
Temporary files; mount an emptyDir |
| Application cache | Dedicated volume or emptyDir |
| Unix sockets | emptyDir or memory-backed volume |
| Upload directories | PVC or object storage, not the image layer |
| Logs | stdout/stderr or a mounted log volume |
| Persistent data | PVC only |
See immutable containers in Kubernetes for how Pod securityContext and volume mounts combine with a minimal image. The image should not assume it can write under / at runtime.
A non-root or distroless image does not automatically make the root filesystem read-only. Container root filesystems remain writable unless Podman uses --read-only or Kubernetes sets readOnlyRootFilesystem: true.
Test the hardened image with a read-only root filesystem:
podman run --rm -d \
--read-only \
--name hardened-readonly \
-p 18082:8080 \
secure-demo:hardened
curl -s http://127.0.0.1:18082/
podman rm -f hardened-readonlysecure-demo okThis static Go server needs no writable paths at runtime. Applications that write under /tmp or cache directories need mounted volumes when you enable read-only mode.
Inspect the final image
Run a structured inspection pass before you push:
podman images secure-demo:hardened --format 'size={{.Size}} id={{.ID}}'podman inspect secure-demo:hardened --format 'user={{.Config.User}} entrypoint={{.Config.Entrypoint}}'user=65532:65532 entrypoint=[/server]Count layers:
podman inspect secure-demo:hardened --format 'layers={{len .RootFS.Layers}}'Check for a shell in distroless (expect failure):
podman run --rm \
--entrypoint sh \
secure-demo:hardened \
-c 'echo inside' 2>&1 | head -1Error: runc: exec failed: unable to start container process: exec: "sh": executable file not found in $PATHScan history for accidental secret strings:
podman history --no-trunc secure-demo:hardened | head -10For Alpine runtimes you can list setuid binaries during a one-off debug build that adds findutils. Distroless runtimes rely on minimal bases that omit setuid tools by design.
Compare before and after
| Check | Bloated (secure-demo:bloated) |
Hardened (secure-demo:hardened) |
|---|---|---|
| Size | 868 MB | Single-digit MB (same app, this lab) |
| Packages / tools | gcc, git, vim, dnf, Go toolchain |
Static application binary plus minimal distroless runtime files; no shell or package manager |
| Runs as root | Yes (uid=0) |
No (65532:65532) |
| Build tools in runtime | Yes | No (builder stage only) |
| Shell | /bin/sh available |
No shell (distroless) |
| Writable paths | Entire root FS as root | Process is non-root, but root filesystem remains writable unless the runtime enables read-only mode |
| Vulnerability surface | Large OS and dev packages | Minimal; scan in CI before promote |
Run your scanner of choice in CI on both tags to quantify CVE differences. This article does not walk through scanner installation.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
no space left on device during bloated build |
Large dnf layers fill storage |
podman system prune -af; build multi-stage instead |
| Distroless container exits immediately | Wrong architecture or dynamically linked binary | Build with CGO_ENABLED=0, or choose base/cc for required dynamic libraries |
| HTTPS fails only with private endpoints | Private CA is not trusted | Add the organization's CA certificate during the image build |
| Permission denied at runtime | Runtime UID lacks access to a required file or writable directory | Keep binaries readable and executable; assign the runtime UID ownership only on directories it must modify, or mount a writable volume |
| Secret still visible in registry | Secret in COPY/ARG/ENV layer |
Rebuild with --mount=type=secret; rotate leaked credential |
| App fails with read-only root | Writes under /tmp or cache paths |
Add emptyDir mounts in Pod spec |
| Binary fails on Alpine | Binary is dynamically linked against glibc or requires a missing loader | Build a genuinely static binary with CGO_ENABLED=0, or use a glibc-compatible runtime |
What's Next
- Scan Container Images for Vulnerabilities with Trivy
- Generate and Scan Container SBOMs with Trivy
- Sign and Verify Container Images with Cosign
References
Summary
You started from an 868 MB Rocky Linux image that compiled Go as root and shipped a full developer toolchain. Multi-stage builds moved compilation into a digest-pinned Rocky builder stage and copied only a static binary into digest-pinned gcr.io/distroless/static-debian12, cutting size to single-digit megabytes and removing the shell, package manager, and compiler from runtime.
Non-root user 65532:65532, build-time secret mounts instead of layer copies, and a read-only path inventory turn that smaller image into something Kubernetes can lock down further. Inspect every tag with podman images, podman inspect, and layer extraction from podman save before push, and treat scanner results as a gate in CI rather than a one-time manual check.
When you deploy, pair the hardened image with private registry pulls and cluster policy. Scanning and signing tutorials in the CKS track build on this foundation.

