Build Secure Minimal Container Images

Build secure minimal container images with multi-stage Dockerfiles, distroless bases, non-root users, digest pins, secret mounts, and runtime checks.

Published

Updated

Read time 13 min read

Reviewed byDeepak Prasad

Secure minimal container image built with multi-stage Dockerfile, non-root user, and distroless runtime base
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.

IMPORTANT
This guide hardens what goes inside the image. It does not replace cluster controls such as NetworkPolicy, admission policy, or runtime scanning. Pair a minimal image with Kubernetes mount and user settings when you deploy.

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:

bash
mkdir -p ~/secure-img-lab && cd ~/secure-img-lab

Add a build-context ignore file so Git metadata, environment files, keys, and build output never enter the image:

text
# .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:

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)
}
Output

Dockerfile.bloated installs build tools and compiles inside the same stage that runs in production:

dockerfile
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:

bash
podman build -f Dockerfile.bloated -t secure-demo:bloated .
output
Successfully tagged localhost/secure-demo:bloated

Check image size:

bash
podman images secure-demo:bloated --format '{{.Size}}'
output
868 MB

The 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:

bash
podman run --rm secure-demo:bloated id
output
uid=0(root) gid=0(root) groups=0(root)
bash
podman run --rm secure-demo:bloated sh -c 'echo has_shell'
output
has_shell

Start the server and hit it from the host:

bash
podman run --rm -d --name bloated-test -p 18080:8080 secure-demo:bloated
bash
curl -s http://127.0.0.1:18080/
output
secure-demo ok

Stop the test container when you finish inspecting:

bash
podman rm -f bloated-test

That 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:

dockerfile
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:

bash
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:

bash
podman build -f Dockerfile.hardened -t secure-demo:hardened .
output
Successfully tagged localhost/secure-demo:hardened

Compare sizes on the same workstation:

bash
podman images \
  --filter 'reference=localhost/secure-demo:.*' \
  --format 'table {{.Tag}}\t{{.Size}}'
output
TAG         SIZE
bloated     868 MB
hardened    2.44 MB

On 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:

bash
podman run --rm \
  --entrypoint sh \
  secure-demo:hardened \
  -c 'echo inside' 2>&1
output
Error: runc: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Exact 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:

bash
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-test
output
secure-demo ok

No 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:

dockerfile
RUN dnf install -y golang && dnf clean all

Avoid 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:

dockerfile
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:

bash
podman build \
  -f Dockerfile.hardened-alpine \
  -t secure-demo:hardened-alpine .

Check the effective user in the distroless image:

bash
podman inspect secure-demo:hardened --format '{{.Config.User}}'
output
65532:65532

Distroless 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:

dockerfile
COPY --from=builder /server /server
USER 65532:65532

For directories the application must write at runtime, create and assign ownership in an Alpine or builder stage:

dockerfile
RUN mkdir -p /var/lib/secure-demo && \
    chown 10001:10001 /var/lib/secure-demo

Keep 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:

bash
podman run --rm --entrypoint id secure-demo:hardened-alpine
output
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
bash
podman run --rm --entrypoint ls secure-demo:hardened-alpine -la /server
output
-rwxr-xr-x    1 root     root         ... /server

Keep 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:

dockerfile
COPY fake-secret.txt /tmp/secret
RUN cat /tmp/secret && rm -f /tmp/secret
dockerfile
ARG 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:

bash
printf 'demo-api-key=not-real\n' > fake-secret.txt

Dockerfile.secret-leak:

dockerfile
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"]
bash
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
done
output
demo-api-key=not-real

podman 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:

dockerfile
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"]
bash
podman build \
  --secret id=apikey,src=./fake-secret.txt \
  -f Dockerfile.secret-safe \
  -t secure-demo:secret-safe .

podman run --rm secure-demo:secret-safe
output
secret was available during build

Secrets 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:

bash
podman pull docker.io/rockylinux/rockylinux:10
podman inspect docker.io/rockylinux/rockylinux:10 \
  --format '{{index .RepoDigests 0}}'
output
docker.io/rockylinux/rockylinux@sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834
bash
podman pull gcr.io/distroless/static-debian12
podman inspect gcr.io/distroless/static-debian12 \
  --format '{{index .RepoDigests 0}}'
output
gcr.io/distroless/static-debian12@sha256:597c2b4bc7f353100af9b8b06bb4f126c4a45f9d8175e25d4f01f965d5d94396

Alpine runtimes follow the same pattern:

bash
podman pull docker.io/library/alpine:3.21
podman inspect docker.io/library/alpine:3.21 \
  --format '{{index .RepoDigests 0}}'
output
docker.io/library/alpine@sha256:2510911c71c2bb89162f020f825f4a8f4dcdc94d27fdef7b5d6e9f215c8c9e3a

Pin in any Dockerfile stage:

dockerfile
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:

bash
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-readonly
output
secure-demo ok

This 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:

bash
podman images secure-demo:hardened --format 'size={{.Size}} id={{.ID}}'
bash
podman inspect secure-demo:hardened --format 'user={{.Config.User}} entrypoint={{.Config.Entrypoint}}'
output
user=65532:65532 entrypoint=[/server]

Count layers:

bash
podman inspect secure-demo:hardened --format 'layers={{len .RootFS.Layers}}'

Check for a shell in distroless (expect failure):

bash
podman run --rm \
  --entrypoint sh \
  secure-demo:hardened \
  -c 'echo inside' 2>&1 | head -1
output
Error: runc: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Scan history for accidental secret strings:

bash
podman history --no-trunc secure-demo:hardened | head -10

For 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


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.


Frequently Asked Questions

1. What is the difference between a minimal base and a distroless image?

A minimal distribution such as Alpine still ships a package manager and shell unless you remove them. Distroless images contain only the runtime files your application needs, often without a shell or package manager, which shrinks attack surface but makes debugging harder.

2. Does deleting a secret file in a Dockerfile remove it from the image?

No. Each RUN and COPY creates an immutable layer. A later RUN rm does not erase data from earlier layers; anyone with the image can extract the secret from an earlier image layer. Use build secret mounts and never bake credentials into layers.

3. Should production images run as root?

Avoid root unless the application truly requires it. Create a dedicated numeric UID and GID, keep immutable application files root-owned, grant the runtime user ownership only of paths it must modify, and declare USER in the final stage. Kubernetes can enforce runAsNonRoot, but the image should already support an unprivileged user.

4. Why pin base images by digest instead of tag?

Tags are mutable pointers. A digest pins the exact layer chain you tested. Pair digest pins with an automated update process that rebuilds and rescans when maintainers publish new bases.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)