Podman Capability Dropping and Restoration
Each failure names the next capability: chown, then bind. Four flags later nginx runs on CapEff 4c1 instead of 800405fb. But adding `--read-only` breaks the published port while the container still serves on its own loopback.
Security and Operations Guide 42 of 47 Advanced
- OSUbuntu 26.04 LTS (resolute)
- Podman5.7.0
- Runtimecrun 1.21
- Networknetavark 1.16.1
- TimeAbout 16 min
- Reviewed22 August 2026
Written against the versions above. Podman follows the distribution here rather than a vendor repository, so the version you get is the one Ubuntu shipped. The commands are stable across 5.x.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| PODMAN01 | 192.168.0.24 | Ubuntu 26.04 LTS | Primary Container Host | 2 Core | 4 GB | 50 GB |
Before you start
-
Find out what it writes before you take anything away
podman diffon a normally-running nginx lists every path it created:A /run/nginx.pid A /var/cache/nginx/client_temp A /var/cache/nginx/fastcgi_temp ...Two directories:
/runand/var/cache/nginx. That is the complete list of what needs to be writable, obtained by measurement rather than by reading the image's documentation - which frequently does not say.Do this first for any image you intend to harden. Guessing produces a container that starts and fails an hour later on a path nobody thought about.
bash Example session podman run -d --name plain docker.io/library/nginx:alpine330cc78e115254ad6fc5a2b7ada2a742a6167ca6d30f1e57b42d9947ec54cb22podman diff plainC /etcC /etc/nginx/conf.dC /etc/nginx/conf.d/default.confA /run/nginx.pidC /var/cache/nginxC /varC /var/cacheA /var/cache/nginx/client_tempA /var/cache/nginx/fastcgi_tempA /var/cache/nginx/proxy_tempA /var/cache/nginx/scgi_tempA /var/cache/nginx/uwsgi_temppodman rm -f plainplainExpected resultA list of added paths under
/runand/var/cache/nginx.Success conditionYou have the image's writable paths as a measured list.
-
Drop everything and read the failure
--cap-drop=ALL, with tmpfs at both paths from step 1. It exits 1:[emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)The error names the capability. nginx wants to give its cache directories to uid 101 so the workers can use them, and
chownneedscap_chown, which you just dropped.This is the methodology and it is worth doing in this order. Starting from nothing and adding back what the failures demand gives you a minimal, justified set where every capability is there for a reason you have read. Starting from the default eleven and guessing which to remove gives you a set nobody can explain.
bash Example session podman run -d --name h1 --read-only --tmpfs /var/cache/nginx --tmpfs /run --cap-drop=ALL -p 8110:80 docker.io/library/nginx:alpineb92b3ee66f2faa89b1124ac787ddc633c65432846945de99d38b284e246eabd1podman inspect h1 --format 'status={{.State.Status}} exit={{.State.ExitCode}}'status=exited exit=1podman logs h1 2>&1 | tail -22026/08/22 12:50: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)podman rm -f h1h1Expected result
status=exited exit=1and achown ... Operation not permittedemerg line.Success conditionThe container failed with a message naming exactly what it needs.
-
Add back one, and read the next failure
--cap-add=CHOWN. It gets further and stops again:[emerg] 1#1: bind() to 0.0.0.0:80 failed (13: Permission denied)A different failure, naming a different need. Port 80 is privileged inside the container too, and binding it needs
cap_net_bind_service- which is in Podman's default eleven and which--cap-drop=ALLremoved.Note this is a separate thing from guide 51. That was pasta failing to bind 80 on the host; this is nginx failing to bind 80 in its own namespace. Same number, two different boundaries, and it is worth keeping them apart.
Add the remaining three -
NET_BIND_SERVICEfor the bind,SETUIDandSETGIDfor dropping to the worker user - and it runs:PID USER COMMAND 1 root nginx 16 nginx nginx 17 nginx nginx CapEff: 00000000000004c14c1against the default800405fb. Four capabilities instead of eleven, each one traceable to a failure you read.touch /nopestill givesRead-only file system, so the root filesystem is immutable and the workers run unprivileged.bash Example session podman run -d --name h2 --read-only --tmpfs /var/cache/nginx --tmpfs /run --cap-drop=ALL --cap-add=CHOWN -p 8111:80 docker.io/library/nginx:alpineace3e17cf3711a8bbd9311b6574ba6df816a0dc21c815a609512aa0a2f085783podman inspect h2 --format 'status={{.State.Status}} exit={{.State.ExitCode}}'status=exited exit=1podman logs h2 2>&1 | tail -22026/08/22 12:50:43 [emerg] 1#1: bind() to 0.0.0.0:80 failed (13: Permission denied)nginx: [emerg] bind() to 0.0.0.0:80 failed (13: Permission denied)podman rm -f h2h2podman top h3 pid user commPID USER COMMAND1 root nginx16 nginx nginx17 nginx nginxpodman exec h3 grep -E '^CapEff' /proc/self/statusCapEff: 00000000000004c1podman exec h3 sh -c 'touch /nope'touch: /nope: Read-only file system[exit 1]podman inspect h3 --format 'ro={{.HostConfig.ReadonlyRootfs}} dropped={{len .HostConfig.CapDrop}}'ro=true dropped=7Expected resultA bind failure with CHOWN alone, then a running container on CapEff 4c1 with a read-only root.
Success conditionYou have a container whose every capability you can justify.
-
And the flag that quietly breaks the port
The hardened container above is running, its workers are up, and
podman toplooks perfect. It does not serve traffic:$ curl http://localhost:8133 from host: http=000 (exit 56, connection reset) $ podman exec r3 wget -qO- http://localhost/ <!DOCTYPE html> (works)Serving fine on its own loopback, resetting every forwarded connection. So the question is which flag did it - and the answer comes from changing one at a time and testing both sides:
| flags | from host | inside | |---|---|---| | none | 200 | serves | |
--cap-drop=ALLplus the four adds | 200 | serves | |--tmpfs /run| 200 | - | |--read-only --tmpfs ...| 000 | serves |It is
--read-only. Not the capabilities - those serve 200. Not--tmpfs /run- that serves 200 on its own. A read-only root filesystem and a published port do not work together on rootless Podman 5.7.0 with pasta, and the container gives no sign of it: it runs, it logs normally, and it answers itself.What to do about it. Check from outside. A container that starts is not a container that serves, and this whole finding exists because a
curlwas in the capture. If you harden something, request it from another machine before believing it.Then choose: keep the capability drops and the tmpfs mounts, which cost nothing here and gave the 200s; and get immutability another way - a rootful container where this combination does work,
--network hostso nothing is forwarded, or accepting a writable root and relying on the capability set.--read-onlyis the one piece of standard hardening advice that does not transfer to rootless unchanged.bash Example session podman run -d --name r0 -p 8130:80 docker.io/library/nginx:alpine503984d6c6d918c3b18b01885a57a5c509002f4531d808554a06fbf91845ccb8curl -s -o /dev/null -w 'from host: http=%{http_code}\n' http://localhost:8130from host: http=200podman run -d --name r1 --cap-drop=ALL --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=NET_BIND_SERVICE -p 8131:80 docker.io/library/nginx:alpine0862b0b0d6b6d86d28e3e9df2d1fba62f29a0d0fcb148441cd2b1a840e0cb976curl -s -o /dev/null -w 'from host: http=%{http_code}\n' http://localhost:8131from host: http=200podman run -d --name r2 --tmpfs /run -p 8132:80 docker.io/library/nginx:alpinedaabd4ea7511016eedc680501114c95ffc66e76508b48822bafb06abbfc3f636curl -s -o /dev/null -w 'from host: http=%{http_code}\n' http://localhost:8132from host: http=200podman run -d --name r3 --read-only --tmpfs /var/cache/nginx --tmpfs /run -p 8133:80 docker.io/library/nginx:alpinebe260617113a7e040816425733174ac39b79885a81db15dc9d8798e55d8ae850curl -s -o /dev/null -w 'from host: http=%{http_code}\n' http://localhost:8133from host: http=000[exit 56]podman exec r3 wget -qO- -T2 http://localhost/ 2>/dev/null | head -1<!DOCTYPE html>podman rm -f r3r3Expected result200 for the first three configurations and 000 for the read-only one, which still serves its own loopback.
Success conditionYou changed one flag at a time and can name the one that broke it.
Troubleshooting
A hardened container starts and then serves nothing.
Why: On rootless Podman 5.7.0,
--read-onlyand a published port do not work together.Fix:Test from outside, then drop
--read-onlyor publish differently. Confirm the container itself is fine withpodman exec <c> wget -qO- http://localhost/.--cap-drop=ALLand the container exits immediately.Why: It needs a capability. The log says which.
Fix:
podman logs <c>and add back only the one named. Repeat until it starts - two or three rounds is normal.--read-onlyand the container will not start at all.Why: A writable path you have not provided.
Fix:
podman diffon an unhardened run lists them all. Mount a tmpfs at each.You want to know what a running container actually holds.
Why:
inspectshows what was requested, which is not always what was granted.Fix:
podman exec <c> grep CapEff /proc/self/statusfor the truth, thencapsh --decode=<value>to read it.