LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Container Security in 2026: The Threat Model, the Supply Chain, and a Pod That Can't Hurt You

containerssecuritydockerkubernetessupply-chaincosignseccomppod-securitytrivyrootless

A virtual machine has its own kernel and a hardware-enforced boundary; if a process escapes the guest OS it still has to defeat the hypervisor. A container has none of that. Every container on a host shares the host’s single Linux kernel, and “isolation” is just a set of kernel features — namespaces, cgroups, capabilities — that the kernel can be talked out of enforcing. That is the one fact the rest of container security follows from: a default container is not a security boundary, it is a packaging format with some isolation bolted on. The good news is that with a dozen specific settings you can make a container behave like a real boundary. The bad news is that almost nobody applies them, and the defaults are permissive. This is the practical version of getting it right.


The Threat Model: What Actually Goes Wrong

Container compromise happens at four layers, and a real defense addresses all of them rather than just scanning images and calling it done:

  1. The supply chain. The image you run was built from a base image, dozens of OS packages, and your application’s dependencies — any of which can carry a known CVE or, worse, a deliberately malicious package. Most real-world container incidents start here, not with an exotic kernel exploit.
  2. The image contents. A bloated image ships a shell, a package manager, curl, and compilers — tools an attacker uses for the second stage of an intrusion. Every binary you don’t include is one they can’t use.
  3. Runtime privilege. A container running as root, with the full Linux capability set, a writable root filesystem, and no seccomp profile, is one application bug away from being a foothold on the host. This is where a web-app RCE becomes a host compromise.
  4. The orchestrator. In Kubernetes, a permissive securityContext, a missing NetworkPolicy, an over-scoped ServiceAccount, or a hostPath mount turns a single compromised pod into lateral movement across the cluster.

The throughline: defense in depth. Assume the application will be compromised, and make sure that the blast radius stops at the container. Everything below is about shrinking that radius.


Image Security: Minimal, Pinned, Scanned

Start from the smallest base you can

The original advice — “use Alpine instead of Ubuntu” — is half-right and now dated. Alpine is small but ships a shell and apk, and its musl libc occasionally causes subtle runtime bugs. The 2026 default for a security-conscious image is distroless (Google’s gcr.io/distroless) or a Chainguard / Wolfi image: these contain your app, its runtime, and CA certificates — and nothing else. No shell, no package manager, no curl. An attacker who lands an RCE in a distroless container finds no tools to pivot with.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Multi-stage build: compile in a full image, ship in a tiny one.
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# Distroless final stage: no shell, no package manager, runs as nonroot.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Pin by digest, not by tag

FROM alpine:3.19 is a moving target — and a stale one; the current Alpine is well past 3.19. Worse, a tag like :latest or even :3.21 can be re-pushed, so the image you scanned is not guaranteed to be the image you deploy. Pin the digest for reproducibility and tamper-evidence, and bump it deliberately:

1
2
# Tag for humans, digest for the machine. This exact bytes-on-disk image, always.
FROM alpine:3.22@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1

The trade-off is real: pinned digests don’t auto-update, so you need automation (Renovate, Dependabot) to open PRs when the base image gets security fixes. A pinned-but-never-updated image is worse than a floating tag — it freezes the CVEs in place. Pin for reproducibility; automate the bumps.

Scan, and fail the build on what matters

Scan every image in CI, and make critical findings block the merge — see CI/CD pipelines for where this gate lives.

1
2
3
4
5
6
7
8
# Trivy: fail the build on fixable HIGH/CRITICAL CVEs
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:1.4.2

# Generate an SBOM (software bill of materials) for provenance and audit
syft myapp:1.4.2 -o spdx-json > sbom.spdx.json

# Grype reads the SBOM directly
grype sbom:./sbom.spdx.json

--ignore-unfixed is the pragmatic flag: blocking on CVEs that have no available patch just teaches your team to bypass the scanner. Gate on what someone can actually fix.


The Supply Chain: Sign It, Verify It, Admit Only What You Trust

Scanning tells you what’s in an image. It does not tell you the image running in production is the one your pipeline built. Closing that gap is the supply-chain story, and in 2026 it is table stakes, not paranoia.

1
2
3
4
5
6
7
8
# Sign an image with cosign (keyless, via OIDC — no long-lived keys to leak)
COSIGN_EXPERIMENTAL=1 cosign sign myregistry.io/myapp@sha256:...

# Attach the SBOM as a signed attestation
cosign attest --predicate sbom.spdx.json --type spdxjson myregistry.io/myapp@sha256:...

# Verify at deploy time
cosign verify --certificate-identity-regexp '.*' myregistry.io/myapp@sha256:...

