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
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: ALLthen add back only what’s needed (DAC_OVERRIDEfor 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.