Skip to content
Docker
Lab 10 of 11·40mIntermediate

Run it as nobody, with a read-only filesystem

Harden a container to non-root, read-only rootfs and no capabilities, then prove each restriction holds by trying to break it.

You need

  • Docker Engine 24+ on Linux (or a Linux VM)

Do first

A container is not a security boundary by default. Root inside is root in the kernel, with a reduced capability set and a shared kernel between you and every other container. Five flags close most of the gap, and each one is provable.

1. See the default

mkdir -p ~/labs/docker-secure && cd ~/labs/docker-secure
docker run --rm alpine:3.20 id
docker run --rm alpine:3.20 sh -c 'touch /etc/anything && echo "wrote to /etc"'
docker run --rm alpine:3.20 sh -c 'apk add --no-cache curl >/dev/null 2>&1 \
  && echo "installed packages at runtime"'

Root, writable root filesystem, and able to install software. If an attacker gets code execution in that container, all three are theirs.

Verify

docker run --rm alpine:3.20 id -u # 0

2. Do not be root

cat > Dockerfile <<'DOCKER'
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app -u 10001 app
RUN mkdir -p /app && echo 'ok' > /app/state && chown -R app:app /app
USER app
WORKDIR /app
CMD ["sh", "-c", "id; cat /app/state; sleep 300"]
DOCKER
docker build -q -t secureapp . >/dev/null
docker run --rm secureapp sh -c 'id'
docker run --rm secureapp sh -c 'touch /etc/x 2>&1 | tail -n 1'

USER in the Dockerfile is the durable form — anyone who runs the image gets the non-root user without needing a flag. docker run --user overrides it at runtime, which is the escape hatch, not the mechanism.

Pick an explicit high uid (10001 here) rather than relying on nobody. nobody is 65534 on most images and different on some, and a bind mount needs a number you can match on the host.

Verify

docker run --rm secureapp id -u # 10001

3. Make the filesystem read-only

docker run --rm --read-only secureapp \
  sh -c 'touch /app/x 2>&1 | tail -n 1'
docker run --rm --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m secureapp \
  sh -c 'touch /tmp/x && echo "tmp is writable"; touch /app/x 2>&1 | tail -n 1'

--read-only makes the entire rootfs immutable. Most applications need _somewhere_ writable, so grant exactly that with a --tmpfs — and note noexec on it, which stops a downloaded payload from being run out of the one writable place.

If the app needs persistent writes, a volume mount stays writable through --read-only. That is the shape to aim for: read-only rootfs, one volume for data, a small tmpfs for scratch.

Verify

docker run --rm --read-only secureapp sh -c 'touch /app/x 2>&1 | grep -c "Read-only"' # 1

4. Drop the capabilities

docker run --rm alpine:3.20 sh -c 'apk add -q libcap 2>/dev/null; \
  capsh --print 2>/dev/null | head -n 2 || echo "capsh unavailable"'
docker run --rm --cap-drop=ALL secureapp sh -c 'id; echo "ran with no capabilities"'
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx:alpine \
  sh -c 'echo "nginx could bind 80 with just this one"'

Docker grants a container about fourteen capabilities by default, including CAP_CHOWN, CAP_SETUID and CAP_NET_RAW. Almost no application needs any of them.

Drop everything and add back only what breaks. The common one is NET_BIND_SERVICE, for binding below port 1024 — though the better answer is usually to listen on 8080 and map the port.

Verify

docker run --rm --cap-drop=ALL secureapp id -u # 10001 — runs fine with zero capabilities

5. Stop privilege escalation

docker run --rm --security-opt=no-new-privileges secureapp \
  sh -c 'id; echo "no-new-privileges set"'
docker run --rm alpine:3.20 sh -c 'ls -l /bin/su; echo'

no-new-privileges prevents a setuid binary inside the container from raising privileges — it makes the setuid bit inert. Since a well-built image has no setuid binaries at all, this flag costs nothing and closes a real path.

Check what your image ships:

docker run --rm secureapp sh -c \
  'find / -perm -4000 -type f 2>/dev/null | head -n 5
   echo "setuid count: $(find / -perm -4000 -type f 2>/dev/null | wc -l)"'

Verify

docker run --rm --security-opt=no-new-privileges secureapp id -u # 10001

6. Never do these

docker run --rm --privileged alpine:3.20 sh -c 'echo "this had full kernel access"'

--privileged disables essentially every isolation feature: all capabilities, all devices, no seccomp. It is equivalent to running the process as root on the host. The same is true of -v /var/run/docker.sock:/var/run/docker.sock — a container that can talk to the Docker socket can start a privileged container, which is root on the host by another route.

Both appear constantly in tutorials. Both are a full compromise if the container is compromised.

Verify

docker run --rm --privileged alpine:3.20 sh -c 'echo demonstrated' # demonstrated — now never use this flag in anger

7. Secrets: files, not environment variables

echo "s3cr3t-value" > ./api.key
chmod 600 ./api.key
CID=$(docker create -e API_KEY=s3cr3t-value alpine:3.20 true)
docker inspect "$CID" \
  --format '{{range .Config.Env}}{{println .}}{{end}}' | grep API_KEY
docker rm "$CID" >/dev/null
docker run --rm -v "$PWD/api.key:/run/secrets/api.key:ro" secureapp \
  sh -c 'cat /run/secrets/api.key'

An environment variable is visible in docker inspect, in /proc/<pid>/environ, in crash dumps, and in the logs of anything that prints its environment on startup. A mounted file is visible to the process and nothing else.

Compose and Kubernetes both have a first-class secrets mechanism that mounts a file. Use it.

Verify

docker run --rm -v "$PWD/api.key:/run/secrets/api.key:ro" secureapp \ sh -c 'cat /run/secrets/api.key' # s3cr3t-value

8. Put it together

docker run -d --name hardened \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop=ALL \
  --security-opt=no-new-privileges \
  --memory=128m --memory-swap=128m --cpus=0.5 --pids-limit=64 \
  secureapp
sleep 2
docker inspect hardened --format 'user={{.Config.User}}
ro={{.HostConfig.ReadonlyRootfs}}
caps={{.HostConfig.CapDrop}}'
docker logs hardened | head -n 3
docker rm -f hardened >/dev/null

That is the baseline. None of it required changing the application.

Verify

docker inspect "$(docker create --user 10001:10001 --read-only --cap-drop=ALL \ secureapp true)" --format '{{.HostConfig.ReadonlyRootfs}}' # true

The checklist

FlagCloses
USER in the DockerfileRoot inside the container
--read-only + --tmpfsWriting to the image, dropping payloads
--cap-drop=ALLFourteen capabilities nothing needs
--security-opt=no-new-privilegessetuid escalation
Secrets as mounted filesCredentials in inspect and /proc
Never --privileged or the socketEverything

Clean up

docker image rm secureapp 2>/dev/null
cd ~ && rm -rf ~/labs/docker-secure

Where this goes next

One challenge left, and it is the one every engineer has actually lived: it works on your machine.