The verification has to be enforced, not advisory. In Kubernetes that means an admission controller — Kyverno or OPA Gatekeeper — that rejects any pod whose image isn’t signed by your pipeline’s identity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-signature
      match:
        any:
          - resources: { kinds: [Pod] }
      verifyImages:
        - imageReferences: ["myregistry.io/*"]
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/myorg/*"
                    issuer: "https://token.actions.githubusercontent.com"

This is what turns “we sign our images” into “an unsigned image physically cannot run here.” Without enforcement, signing is a checkbox that an attacker simply ignores.


Runtime Hardening: Drop Everything, Then Add Back

This is the highest-leverage and most-skipped layer. A container starts with root, ~14 Linux capabilities, a writable filesystem, and the runtime’s default seccomp profile. Strip all of it.

  Default container                  Hardened container
  ----------------                   ------------------
  user: root (uid 0)        ->       user: 10001 (non-root)
  capabilities: ~14 default ->       capabilities: drop ALL
  privilege escalation: yes ->       no-new-privileges: true
  filesystem: read-write    ->       read-only root + tmpfs for /tmp
  seccomp: unconfined/RT    ->       RuntimeDefault (or custom)
  /var/run/docker.sock      ->       never mounted
Control What it stops Docker flag / Compose
Non-root user Root-on-host if the kernel boundary is bypassed USER 10001 in Dockerfile
--cap-drop ALL Raw sockets, mount, ptrace, module loading cap_drop: [ALL]
--security-opt no-new-privileges setuid binaries escalating mid-exploit security_opt: ["no-new-privileges:true"]
Read-only root FS Attacker writing tools/persistence to disk read_only: true + tmpfs
seccomp RuntimeDefault ~44 dangerous syscalls (keyctl, ptrace, etc.) on by default; don’t set unconfined
Resource limits A compromised or runaway container DoSing the host mem_limit, cpus
No privileged: true Full host access — this disables all isolation never set it
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# docker-compose.yml — a hardened service
services:
  app:
    image: myregistry.io/myapp@sha256:...
    read_only: true
    tmpfs: [/tmp]
    user: "10001:10001"
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    mem_limit: 512m
    cpus: 0.5

Two rules carry most of the weight here. Never mount the Docker socket (/var/run/docker.sock) into a container — it is root on the host, full stop; any container with it can start a privileged container and own the machine. And never run --privileged unless you genuinely understand that it disables namespacing, capability dropping, and device restrictions all at once. For an even stronger boundary on multi-tenant or hostile workloads, run rootless Podman/Docker (the daemon itself runs as a non-root user) or a sandboxed runtime like gVisor or Kata Containers, which give each container its own kernel surface at a modest performance cost.


Kubernetes: Pod Security That Actually Holds

Everything above maps to a Kubernetes securityContext, and Kubernetes adds the Pod Security Standards — three profiles (privileged, baseline, restricted) enforced by a built-in admission controller via a namespace label. Set the namespace to restricted and the API server rejects pods that don’t comply, so the guarantee doesn’t depend on every developer remembering the YAML:

1
2
3
4
5
6
7
apiVersion: v1
kind: Namespace
metadata:
  name: prod
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest

A pod that passes restricted looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: apps/v1
kind: Deployment
metadata: { name: app, namespace: prod }
spec:
  template:
    spec:
      automountServiceAccountToken: false   # don't hand out an API token by default
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: app
          image: myregistry.io/myapp@sha256:...
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          resources:
            limits: { cpu: "500m", memory: "512Mi" }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: {}

Then close the network. By default every pod in a cluster can talk to every other pod; a default-deny NetworkPolicy turns that off so a compromised pod can’t scan and pivot:

1
2
3
4
5
6
7
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: prod }
spec:
  podSelector: {}            # all pods in the namespace
  policyTypes: [Ingress, Egress]
  # No ingress/egress rules == deny all. Add explicit allows on top.

The other Kubernetes-specific traps: never mount hostPath, never set hostNetwork/hostPID/hostIPC (they erase the namespace boundary you’re paying for), and scope every ServiceAccount with least-privilege RBAC instead of leaving the default token mounted. Most of these are the same homelab-to-prod hardening steps covered in Kubernetes for the homelab; they just matter more when the cluster is multi-tenant.


Secrets and Build-Time Credentials

Secrets must never bake into an image — image layers are cached, shared, and trivially docker history-inspected, so a secret in a RUN step or ENV line is a secret published to anyone who pulls the image. Use BuildKit’s secret mounts so the credential is available during the build but never written to a layer:

1
2
3
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci

At runtime, prefer mounted secret files over environment variables — env vars leak into child processes, crash dumps, and /proc/<pid>/environ. For anything beyond a single host, back the secrets with an external manager (Vault, cloud secret stores, or the External Secrets Operator on Kubernetes) rather than checking encrypted blobs into git. The full treatment is in secrets management; the container-specific rule is simply: the image is public, the env is semi-public, a mounted file from a real secret store is the floor.


Detection: Assume Something Gets Through

Prevention is never complete, so the last layer is runtime detection. Falco (a CNCF project) watches syscalls and flags container behavior that shouldn’t happen — a shell spawned inside a distroless container, a write to /etc, an outbound connection from a pod that should only receive traffic, an attempt to read /proc of another process. Those are exactly the signals of a post-exploitation stage:

1
2
3
4
5
6
# A Falco rule: a shell in a container is almost always an intrusion
- rule: Terminal shell in container
  desc: A shell was spawned in a container
  condition: spawned_process and container and shell_procs and proc.tty != 0
  output: "Shell in container (user=%user.name container=%container.name cmd=%proc.cmdline)"
  priority: WARNING

Pair detection with the basics that make an incident investigable: centralized logs, immutable audit trails, and image provenance you can actually trace back through the SBOM and signatures you generated above.


Verdict

The single idea to hold onto is that a container is not a security boundary until you make it one. The kernel is shared, the defaults are permissive, and “it runs in a container” buys you essentially nothing against a determined attacker out of the box. What buys you safety is a short, specific list applied at every layer: build from distroless or Wolfi, pin by digest and automate the bumps, scan and gate in CI on fixable CVEs, sign with cosign and enforce verification with an admission controller, and at runtime drop all capabilities, run non-root, mount the root filesystem read-only, keep seccomp on, and never mount the Docker socket or run privileged. On Kubernetes, label your namespaces restricted, write the matching securityContext, and default-deny the network.

None of this is exotic, and none of it requires a commercial platform — it is a dozen settings and three CLI tools. The reason container breaches keep happening is not that the controls are hard; it is that the defaults are loose and the controls are optional, so teams ship the defaults. Treat the hardened configuration as the starting template for every service, gate it in CI so it can’t regress, and add Falco so you find out when something slips through anyway. Defense in depth is the whole game: assume the app gets popped, and make sure the blast radius stops at the container.


Sources

Comments