Hardening Docker Containers: cap_drop, read_only & Least Privilege
Harden containers with Docker's own tools: drop capabilities, no-new-privileges, read_only with tmpfs and resource limits – every step verified for real.
Table of contents
A container is not a virtual machine. By default root runs inside it, with fourteen kernel capabilities and a writable filesystem – and hardly any application needs that. This recipe takes everything away from a container piece by piece, checking after each step whether it still works.
What are we building?
A hardening pattern you can apply to any existing Compose stack: drop capabilities
entirely and hand back only the bare minimum, block privilege escalation with
no-new-privileges, mount the filesystem read-only, and cap memory and process count.
The practice object is an nginx 1.31-alpine behind your Traefik. By the end it runs without a single capability, as an unprivileged user, with a read-only root filesystem – and still serves perfectly normally. Tested on Debian 13 with Docker 29.8.2 and Compose v5.6.0.
The benefit is concrete: when an application has a hole, hardening decides whether an attacker merely stands around inside the container or starts changing file ownership, loading binaries and attacking the host.
Prerequisites
- The Compose basics – we work in
compose.yamlthroughout. - A hardened server with Docker; for the demo part a running
Traefik reverse proxy with the
proxynetwork and theleresolver. - A basic grasp of users and permissions on Linux – UIDs and GIDs come up constantly here.
Hardening does not replace updates
VPS 1000 G12.5
4 vCores · 8 GB RAM · 128 GB SSD
from €14.50/month
Hardening costs no resources – this runs on the smallest VPS.
💶 1 month free:
Single use, valid for VPS 1000 G12.5. Redeem in the cart →
💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite
Step by step
Step 1: Take stock – how exposed are your containers right now?
Before changing anything, get an overview. This one-liner queries all running containers and shows the three values this recipe is about:
docker ps -q | xargs docker inspect --format \
'{{printf "%-14s" .Name}} user={{if .Config.User}}{{.Config.User}}{{else}}root{{end}} readonly={{.HostConfig.ReadonlyRootfs}} cap_drop={{.HostConfig.CapDrop}}'On a typical server that looks like this:
/traefik-traefik-1 user=root readonly=false cap_drop=[]Translated: the container runs as root, may write anywhere, and holds all default capabilities. That is the Docker default – not the application’s fault.
A second look goes to the most dangerous mount of all, the Docker socket:
docker ps -q | xargs docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock/traefik-traefik-1 /opt/traefik/acme.json /var/run/docker.sockThe Docker socket is root on the host
/var/run/docker.sock inside a container can start a new container with
the host filesystem mounted – and is therefore root on the server, regardless of
every other hardening measure. Traefik needs the socket for its auto-configuration; that
is the classic conflict of goals. How to defuse it is under “Maintenance” below. For
every other container the rule is: never mount the socket.Step 2: Why “root in the container” is a real problem
Look at who is working inside a perfectly ordinary container:
docker run --rm alpine:3 iduid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)That is the same UID 0 as on the host – separated only by namespaces, not by a different identity. And that UID 0 gets fourteen capabilities from the Docker daemon:
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap=epCapabilities are the pieces Linux split root’s former omnipotence into. cap_chown, for
instance, allows changing the owner of arbitrary files – including inside a mounted host
directory. That takes ten seconds to demonstrate. Create a file owned by another user:
mkdir -p /tmp/captest && echo test > /tmp/captest/datei.txt
sudo chown 1000:1000 /tmp/captest/datei.txtNow the container may claim it:
docker run --rm -v /tmp/captest:/d alpine:3 sh -c 'chown 0:0 /d/datei.txt && echo "chown OK"'
ls -ln /tmp/captest/datei.txtchown OK
-rw-r----- 1 0 0 5 Oct 4 23:24 /tmp/captest/datei.txtThe file now belongs to root – changed from inside a container. Take that one capability away and the attack goes nowhere:
sudo chown 1000:1000 /tmp/captest/datei.txt
docker run --rm --cap-drop CHOWN -v /tmp/captest:/d alpine:3 sh -c 'chown 0:0 /d/datei.txt'chown: /d/datei.txt: Operation not permittedThat is exactly what least privilege means: not “abolish root”, but disarm root. Clean up the test folder:
rm -rf /tmp/captestStep 3: Block privilege escalation with no-new-privileges
The cheapest hardening first – one line that practically never breaks anything.
no-new-privileges forbids the kernel to grant a process more rights after the fact,
for example via a setuid binary. The difference is directly visible:
docker run --rm alpine:3 grep NoNewPrivs /proc/self/status
docker run --rm --security-opt no-new-privileges:true alpine:3 grep NoNewPrivs /proc/self/statusNoNewPrivs: 0
NoNewPrivs: 1With 1 there, an attacker who achieved code execution inside the container can no
longer use setuid binaries as a stepping stone. In Compose that is:
security_opt:
- no-new-privileges:trueStep 4: Drop all capabilities – and hand back only what is needed
Now the core. The rule: take everything away first, then hand back what the application really needs, based on real error messages. With everything gone, the list is genuinely empty:
docker run --rm --cap-drop ALL alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'Current: =nginx then fails to start immediately. The entrypoint still works through its scripts, then it aborts – the last two lines say exactly what is missing:
docker run --rm --cap-drop ALL nginx:1.31-alpine/docker-entrypoint.sh: Configuration complete; ready for start up
2026/09/09 19:38:42 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)The image’s entrypoint wants to hand the cache directory to the nginx user – that needs
CHOWN. Hand back only that one, and something instructive happens: the container
runs, but does not answer.
docker run -d --name probe --cap-drop ALL --cap-add CHOWN nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,commPID USER COMMAND
1 root nginx
34 root psOnly the master process is left (PID 34 is the ps itself), not a single worker. The log
reveals why:
docker logs probe 2>&1 | grep alert | tail -12026/09/09 19:38:42 [alert] 1#1: worker process 33 exited with fatal code 2 and cannot be respawnedWithout SETUID/SETGID the master may not switch to the unprivileged nginx user, so
the workers die instantly. With those two added it works:
docker rm -f probe
docker run -d --name probe --cap-drop ALL \
--cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,commPID USER COMMAND
1 root nginx
30 nginx nginx
31 nginx nginx
32 nginx nginx
33 nginx nginx
34 root psMaster as root, workers as nginx – that is how it should be. (The number of workers depends on
the CPU cores; the VPS 1000 has four.) Verify what the kernel
actually grants the process:
docker exec probe grep CapEff /proc/1/status
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --decode=00000000000000c1'CapEff: 00000000000000c1
0x00000000000000c1=cap_chown,cap_setgid,cap_setuidThree instead of fourteen. Don’t forget the test container:
docker rm -f probeYou usually don't need NET_BIND_SERVICE
Many guides reflexively hand back NET_BIND_SERVICE because the service listens on port
80. In containers that has been unnecessary for years – Docker sets the threshold for
privileged ports to zero:
docker run --rm alpine:3 cat /proc/sys/net/ipv4/ip_unprivileged_port_start0So every process in the container may bind any port. Measured: nginx runs with
CHOWN,SETUID,SETGID on port 80, entirely without NET_BIND_SERVICE. Hand back nothing
you haven’t proven necessary with a real error message.
Step 5: Mount the filesystem read-only
read_only: true makes the container’s root filesystem unwritable. Malicious code can
then neither drop binaries nor tamper with configuration. The effect is blunt:
docker run --rm --read-only alpine:3 sh -c 'touch /tmp/x'touch: /tmp/x: Read-only file systemAlmost every application needs write access somewhere though. The answer is tmpfs: a
RAM filesystem that disappears on restart and leaves nothing on disk.
docker run --rm --read-only --tmpfs /tmp alpine:3 sh -c 'touch /tmp/x && echo "writing OK (RAM only)"'writing OK (RAM only)Which paths those are is, again, what the error message tells you. For nginx:
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)Three directories are enough: /var/cache/nginx, /var/run and /tmp.
Step 6: No root at all – the container as its own user
The premier discipline: when no root works inside the container at all, capabilities
become largely moot. The nginx image already ships a nginx user with UID/GID 101.
A naive attempt fails, however:
docker run --rm --user 101:101 --cap-drop ALL --read-only \
--tmpfs /var/cache/nginx nginx:1.31-alpinenginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)The reason is inconspicuous: a tmpfs belongs to root by default, and UID 101 may
not write there. You have to pass the owner when mounting:
docker run -d --name probe --user 101:101 --cap-drop ALL \
--security-opt no-new-privileges:true --read-only \
--tmpfs /var/cache/nginx:uid=101,gid=101 \
--tmpfs /var/run:uid=101,gid=101 \
--tmpfs /tmp:uid=101,gid=101 nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,commPID USER COMMAND
1 nginx nginx
21 nginx nginx
22 nginx nginx
23 nginx nginx
24 nginx nginx
25 nginx psNow even PID 1 runs as nginx – and the three capabilities from step 4 are no longer
needed, because nobody has to switch users or hand over cache directories any more.
Clean up:
docker rm -f probeNot every image can do this
user: only works if the image was built for it – the directories must belong to the
chosen user, or be provided via tmpfs with a matching UID. Images that run chown
themselves at startup (many database images) will fail. In that case stick with step 4:
root in the container, but without capabilities.Step 7: Cap memory and processes
Hardening also means that one derailed container does not take the whole server with it. Without a limit a container may claim all the host’s RAM. With a limit the OOM killer steps in – but only inside the container:
docker run -d --name oomtest --memory 128m alpine:3 sh -c 'tail /dev/zero'
sleep 8
docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oomtestOOMKilled=true ExitCode=137ExitCode 137 is the signature of a memory limit – remember the number, it shows up
often in daily work. The counterpart against fork bombs is pids_limit:
docker run --rm --pids-limit 20 alpine:3 sh -c 'for i in $(seq 1 40); do sleep 5 & done'sh: can't fork: Resource temporarily unavailabledocker rm -f oomtestStep 8: The finished stack
Everything combined, using a statically served website as the example – say the output of your own website with Hugo. Create the project:
mkdir -p ~/webhard/site && cd ~/webhardCreate compose.yaml and replace YOUR_DOMAIN:
services:
web:
image: nginx:1.31-alpine
container_name: web-hart
restart: unless-stopped
user: "101:101"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /var/cache/nginx:uid=101,gid=101
- /var/run:uid=101,gid=101
- /tmp:uid=101,gid=101
mem_limit: 128m
pids_limit: 64
volumes:
- ./site:/usr/share/nginx/html:ro
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.hart.rule=Host(`YOUR_DOMAIN`)"
- "traefik.http.routers.hart.entrypoints=websecure"
- "traefik.http.routers.hart.tls.certresolver=le"
- "traefik.http.services.hart.loadbalancer.server.port=80"
networks:
proxy:
external: trueThe points that make the difference:
user: "101:101"– no root in the container. The quotes matter, otherwise YAML reads the value as a sexagesimal number.cap_drop: [ALL]with nocap_addat all – possible becauseuser:is set.read_only: trueplus threetmpfsmounts with the matching UID.:roon the volume – nginx only reads the website, it never has to write there.mem_limitandpids_limitcap the damage of a runaway process.
Because nginx now runs as UID 101 rather than root, it also has to be allowed to read the
website. On a hardened server with UMASK 027 in /etc/login.defs – as the Lynis audit recommends –
mkdir and echo as root create drwxr-x--- and -rw-r-----, and UID 101 can’t see any of it;
the stack then answers 403 Forbidden. Hence the chmod -R a+rX site below – it makes files
readable and directories traversable without making files executable, and does no harm with
Debian’s default umask.
Drop in a test page and start:
echo '<h1>Hardening worked</h1>' > site/index.html
chmod -R a+rX site
docker compose up -d
docker compose psNAME IMAGE STATUS
web-hart nginx:1.31-alpine Up 10 secondsNow the actual check – trust is good, docker inspect is better:
docker inspect web-hart --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}} PidsLimit={{.HostConfig.PidsLimit}} User={{.Config.User}}'ReadonlyRootfs=true CapDrop=[ALL] SecurityOpt=[no-new-privileges:true] PidsLimit=64 User=101:101And the counter-test that the write protection really bites:
docker exec web-hart sh -c 'touch /etc/x'touch: /etc/x: Read-only file systemFinally from outside, through Traefik with a real certificate:
curl -sS https://YOUR_DOMAIN/<h1>Hardening worked</h1>A container with no capabilities, no root, no write access – serving perfectly normally. This is the pattern you now attach to your remaining stacks.
When things go wrong
The container won’t start and reports Operation not permitted. You dropped one
capability too many. The message almost always names the system call – chown(...) failed means CHOWN, setgid means SETGID. Hand back exactly that one
(cap_add: [CHOWN]) and restart; work your way towards the minimum instead of adding
several capabilities on suspicion.
The container is Up according to docker compose ps but doesn’t answer. Up only
means PID 1 is alive – not that the application is working. With nginx the workers die
without SETUID/SETGID while the master keeps running. Check with docker exec CONTAINER ps -o pid,user,comm whether there is more than one process, and look at the
log: docker compose logs | grep -iE "alert|emerg".
Read-only file system at startup. The application needs a writable directory you
haven’t provided as tmpfs yet. The error message names the path; add it under tmpfs:.
Careful: real data never belongs in a tmpfs – that is RAM and gone on restart. Keep
using a volume for that.
The page returns 403 Forbidden, the log says is forbidden (13: Permission denied).
nginx runs as UID 101 and isn’t allowed to read the files in the mounted directory – typical for
servers with UMASK 027, where root creates new files without read permission for others. Check
with ls -l and fix with chmod -R a+rX site.
Permission denied in the tmpfs even though read_only looks right. A tmpfs belongs
to root by default. If you set user:, the mount needs the UID:
/var/cache/nginx:uid=101,gid=101. Find the right UID with docker run --rm YOUR_IMAGE id USERNAME.
The container is killed without warning and docker inspect shows ExitCode 137. That
is the OOM killer: mem_limit is too tight. Measure the real demand in operation with
docker stats --no-stream and set the limit a little above it. Leaving the limit out
entirely is the worse option – then the container may take the server down with it.
Your problem is not listed? Search all error messages →
Maintenance & backups
- Hardening is per-application work, not a one-off for all. Every image needs a
different minimum. Budget 15–30 minutes per stack for the “drop everything, read the
log, hand back what’s needed” round – and do it once properly instead of copying
values from other people’s examples. Foreign
cap_addlists are almost always too generous. - Re-check briefly after every image update. A new major version may want to write to an additional directory or switch its internal user. The container then won’t start – annoying, but quickly found when you know what to look for. Fold the check into your update process.
- The audit one-liner from step 1 belongs in your routine. Running it over all containers once a month immediately shows which stack slipped through without hardening on the last deploy.
- Defuse the Docker socket. Traefik genuinely needs it – but not for writing. A
:roon the mount is a first step, but it does not prevent starting containers via the API. The clean solution is a socket proxy: a small container that passes Traefik only the read endpoints and blocks everything else. If the socket is there just for Traefik, that is where you gain the most. - Honest about the benefit. These measures do not prevent a break-in into the application. They make sure that “attacker can run code in a web application” does not become “attacker is root on the server”. Together with Traefik hardening in front and encrypted backups behind, that makes a resilient chain.
- Backup: no new data is involved – you back up the volume contents as before. The
hardening itself lives entirely in
compose.yaml, which belongs in version control anyway.
Send feedback: feedback@serverkueche.de
You might also like


