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
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
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
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
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
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
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
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/nullThat is the baseline. None of it required changing the application.
Verify
The checklist
| Flag | Closes |
|---|---|
USER in the Dockerfile | Root inside the container |
--read-only + --tmpfs | Writing to the image, dropping payloads |
--cap-drop=ALL | Fourteen capabilities nothing needs |
--security-opt=no-new-privileges | setuid escalation |
| Secrets as mounted files | Credentials in inspect and /proc |
Never --privileged or the socket | Everything |
Clean up
docker image rm secureapp 2>/dev/null
cd ~ && rm -rf ~/labs/docker-secureWhere this goes next
One challenge left, and it is the one every engineer has actually lived: it works on your machine.