How to Harden Docker Containers From the Command Line
Harden Docker containers from the command line: non-root user, read-only filesystem, dropped capabilities, no new privileges and resource limits.

By default a container runs as root, can write anywhere in its filesystem and holds a dozen Linux capabilities. For most applications, none of that is needed, and each unneeded permission is something an attacker who breaks into the application can use. Removing them costs a handful of flags. This post builds up a hardened docker run and the Compose version. It extends read-only filesystems and dropped capabilities and running as non-root. The commands assume Docker Engine 27 on Debian 13.
The hardened command
docker run -d --name web \
--user 10001:10001 \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
--pids-limit 200 --memory 256m --cpus 1 \
-p 127.0.0.1:8080:8080 \
registry.example.org/web:1.4.0Each flag removes a class of problem. A non-root user means an exploit does not begin as root inside the container, which also limits what a container escape could do. A read-only root filesystem stops the process, or an attacker, from modifying binaries or dropping tools; the tmpfs gives it scratch space that is in memory and cannot execute. Dropping all capabilities removes the privileges such as CHOWN, NET_RAW and SETUID that Docker grants by default. no-new-privileges keeps setuid programs from gaining more. The limits stop a runaway process from taking down the host. Publishing on loopback follows safe port publishing.
Find what the application needs
docker run --rm --read-only --cap-drop ALL registry.example.org/web:1.4.0
docker logs web 2>&1 | grep -iE 'read-only|permission denied|operation not permitted'Start strict and add back what fails. A "read-only file system" error names a path to give a tmpfs or a volume. "Operation not permitted" on a port below 1024 needs NET_BIND_SERVICE, or better, a service that listens on a high port. Few applications need any capability at all, and none needs --privileged. The base image must run as a numeric non-root user, set with USER in the Dockerfile; ownership of data directories must match.
The Compose version
services:
web:
image: registry.example.org/web:1.4.0
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
pids_limit: 200
mem_limit: 256m
ports:
- "127.0.0.1:8080:8080"What never to do
--privilegedgives the container nearly all host capabilities and device access. It is a root shell on the host in one flag.- Mounting
/var/run/docker.socklets the container start any container, including one that mounts the host's root. Use a restricted proxy if a tool really needs the API. --network hostremoves network isolation, and--pid hostexposes the host's processes.- Mounting sensitive host paths (
/etc,/root,/) read-write.
Check the result
docker inspect web --format 'user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} caps_drop={{.HostConfig.CapDrop}} sec={{.HostConfig.SecurityOpt}}'
docker exec web id
docker exec web sh -c 'touch /x'user=10001:10001 ro=true caps_drop=[ALL] sec=[no-new-privileges:true]
uid=10001 gid=10001
touch: /x: Read-only file system
Test the negative case: if the write succeeds, the flag is not doing what you think. Default seccomp and AppArmor profiles are already applied and should stay on; do not disable them to make something run.
Where this does not apply
Containers share the host kernel, so hardening reduces but does not remove the risk of a kernel vulnerability. For hostile code, use a stronger boundary, such as a VM or a sandbox runtime; see sandboxing with bubblewrap for lighter isolation of scripts. Images should be scanned too, as in image scanning.
What each hardening option removes
A container started with default settings runs as root inside, holds a set of Linux capabilities, can write anywhere in its file system layer, and can gain new privileges through setuid programs. Each hardening option removes one of those conveniences. --user runs the process as an unprivileged user, so a compromise does not start with root inside the container. --cap-drop=ALL removes every capability, and you add back only what the program needs, such as NET_BIND_SERVICE to listen on a port below 1024. --read-only makes the root file system read-only, so malware cannot drop files, and a tmpfs mount for /tmp gives programs a writable scratch area that disappears on exit. --security-opt no-new-privileges stops a process from gaining privileges through setuid binaries. Limits on memory, processes and CPU keep a runaway or hostile process from taking the host down. None of these is complete protection, and together they shrink what an attacker gets after a flaw in the application.
The container shares the host's kernel, so a kernel vulnerability can still lead to an escape. Hardening reduces the likelihood and the blast radius, and stronger isolation, such as a virtual machine based runtime, is the answer for untrusted code.
Finding what the application really needs
Do it by subtraction and testing. Start the container with every restriction on and watch it fail, then read the error and relax exactly one thing. A program that cannot write a pid file or a cache needs a tmpfs or a writable volume at that path, and the error names the path. A web server that cannot bind port 80 as a non-root user can listen on 8080 instead and let the published port map to it, which avoids granting a capability at all. A process that fails on chown may want CHOWN while it starts and none afterwards, which suggests changing the entrypoint to do ownership once at build time. Record the final set of options and the reason for each in the Compose file as comments. Check the result by inspecting the running container with docker inspect, and look at the capabilities a process really has in /proc/PID/status or with a tool such as capsh --decode.
Options never to use and how to keep the setup
A short list of settings undoes everything else. --privileged gives the container nearly the host's full power, including access to devices, and should never appear in production. Mounting the Docker socket into a container gives that container control over the host through the daemon, and is the same as handing over root. --network host removes network isolation, and --pid host exposes the host's processes. Mounting sensitive host paths, such as /, /etc or the home directory, widens what a compromise can reach. Adding the SYS_ADMIN capability is nearly the same as privileged mode. Put the hardened settings in the Compose file, where they live with the application and get reviewed, and check them in CI with a linter or a scanner that flags the dangerous ones. Rebuild images regularly on updated bases, drop the shell and package manager from the final image where you can, and run a vulnerability scan on every build.
Checking and keeping the settings in practice
After starting the container, look at what the daemon applied with docker inspect and read the host configuration section: the user, the read-only flag, the dropped capabilities and the security options should be there as you wrote them. Try to violate each restriction from inside with docker exec: write a file to the root file system, run a command that needs a dropped capability, and confirm each fails. Add a CI step that parses the Compose file and fails if a service lacks the baseline settings, so a new service cannot be added without them. For a fleet, an admission policy in Kubernetes does the same job, with equivalent settings in the pod's security context: a non-root user, a read-only root file system, dropped capabilities and a default seccomp profile. Review the exceptions regularly, since each one was added for a reason that may no longer hold.
See Docker's security overview and the run reference.


