Ravindra BagaleCourses & study guides मराठी

Chapter 55: Docker Security Best Practices, Image Scanning with Trivy, Logging, Monitoring and Docker Swarm Basics

55.1 Run containers as non-root and drop capabilities

Friends, by default a process in a container runs as root. If an attacker finds a bug in your app, they get root inside the container, plus about 14 Linux capabilities. Today, step by step, we take those powers away: a non-root USER, --cap-drop ALL, adding back only what is needed, no-new-privileges, and never --privileged.

1. Start from a story

At PhonePe, a security engineer named Aditi ran a routine review of a small internal image-resize service. It ran as root with default capabilities.

During a test, she used a known bug in an old image library to run a shell inside the container. As root she could change any file in the container, open raw sockets and sniff traffic, and change file owners.

Aditi rebuilt the service with USER 10001, --cap-drop ALL, --read-only and no-new-privileges. She ran the same exploit again. This time the shell was a powerless user that could not write files, open raw sockets or become root.

The bug was still there until it was patched, but the damage it could do was tiny. That is defence in depth, and today we build it.

App bug exploitedattacker gets a shellNon-root USERno root inside--cap-drop ALLno special kernel powersno-new-privilegessetuid cannot raise rightsSmall blast radiusbug cannot do much
Picture 1: layers that limit an attacker

2. Users and capabilities step by step

Step 1: See the default user

Why: Root unless you change it.

What: Most official images run as root. Check with id.

Example 1: Default

Type this:

docker run --rm alpine:3.20 id

You will see:

uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floor),20(dialout),26(tape),27(video)

What it means: Root, with many groups.

Example 2: Image config

Type this:

docker inspect -f '{{.Config.User}}' nginx:1.27

You will see:


What it means: Empty means root.

Example 3: Some images are non-root

Type this:

docker inspect -f '{{.Config.User}}' gcr.io/distroless/static-debian12:nonroot

You will see:

65532

What it means: Distroless nonroot uses 65532.

Step 2: USER in the Dockerfile

Why: Make non-root the default.

What: Create a system user with a fixed UID and switch to it before CMD. Use a number so Kubernetes can verify it is not root.

Example 4: Dockerfile

Type this:

printf '%s\n' 'FROM python:3.12-slim' 'RUN useradd -r -u 10001 -s /usr/sbin/nologin app' 'WORKDIR /app' 'COPY --chown=10001:10001 app.py .' 'USER 10001' 'CMD ["python", "app.py"]' > Dockerfile && printf 'print("running")\n' > app.py && docker build -q -t resize:1.0 . && echo built

You will see:

sha256:41c9...
built

What it means: USER comes after installs that need root.

Example 5: Check

Type this:

docker run --rm resize:1.0 id

You will see:

uid=10001(app) gid=10001(app) groups=10001(app)

What it means: Non-root by default.

Example 6: Numeric user

Type this:

docker inspect -f '{{.Config.User}}' resize:1.0

You will see:

10001

What it means: A number, not a name, works with runAsNonRoot.

Step 3: --user at run time

Why: Override without rebuilding.

What: You can force a UID and GID when running any image.

Example 7: Run as 1000

Type this:

docker run --rm --user 1000:1000 alpine:3.20 id

You will see:

uid=1000 gid=1000 groups=1000

What it means: The user need not exist in /etc/passwd.

Example 8: Match your host user

Type this:

docker run --rm --user $(id -u):$(id -g) -v "$PWD":/w -w /w alpine:3.20 touch made.txt && ls -l made.txt

You will see:

-rw-r--r-- 1 ubuntu ubuntu 0 made.txt

What it means: Files on mounts belong to you, not root.

Example 9: Try root actions

Type this:

docker run --rm --user 1000 alpine:3.20 apk add curl 2>&1 | tail -1

You will see:

ERROR: Unable to lock database: Permission denied

What it means: Non-root cannot install packages.

Step 4: Fix permission errors the right way

Why: Ownership, not open permissions.

What: When a non-root app cannot write, give the folder to its user. Never open it to everyone.

Example 10: The error

Type this:

docker run --rm --user 10001 alpine:3.20 sh -c 'echo hi > /app/data.txt' 2>&1

You will see:

sh: can't create /app/data.txt: nonexistent directory

What it means: The folder does not exist or belongs to root.

Example 11: Create with owner

Type this:

printf '%s\n' 'FROM alpine:3.20' 'RUN adduser -D -u 10001 app && mkdir -p /app/data && chown 10001:10001 /app/data' 'USER 10001' > Dockerfile.data && docker build -q -f Dockerfile.data -t datafix:1.0 . >/dev/null && docker run --rm datafix:1.0 sh -c 'echo hi > /app/data/x.txt && ls -l /app/data'

You will see:

-rw-r--r--    1 app      app              3 x.txt

What it means: Owned by the app user only.

Example 12: Volume ownership

Type this:

