Lesson 12 of 30 advanced 5 min read

Before this:Container networking & volumes

Container security

Key takeaways A container is only as safe as the access you give it. Run as a non-root user, not root. Drop all Linux capabilities and add back only the few you truly need. Make the root filesystem read-only where you can. Keep secrets out of the image. And scan images for known vulnerabilities. GopherTrunk’s compose file is a working example of every one of these.

Networking & volumes gave a container reach; this lesson takes reach away until only what’s needed remains. The principle is least privilege: give a container exactly the access it needs and nothing more — the same instinct as the systemd hardening on the bare-metal side, and it connects straight to hardening systems in the security module.

Don’t run as root

By default the process inside a container runs as root. If the app is compromised or escapes its isolation, that root carries over to mounted volumes and, in bad configurations, toward the host. The fix is one line in the Dockerfile — create an unprivileged user and switch to it, which GopherTrunk does:

RUN useradd --system --create-home --shell /usr/sbin/nologin gopher
USER gopher

Everything after USER gopher runs as an ordinary user with no special powers. A break-in is now confined to what gopher can touch. This is the highest-value step for the least effort — always do it.

Drop capabilities to the bare minimum

Linux breaks root’s power into individual capabilities: NET_BIND_SERVICE to bind low ports, CHOWN to change ownership, SYS_MODULE to load kernel modules, and dozens more. A container gets a default set it almost never fully uses. Drop them all, then add back only what’s required. GopherTrunk’s compose file:

cap_drop:
  - ALL
cap_add:
  - DAC_OVERRIDE     # only to let the non-root user open the USB device node

That’s least privilege made concrete: every capability removed, exactly one added back, with a comment saying why. If you can’t name why a capability is on the list, it shouldn’t be.

Make the filesystem read-only

An app that never needs to write to its own program files shouldn’t be able to. A read-only root filesystem stops an attacker from dropping a payload into the container’s filesystem:

services:
  app:
    read_only: true
    tmpfs:
      - /tmp                     # writable scratch, if the app needs it
    volumes:
      - ./recordings:/var/lib/gophertrunk/recordings   # the one path that must persist

The app can still write to its explicit volumes and any tmpfs scratch space — everything else is frozen. It’s the container mirror of systemd’s ProtectSystem=strict.

Keep secrets out of the image

A secret baked into an image — an API token, a password — is baked into every layer forever, readable by anyone who pulls it, even if a later layer deletes it. Never COPY a secrets file or hard-code a token in a Dockerfile. Inject secrets at runtime instead — an environment variable, a mounted file, or a secret store — so they live only in the running container, not the shipped artifact.

Scan images and know what you ship

Even a minimal image contains packages that grow vulnerabilities over time. An image scanner flags known CVEs so you patch before an attacker exploits them:

trivy image gophertrunk:latest       # or: docker scout cves gophertrunk:latest

Run it in CI and on a schedule — a base image that’s fine today picks up a CVE tomorrow. The smaller your optimized image, the shorter this report, which is why small and secure go together.

The layers of container security

scanned image (known-good packages) non-root USER cap_drop ALL + minimal cap_add read-only fs, no baked secrets
Defense in depth: each layer removes power, so a break-in at one level still hits a wall at the next.

Quick check: what does GopherTrunk's cap_drop: ALL plus cap_add: DAC_OVERRIDE achieve?

Recap

  • Run as a non-root user (USER gopher) — the highest-value, lowest-cost step.
  • cap_drop: ALL then add back only what’s needed (DAC_OVERRIDE for GopherTrunk).
  • Use a read-only root filesystem with explicit writable volumes and tmpfs.
  • Never bake secrets into an image; inject them at runtime.
  • Scan images for CVEs in CI and on a schedule; smaller images have less to scan.

Next up: turning a plain binary into a managed service with systemd.

Frequently asked questions

Why shouldn't containers run as root?

By default a container process runs as root inside the container, and a container escape or a compromised app then has root-level power over mounted volumes and, in some misconfigurations, the host. Running as an unprivileged user means a break-in is confined to what that user can touch. It’s the single highest-value container hardening step and costs almost nothing.

What does cap_drop ALL do?

Linux splits root’s power into fine-grained capabilities — binding low ports, changing file ownership, loading kernel modules, and so on. cap_drop ALL removes every one of them, then cap_add grants back only the specific few the app genuinely needs. GopherTrunk drops ALL and adds back only DAC_OVERRIDE, the one capability its non-root user needs to open the USB device node.