{"id":356,"date":"2026-10-29T06:38:00","date_gmt":"2026-10-29T13:38:00","guid":{"rendered":"https:\/\/blog.canutethegreat.com\/?p=356"},"modified":"2026-10-29T06:38:00","modified_gmt":"2026-10-29T13:38:00","slug":"container-image-hardening-distroless-non-root-read-only","status":"publish","type":"post","link":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/29\/container-image-hardening-distroless-non-root-read-only\/","title":{"rendered":"Container image hardening: minimal images, non-root, read-only filesystems"},"content":{"rendered":"<p>Most container images in production are a full Linux distribution wearing a trench coat. A Debian or Ubuntu base, a package manager, a shell, curl, and an application running as root with a writable filesystem. It works, it&#8217;s easy to debug, and it hands an attacker who gets code execution everything they need for the next step.<\/p>\n<p>Hardening an image isn&#8217;t one big change. It&#8217;s a handful of small ones that each remove a capability the application never needed, and that an attacker would very much like to have. None of them are new, and every one of them gets skipped all the time.<\/p>\n<h2>Start with less: minimal and distroless bases<\/h2>\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/container-image-hardening-distroless-non-root-read-only-diagram.png\" alt=\"Side-by-side comparison of a typical container image and a hardened one: distroless base, no shell, non-root UID 65532, read-only root with noexec tmpfs, all capabilities dropped, no privilege escalation, seccomp RuntimeDefault and pinned digests, with the attacker capability each change removes\" \/><\/figure>\n<p>The fastest win is shipping fewer things. A typical <code>debian<\/code> or <code>ubuntu<\/code> base pulls in a hundred-odd packages, and your scanner will report CVEs against all of them, whether or not your app ever touches them. That noise has a real cost, which I covered in the <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/26\/prioritising-vulnerabilities-cvss-epss-kev\/\">vulnerability management series<\/a> earlier this week. Every finding in a package you don&#8217;t use is triage time you&#8217;re not spending on one that matters.<\/p>\n<p>There are a few levels of minimal:<\/p>\n<ul>\n<li><code>scratch<\/code> is empty. Good for fully static binaries, like Go built with <code>CGO_ENABLED=0<\/code>. You have to bring your own CA certificates and timezone data if you need them.<\/li>\n<li>Google&#8217;s distroless images contain the runtime pieces an app needs (libc, CA certs, tzdata, sometimes a language runtime) and no shell or package manager.<\/li>\n<li>Wolfi-based images from Chainguard and minimal Alpine images sit somewhere in between, with a package manager available at build time.<\/li>\n<\/ul>\n<p>For a Go service, a multi-stage build onto distroless looks like this:<\/p>\n<pre class=\"wp-block-code\"><code>FROM golang:1.25 AS build\nWORKDIR \/src\nCOPY go.mod go.sum .\/\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -trimpath -ldflags=\"-s -w\" -o \/out\/app .\/cmd\/app\n\nFROM gcr.io\/distroless\/static-debian12:nonroot\nCOPY --from=build \/out\/app \/app\nUSER 65532:65532\nENTRYPOINT [\"\/app\"]<\/code><\/pre>\n<p>The compiler, the source and the module cache stay in the build stage. The final image is your binary plus a few megabytes of support files.<\/p>\n<p>The objection I always hear is debugging. &#8220;How do I exec into it without a shell?&#8221; On Kubernetes you don&#8217;t need to: an ephemeral debug container shares the target&#8217;s process namespace and brings its own tools.<\/p>\n<pre class=\"wp-block-code\"><code>kubectl debug -it pod\/payments-api-7d9f8c --image=busybox:1.36 --target=app<\/code><\/pre>\n<p>That&#8217;s better than a shell baked into every image anyway, because the tools only exist while someone is actually debugging.<\/p>\n<h2>Don&#8217;t run as root<\/h2>\n<p>Containers running as UID 0 are the default, and it&#8217;s a bad default. Root inside a container isn&#8217;t the same as root on the host, since namespaces and dropped capabilities limit it, but it&#8217;s much closer than people assume, and it turns every container runtime bug into a bigger problem.<\/p>\n<p>CVE-2019-5736 is the classic example. A malicious process in a container could overwrite the host&#8217;s <code>runc<\/code> binary and get root on the host the next time runc ran. The exploit needed root inside the container. A container running as an unprivileged user wasn&#8217;t exploitable that way.<\/p>\n<p>Set the user in the image, with a numeric UID so Kubernetes can verify it:<\/p>\n<pre class=\"wp-block-code\"><code>USER 65532:65532<\/code><\/pre>\n<p>Then enforce it at the platform level so an image that forgets doesn&#8217;t get scheduled. In Kubernetes that&#8217;s <code>runAsNonRoot: true<\/code> in the security context, which makes the kubelet refuse to start a container whose user resolves to 0.<\/p>\n<p>If the app needs to bind to port 80 or 443, don&#8217;t give it root. Bind to 8080 and let the Service or the load balancer map the port, or add only <code>NET_BIND_SERVICE<\/code>.<\/p>\n<h2>Make the root filesystem read-only<\/h2>\n<p>An attacker with code execution usually wants to write something: a downloaded tool, a modified config, a web shell, a cron entry. A read-only root filesystem takes that away, and it catches a surprising number of bugs where an app was quietly writing logs or caches into its own image.<\/p>\n<p>Most apps need somewhere to write, usually <code>\/tmp<\/code> or a cache directory. Give them exactly that as a tmpfs or an <code>emptyDir<\/code> volume and nothing else. With plain Docker:<\/p>\n<pre class=\"wp-block-code\"><code>docker run --read-only \\\n  --tmpfs \/tmp:rw,noexec,nosuid,size=64m \\\n  --cap-drop=ALL \\\n  --security-opt=no-new-privileges:true \\\n  --user 65532:65532 \\\n  registry.example.com\/payments-api@sha256:&lt;digest&gt;<\/code><\/pre>\n<p>The <code>noexec<\/code> on the tmpfs matters. A writable directory that can also execute binaries is half of what an attacker needed from a writable root.<\/p>\n<h2>The Kubernetes security context<\/h2>\n<p>All of this comes together in the pod spec. This is the block I start every deployment from:<\/p>\n<pre class=\"wp-block-code\"><code>securityContext:\n  runAsNonRoot: true\n  runAsUser: 65532\n  runAsGroup: 65532\n  seccompProfile:\n    type: RuntimeDefault\ncontainers:\n  - name: app\n    image: registry.example.com\/payments-api@sha256:&lt;digest&gt;\n    securityContext:\n      allowPrivilegeEscalation: false\n      readOnlyRootFilesystem: true\n      capabilities:\n        drop: [\"ALL\"]\n    volumeMounts:\n      - name: tmp\n        mountPath: \/tmp\nvolumes:\n  - name: tmp\n    emptyDir:\n      medium: Memory\n      sizeLimit: 64Mi<\/code><\/pre>\n<p><code>allowPrivilegeEscalation: false<\/code> sets the kernel&#8217;s <code>no_new_privs<\/code> flag, so setuid binaries can&#8217;t raise privileges. Dropping all capabilities removes things like <code>CAP_NET_RAW<\/code>, which some runtimes still grant by default and which lets a process craft raw packets. <code>RuntimeDefault<\/code> seccomp blocks a set of syscalls that normal apps never make.<\/p>\n<p>Kubernetes Pod Security Admission&#8217;s <code>restricted<\/code> profile enforces most of this for you: non-root, no privilege escalation, all capabilities dropped and a seccomp profile. It doesn&#8217;t require a read-only root filesystem, so that one you have to enforce with your own policy (Kyverno, Gatekeeper or a ValidatingAdmissionPolicy). Turn <code>restricted<\/code> on in warn mode for a namespace first, see what breaks, and fix those workloads before switching to enforce.<\/p>\n<h2>Pin what you ship, rebuild what you pin<\/h2>\n<p>Reference images by digest in deployment manifests, not by tag. A tag is a mutable pointer, and <code>:1.42.0<\/code> can be repushed. The digest is a hash of the content and can&#8217;t.<\/p>\n<p>Pinning has a cost, though. A pinned base image never gets its security fixes unless you rebuild. Pair digest pinning with an automated job that rebuilds images on a schedule against fresh bases and opens the PR to bump the digest. Pinning without rebuilding is how you end up running a two-year-old OpenSSL with perfect reproducibility.<\/p>\n<h2>What this doesn&#8217;t fix<\/h2>\n<p>None of this stops an exploit in your own code. A read-only, non-root, distroless container with a SQL injection bug still has a SQL injection bug, and the attacker still reaches whatever the app&#8217;s database credentials can reach. Container runtime escapes still happen too. CVE-2024-21626, the runc file descriptor leak fixed in runc 1.1.12, allowed escape through a crafted working directory, and the fix was patching runc, not anything in the image.<\/p>\n<p>What hardening does is make the second step expensive. The attacker lands with no shell, no package manager, no writable path that can execute, no root and no extra capabilities. I&#8217;ve read enough incident reports to know that &#8220;the attacker had a foothold but couldn&#8217;t do much with it&#8221; is one of the better sentences you can have in one.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Most container images in production are a full Linux distribution wearing a trench coat. A Debian or Ubuntu base, a package manager, a shell, curl, and an application running as root with a writable filesystem. It works, it&#8217;s easy to debug, and it hands an attacker who gets code execution everything they need for the [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":358,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[22,21],"tags":[79,83,274,81,275],"class_list":["post-356","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux","category-security","tag-containers","tag-container-security","tag-distroless","tag-docker","tag-kubernetes"],"_links":{"self":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/356","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/comments?post=356"}],"version-history":[{"count":1,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/356\/revisions"}],"predecessor-version":[{"id":488,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/356\/revisions\/488"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media\/358"}],"wp:attachment":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media?parent=356"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/categories?post=356"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/tags?post=356"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}