docker volume create appdata >/dev/null && docker run --rm -v appdata:/app/data datafix:1.0 ls -ld /app/data

You will see:

drwxr-xr-x    2 app      app           4096 /app/data

What it means: A new named volume copies the owner from the image folder.

Step 5: What capabilities are

Why: Root split into pieces.

What: Linux splits root powers into capabilities such as CHOWN, NET_RAW and NET_BIND_SERVICE. Docker gives containers a default set.

Example 13: Default set

Type this:

docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'

You will see:

CapEff: 00000000a80425fb

What it means: A bitmask of about 14 capabilities.

Example 14: Decode

Type this:

docker run --rm alpine:3.20 sh -c 'apk add -q libcap && capsh --decode=00000000a80425fb'

You will see:

0x00000000a80425fb=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

What it means: These are the defaults.

Example 15: NET_RAW in action

Type this:

docker run --rm alpine:3.20 ping -c1 -W1 1.1.1.1 | tail -1

You will see:

round-trip min/avg/max = 2.1/2.1/2.1 ms

What it means: ping needs NET_RAW, which attackers also use to sniff.

Step 6: --cap-drop ALL

Why: Start from zero.

What: Drop every capability. Most web apps need none.

Example 16: Drop all

Type this:

docker run --rm --cap-drop ALL alpine:3.20 sh -c 'grep CapEff /proc/self/status'

You will see:

CapEff: 0000000000000000

What it means: No special powers.

Example 17: ping now fails

Type this:

docker run --rm --cap-drop ALL alpine:3.20 ping -c1 1.1.1.1 2>&1 | tail -1

You will see:

ping: permission denied (are you root?)

What it means: NET_RAW is gone.

Example 18: chown fails

Type this:

docker run --rm --cap-drop ALL alpine:3.20 chown nobody /etc/hostname 2>&1

You will see:

chown: /etc/hostname: Operation not permitted

What it means: Even root inside cannot change owners.

Step 7: --cap-add only what is needed

Why: Add back one at a time.

What: If the app breaks, read the error, add one capability, and test again.

Example 19: Port 80 as non-root

Type this:

docker run --rm --user 101 --cap-drop ALL nginx:1.27-alpine 2>&1 | grep -m1 emerg

You will see:

nginx: [emerg] bind() to 0.0.0.0:80 failed (13: Permission denied)

What it means: Ports under 1024 need NET_BIND_SERVICE.

Example 20: Better: high port

Type this:

docker run -d --name web --user 101 --cap-drop ALL -p 8080:8080 nginxinc/nginx-unprivileged:1.27-alpine && sleep 1 && curl -s -o /dev/null -w '%{http_code}\n' localhost:8080

You will see:

200

What it means: nginx-unprivileged listens on 8080 and needs no capability.

Example 21: Inspect

Type this:

docker inspect -f '{{.HostConfig.CapDrop}} {{.HostConfig.CapAdd}}' web

You will see:

[ALL] []

What it means: Nothing added back.

Step 8: no-new-privileges

Why: Block setuid tricks.

What: Even as a normal user, a setuid binary like su could raise rights. no-new-privileges stops that.

Example 22: setuid exists

Type this:

docker run --rm alpine:3.20 sh -c 'ls -l /bin/su' 

You will see:

-rwsr-xr-x    1 root     root        ... /bin/su

What it means: The s bit means setuid.

Example 23: Enable it

Type this:

docker run --rm --user 1000 --security-opt no-new-privileges:true alpine:3.20 sh -c 'grep NoNewPrivs /proc/self/status'

You will see:

NoNewPrivs: 1

What it means: Kernel flag set.

Example 24: Daemon default

Type this:

printf '{\n  "no-new-privileges": true\n}\n' | sudo tee /etc/docker/daemon.json && sudo systemctl restart docker && echo set

You will see:

{
  "no-new-privileges": true
}
set

What it means: Turn it on for every container. Merge with existing keys if the file exists.

3. Put it together

--user 10001:10001non-root--cap-drop ALLno capabilities--security-opt no-new-privilegesno escalation--read-only plus --tmpfs /tmpno writes to the imageHigh port, no --privilegedsafe defaults
Picture 2: a hardened run command

Step 9: Never use --privileged

Why: Almost full host access.

What: --privileged gives every capability and all host devices. A root process can then mount the host disk.

Example 25: Capabilities

Type this:

docker run --rm --privileged alpine:3.20 sh -c 'grep CapEff /proc/self/status'

You will see:

CapEff: 000001ffffffffff

What it means: Everything.

Example 26: Host devices

Type this:

docker run --rm --privileged alpine:3.20 sh -c 'ls /dev | grep -c -E "^(sd|nvme|xvd)"'

You will see:

3

What it means: It can see host disks.

Example 27: Find it in your fleet

Type this:

docker ps -q | xargs -r docker inspect -f '{{.Name}} {{.HostConfig.Privileged}}' | grep true

