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, andbpfthat an application has no legitimate reason to call. - No device access, no host namespaces. Without
--privilegedor specific--device/--cap-addflags, 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:
docker run --cap-drop=ALL docktionary/app:1.4.0NET_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:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE docktionary/app:1.4.0Read-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.
docker run --read-only --tmpfs /tmp docktionary/app:1.4.0A 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.
docker run --security-opt=no-new-privileges docktionary/app:1.4.0Seccomp 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:
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.0AppArmor 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
docker run \
--cap-drop=ALL \
--read-only \
--tmpfs /tmp \
--security-opt=no-new-privileges \
-p 8080:8080 \
docktionary/app:1.4.0The same flags, for a service already defined in a compose file:
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.