Skip to content
docktionary

Docker

Runtime hardening

A non-root USER (see Dockerfile reference) stops a process from running as root inside the container. It doesn't touch everything else a container can still do by default -- write anywhere in its own filesystem, hold onto a specific set of Linux capabilities, or escalate privileges through a setuid binary.

What's actually on by default

Docker isn’t wide open out of the box -- it's worth being precise about what it already restricts, so the hardening below reads as tightening a floor, not starting from nothing:

  • Capabilities. A container gets 14 of Linux's roughly 40 capabilities by default (CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, AUDIT_WRITE) -- not all of them, but still more than most applications ever use.
  • Seccomp. Docker's default seccomp profile is already applied to every container automatically, and it blocks around 44 syscalls out of 300+ -- things like mount, reboot, ptrace, and bpf that an application has no legitimate reason to call.
  • No device access, no host namespaces. Without --privileged or specific --device/--cap-add flags, a container can’t touch host devices or escape its own namespaces.

The gap is that this default set is sized for "a container that might need to do anything," not for the one specific app actually running in it -- most web services use none of those 14 capabilities, and locking that down is what the rest of this page is about.

Dropping capabilities

Drop everything, then add back only what this specific container actually needs -- usually nothing at all for a typical web app:

terminal
docker run --cap-drop=ALL docktionary/app:1.4.0

NET_BIND_SERVICE is the one most apps genuinely hit -- it's required to bind a port below 1024. Listening on 8080 instead of 80 inside the container (and mapping -p 80:8080 at the Docker level) avoids needing it at all; add it back explicitly only if the process really has to bind a privileged port itself:

terminal
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE docktionary/app:1.4.0

Read-only root filesystem

Most of a running container never needs to write to its own filesystem at all -- the application code, the base image, and most dependencies are read-only by nature. --read-only enforces that; a tmpfs mount covers the few paths (temp files, a cache directory) that genuinely need to be writable, without making the whole filesystem mutable just for those.

terminal
docker run --read-only --tmpfs /tmp docktionary/app:1.4.0

A container that tries to write somewhere else under this will fail loudly and immediately -- which is itself useful: it surfaces a write path you didn’t know the application depended on, before that write path becomes something an attacker can abuse instead.

no-new-privileges

A setuid or setgid binary inside the container can normally escalate to the privileges of the binary's owner when executed, regardless of what capabilities the container was started with. no-new-privileges disables that escalation path entirely, for every process in the container.

terminal
docker run --security-opt=no-new-privileges docktionary/app:1.4.0

Seccomp and AppArmor profiles

The default seccomp profile covers a lot, but a custom, stricter profile -- built by tracing exactly which syscalls a specific application actually makes -- can go further by denying everything else:

terminal
docker run --security-opt seccomp=./app-profile.json docktionary/app:1.4.0

# Only to confirm a profile is the cause of an unexpected failure --
# never leave a container running with seccomp fully disabled.
docker run --security-opt seccomp=unconfined docktionary/app:1.4.0

AppArmor is the Linux Security Module equivalent on AppArmor-enabled distros (Ubuntu, Debian) -- Docker applies its own default profile automatically there too, with the same idea of a custom profile for anything that needs tighter rules. It's less portable than seccomp (no effect at all on a non-AppArmor host like most RHEL/Fedora-based images, which use SELinux instead), which is why seccomp is usually the one worth customizing first.

--privileged: what it actually removes

--privileged isn’t "more capabilities" -- it removes the isolation this whole page is about, all at once: every capability, access to every host device, and seccomp and AppArmor both disabled. It exists for cases that genuinely need it (running Docker-in-Docker, certain hardware access), not as a shortcut past a permissions error whose actual cause hasn’t been found yet.

Putting it together

terminal
docker run \
  --cap-drop=ALL \
  --read-only \
  --tmpfs /tmp \
  --security-opt=no-new-privileges \
  -p 8080:8080 \
  docktionary/app:1.4.0

The same flags, for a service already defined in a compose file:

docker-compose.yml
services:
  web:
    image: docktionary/app:1.4.0
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    ports:
      - "8080:8080"

None of this replaces a non-root USER in the Dockerfile itself, or checking who actually published the image you're running these flags against in the first place -- it's what you add once both of those are already true.

On Kubernetes, these same ideas aren’t flags you set per container -- they're enforced namespace-wide by Pod Security Standards, so a manifest that skips them gets rejected rather than just running unhardened.