You will see:

/old-monitor true

What it means: Fix these first.

Step 10: The Docker socket is root

Why: Do not mount it casually.

What: Mounting /var/run/docker.sock gives the container control of Docker, which means root on the host.

Example 28: Find mounts

Type this:

docker ps -q | xargs -r docker inspect -f '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock

You will see:

/ci-runner /var/run/docker.sock

What it means: Only trusted tools such as a CI runner, and only when needed.

Example 29: Why it is root

Type this:

printf 'With the socket, a container can run: docker run -v /:/host --privileged ... and own the host.\n'

You will see:

With the socket, a container can run: docker run -v /:/host --privileged ... and own the host.

What it means: Treat the socket like the root password.

Example 30: Alternative

Type this:

printf 'Use rootless Docker, Kaniko or BuildKit for CI builds instead of the host socket.\n'

You will see:

Use rootless Docker, Kaniko or BuildKit for CI builds instead of the host socket.

What it means: Less power for CI jobs.

Step 11: Hardened run in Compose

Why: The same flags in YAML.

What: Every flag has a Compose key, so the safe setup is saved in Git.

Example 31: compose.yaml

Type this:

printf '%s\n' 'services:' '  web:' '    image: nginxinc/nginx-unprivileged:1.27-alpine' '    user: "101:101"' '    cap_drop: [ALL]' '    security_opt: ["no-new-privileges:true"]' '    read_only: true' '    tmpfs: [/tmp]' '    ports: ["8080:8080"]' > compose.yaml && docker compose config -q && echo valid

You will see:

valid

What it means: All safety settings in one file.

Example 32: Up

Type this:

docker rm -f web >/dev/null; docker compose up -d && sleep 1 && curl -s -o /dev/null -w '%{http_code}\n' localhost:8080

You will see:

200

What it means: Still serves pages.

Example 33: Verify

Type this:

docker compose exec web sh -c 'id -u; grep CapEff /proc/self/status; touch /etc/x' 2>&1

You will see:

101
CapEff: 0000000000000000
touch: /etc/x: Read-only file system

What it means: Non-root, no capabilities, read-only.

Step 12: Rootless Docker and user namespaces

Why: Root inside, normal user outside.

What: Rootless mode runs the whole Docker daemon as a normal user. userns-remap maps container root to a high host UID.

Example 34: Install rootless

Type this:

dockerd-rootless-setuptool.sh install 2>&1 | tail -1

You will see:

[INFO] Make sure the following environment variable(s) are set (or add them to ~/.bashrc):

What it means: Runs under your own user.

Example 35: Check

Type this:

docker info --format '{{.SecurityOptions}}'

You will see:

[name=seccomp,profile=builtin name=rootless name=cgroupns]

What it means: rootless shows in security options.

Example 36: Outside view

Type this:

docker run -d --name s alpine:3.20 sleep 300 && ps -o user= -p $(pgrep -f 'sleep 300')

You will see:

ubuntu

What it means: Root in the container is your normal user on the host.

4. Common mistakes and how Aditi avoids them

Mistake one: leaving the default root user. Aditi sets USER 10001 in every Dockerfile.

Mistake two: opening permissions to everyone to fix a permission error. She uses chown to the app user instead.

Mistake three: keeping default capabilities. She uses --cap-drop ALL and adds back one only when an error proves it is needed.

Mistake four: --privileged or the Docker socket for convenience. She removes them.

Mistake five: port 80 inside the container. She uses a high port and maps it.

Check yourself

Q1. Who does a container run as by default?

root, unless the image sets USER.

Q2. What does --cap-drop ALL do?

Removes every Linux capability from the container.

Q3. Why not open folder permissions to everyone?

It lets every user write; give ownership to the app user instead.

Q4. Why is mounting docker.sock dangerous?

It gives control of Docker, which means root on the host.

Friends, non-root USER, --cap-drop ALL, add back only what errors prove, no-new-privileges, never --privileged, and guard the Docker socket. Did you understand? Let us try it once more. Harden one of your containers and prove it with id and CapEff.

Ravindra Bagale's Tip

Start strict and loosen only when an error tells you to. It is much easier than starting open and trying to close holes later.

Lab: try at home

  1. Check the default user of alpine, nginx and a distroless image.
  2. Build an image with USER 10001 and verify with id.
  3. Run with --cap-drop ALL and see ping and chown fail.
  4. Serve nginx-unprivileged on 8080 with no capabilities.
  5. Add no-new-privileges and check NoNewPrivs.
  6. Write the hardened compose.yaml and verify all three checks.

Recap

  • Containers run as root by default.
  • Set USER with a numeric UID.
  • Drop all capabilities, add back only what is proven.
  • Enable no-new-privileges.
  • Never --privileged; guard docker.sock.
  • Use high ports and put flags in Compose.

Ravi Sir's Cards for this lesson

मराठी cards