Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Advanced

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.

· 14 min read ·Duration: approx. 50 minutes
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

Hardening does not replace updates

A hardened container running outdated software is still vulnerable – hardening only limits the damage. It is the second line of defence behind a working update process, not a substitute for it.
🍳 Recommendation Ad

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.

Go to netcup →

💶 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

All netcup vouchers →

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:

Terminal
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:

Ausgabe
/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:

Terminal
docker ps -q | xargs docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock
Ausgabe
/traefik-traefik-1 /opt/traefik/acme.json /var/run/docker.sock

The Docker socket is root on the host

Anyone holding /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:

Terminal
docker run --rm alpine:3 id
Ausgabe
uid=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:

Terminal
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'
Ausgabe
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=ep

Capabilities 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:

Terminal
mkdir -p /tmp/captest && echo test > /tmp/captest/datei.txt
sudo chown 1000:1000 /tmp/captest/datei.txt

Now the container may claim it:

Terminal
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.txt
Ausgabe
chown OK
-rw-r----- 1 0 0 5 Oct  4 23:24 /tmp/captest/datei.txt

The file now belongs to root – changed from inside a container. Take that one capability away and the attack goes nowhere:

Terminal
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'
Ausgabe
chown: /d/datei.txt: Operation not permitted

That is exactly what least privilege means: not “abolish root”, but disarm root. Clean up the test folder:

Terminal
rm -rf /tmp/captest

Step 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:

Terminal
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/status
Ausgabe
NoNewPrivs:	0
NoNewPrivs:	1

With 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:

YAML
    security_opt:
      - no-new-privileges:true

Step 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:

Terminal
docker run --rm --cap-drop ALL alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --print | grep -E "^Current:"'
Ausgabe
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:

Terminal
docker run --rm --cap-drop ALL nginx:1.31-alpine
Ausgabe
/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.

Terminal
docker run -d --name probe --cap-drop ALL --cap-add CHOWN nginx:1.31-alpine
sleep 5 && docker exec probe ps -o pid,user,comm
Ausgabe
PID   USER     COMMAND
    1 root     nginx
   34 root     ps

Only the master process is left (PID 34 is the ps itself), not a single worker. The log reveals why:

Terminal
docker logs probe 2>&1 | grep alert | tail -1
Ausgabe
2026/09/09 19:38:42 [alert] 1#1: worker process 33 exited with fatal code 2 and cannot be respawned

Without SETUID/SETGID the master may not switch to the unprivileged nginx user, so the workers die instantly. With those two added it works:

Terminal
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,comm
Ausgabe
PID   USER     COMMAND
    1 root     nginx
   30 nginx    nginx
   31 nginx    nginx
   32 nginx    nginx
   33 nginx    nginx
   34 root     ps

Master 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:

Terminal
docker exec probe grep CapEff /proc/1/status
docker run --rm alpine:3 sh -c 'apk add -q --no-cache libcap && capsh --decode=00000000000000c1'
Ausgabe
CapEff:	00000000000000c1
0x00000000000000c1=cap_chown,cap_setgid,cap_setuid

Three instead of fourteen. Don’t forget the test container:

Terminal
docker rm -f probe

You 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:

Terminal
docker run --rm alpine:3 cat /proc/sys/net/ipv4/ip_unprivileged_port_start
Ausgabe
0

So 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:

Terminal
docker run --rm --read-only alpine:3 sh -c 'touch /tmp/x'
Ausgabe
touch: /tmp/x: Read-only file system

Almost every application needs write access somewhere though. The answer is tmpfs: a RAM filesystem that disappears on restart and leaves nothing on disk.

Terminal
docker run --rm --read-only --tmpfs /tmp alpine:3 sh -c 'touch /tmp/x && echo "writing OK (RAM only)"'
Ausgabe
writing OK (RAM only)

Which paths those are is, again, what the error message tells you. For nginx:

Ausgabe
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:

Terminal
docker run --rm --user 101:101 --cap-drop ALL --read-only \
  --tmpfs /var/cache/nginx nginx:1.31-alpine
Ausgabe
nginx: [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:

Terminal
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,comm
Ausgabe
PID   USER     COMMAND
    1 nginx    nginx
   21 nginx    nginx
   22 nginx    nginx
   23 nginx    nginx
   24 nginx    nginx
   25 nginx    ps

Now 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:

Terminal
docker rm -f probe

Not 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:

Terminal
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}}' oomtest
Ausgabe
OOMKilled=true ExitCode=137

ExitCode 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:

Terminal
docker run --rm --pids-limit 20 alpine:3 sh -c 'for i in $(seq 1 40); do sleep 5 & done'
Ausgabe
sh: can't fork: Resource temporarily unavailable
Terminal
docker rm -f oomtest

Step 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:

Terminal
mkdir -p ~/webhard/site && cd ~/webhard

Create compose.yaml and replace YOUR_DOMAIN:

YAML
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: true

The 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 no cap_add at all – possible because user: is set.
  • read_only: true plus three tmpfs mounts with the matching UID.
  • :ro on the volume – nginx only reads the website, it never has to write there.
  • mem_limit and pids_limit cap 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:

Terminal
echo '<h1>Hardening worked</h1>' > site/index.html
chmod -R a+rX site
docker compose up -d
docker compose ps
Ausgabe
NAME       IMAGE               STATUS
web-hart   nginx:1.31-alpine   Up 10 seconds

Now the actual check – trust is good, docker inspect is better:

Terminal
docker inspect web-hart --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}  CapDrop={{.HostConfig.CapDrop}}  SecurityOpt={{.HostConfig.SecurityOpt}}  PidsLimit={{.HostConfig.PidsLimit}}  User={{.Config.User}}'
Ausgabe
ReadonlyRootfs=true  CapDrop=[ALL]  SecurityOpt=[no-new-privileges:true]  PidsLimit=64  User=101:101

And the counter-test that the write protection really bites:

Terminal
docker exec web-hart sh -c 'touch /etc/x'
Ausgabe
touch: /etc/x: Read-only file system

Finally from outside, through Traefik with a real certificate:

Terminal
curl -sS https://YOUR_DOMAIN/
Ausgabe
<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_add lists 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 :ro on 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.

You might also like