Objective
Drop all Linux capabilities, then add back only what an nginx process needs. Confirm the container runs and serves traffic.
Tasks
Task 1: Without any capability changes
docker run -d --name nginx-default nginx:1.27
docker exec nginx-default cat /proc/1/status | grep ^Cap
# CapInh: 0000000000000000
# CapPrm: 00000000a80425fb <- the default ~40 caps
# CapEff: 00000000a80425fb
# CapBnd: 00000000a80425fb
Decode the mask: capsh --decode=00000000a80425fb.
Task 2: Drop ALL
docker run -d --name nginx-no-caps --cap-drop=ALL nginx:1.27
docker exec nginx-no-caps cat /proc/1/status | grep ^Cap
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
Nginx still listens on port 80 by default because the binary
already had setcap applied by the image builder. So this works.
Task 3: Try to listen on a low port from a custom binary
cat > /srv/listen-80.c <<'EOF'
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int s = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = {.sin_family=AF_INET, .sin_port=htons(80), .sin_addr.s_addr=INADDR_ANY};
bind(s, (struct sockaddr*)&a, sizeof(a));
listen(s, 5);
printf("listening on 80\n");
pause();
}
EOF
gcc /srv/listen-80.c -o /srv/listen-80
docker run -d --name listen-80 -v /srv/listen-80:/listen-80 --cap-drop=ALL alpine /listen-80
docker logs listen-80
# bind: Operation not permitted
Task 4: Add CAP_NET_BIND_SERVICE back
docker run -d --name listen-80-ok --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
-v /srv/listen-80:/listen-80 alpine /listen-80
docker logs listen-80-ok
# listening on 80
Step 5: Add read-only and no-new-privileges
docker run -d --name nginx-hardened \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--read-only --tmpfs /var/cache/nginx --tmpfs /var/run \
--security-opt no-new-privileges \
nginx:1.27
docker exec nginx-hardened cat /proc/1/status | grep ^Cap
# CapPrm: 0000000000000400 <- NET_BIND_SERVICE only
Cleanup
docker rm -f nginx-default nginx-no-caps listen-80 listen-80-ok nginx-hardened
rm -f /srv/listen-80 /srv/listen-80.c