Docker · lesson 8 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
Also part of: Security Fundamentals DevSecOps (AppSec in the pipeline) Cloud Security
Docker Security
Harden Docker images and containers: non-root users, read-only filesystems, secrets management, image scanning, and runtime policies.
By SkillByExample · Updated
Principle of Least Privilege
Run as Non-Root
FROM node:18-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm install --omit=dev
COPY --chown=node:node . .
# Switch to the node user (uid 1000, already exists in node images)
USER node
CMD ["node", "src/index.js"]
docker build -t secdemo:nonroot .
docker run --rm secdemo:nonroot
docker run --rm secdemo:nonroot id
$ docker run --rm secdemo:nonroot
app running as uid 1000 gid 1000
$ docker run --rm secdemo:nonroot id
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
Without USER node, both commands would report uid=0(root) — the app’s own process.getuid()
call and id are agreeing on what the container actually runs as, not what the Dockerfile
merely says it intends.
For custom users on a base image with no built-in one:
FROM alpine:3.20
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
CMD ["id"]
$ docker build -f Dockerfile.customuser -t secdemo:customuser .
[+] Building 0.3s (6/6) FINISHED
$ docker run --rm secdemo:customuser
uid=100(appuser) gid=101(appgroup) groups=101(appgroup),101(appgroup)
adduser -S creates a system user with no password and no login shell — exactly what a
container process needs and nothing more.
Read-Only Root Filesystem
docker run --read-only --tmpfs /tmp alpine:3.20 sh -c "echo hello > /root/test.txt"
docker run --read-only --tmpfs /tmp alpine:3.20 sh -c "echo hello > /tmp/test.txt && cat /tmp/test.txt"
$ docker run --read-only --tmpfs /tmp alpine:3.20 sh -c "echo hello > /root/test.txt"
sh: can't create /root/test.txt: Read-only file system
$ docker run --read-only --tmpfs /tmp alpine:3.20 sh -c "echo hello > /tmp/test.txt && cat /tmp/test.txt"
hello
Writing anywhere outside /tmp fails immediately — the whole image filesystem is mounted
read-only. --tmpfs /tmp punches exactly one writable hole, backed by memory and gone
when the container stops. A real app typically also needs --tmpfs /var/run for PID
files or sockets.
In Compose:
services:
api:
image: myapp:1.0
read_only: true
tmpfs:
- /tmp
- /var/run
Drop Capabilities
Linux capabilities grant root-like privileges individually. Drop all, add only what’s needed:
docker run --cap-drop ALL alpine:3.20 sh -c "grep CapEff /proc/self/status"
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE alpine:3.20 sh -c "grep CapEff /proc/self/status"
$ docker run --cap-drop ALL alpine:3.20 sh -c "grep CapEff /proc/self/status"
CapEff: 0000000000000000
$ docker run --cap-drop ALL --cap-add NET_BIND_SERVICE alpine:3.20 sh -c "grep CapEff /proc/self/status"
CapEff: 0000000000000400
/proc/self/status reports the process’s effective capability bitmask directly from the
kernel. All zero means the process — even running as root inside the container — can’t do
anything a capability would be required for; adding back NET_BIND_SERVICE sets exactly
one bit, enough to bind ports below 1024 and nothing else.
# docker-compose.yml
services:
api:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
Secrets Management
Docker Secrets (Swarm mode)
Secrets only exist in Swarm mode — a single node can still join its own one-node swarm to use them:
docker swarm init
echo "supersecret" | docker secret create db_password -
docker service create --name secdemo --secret db_password alpine:3.20 sleep 3600
$ docker swarm init
Swarm initialized: current node (...) is now a manager.
$ echo "supersecret" | docker secret create db_password -
az4ljm7mtam3uouneagtrmpwb
$ docker service create --name secdemo --secret db_password alpine:3.20 sleep 3600
verify: Service ... converged
Docker mounts the secret into the service’s containers at /run/secrets/<name> — find the
running task’s container and read it:
docker exec <container-id> cat /run/secrets/db_password
$ docker exec <container-id> cat /run/secrets/db_password
supersecret
The secret never appears in an ENV, a docker inspect, or an image layer — only inside
that file, inside the container, for services the swarm explicitly granted it to.
tmpfs for Secrets at Build Time
Secrets needed only during build (SSH keys, npm tokens) use a different mechanism —
RUN --mount=type=secret, available outside Swarm mode:
# syntax=docker/dockerfile:1
FROM alpine:3.20
# Mount secret at build time — not stored in any layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
cat /root/.npmrc | wc -l
docker build --secret id=npmrc,src=.npmrc -t myapp:1.0 .
docker run --rm myapp:1.0 sh -c "test -f /root/.npmrc && echo FOUND || echo not-found"
$ docker build --secret id=npmrc,src=.npmrc -t myapp:1.0 .
[+] Building 0.4s (7/7) FINISHED
$ docker run --rm myapp:1.0 sh -c "test -f /root/.npmrc && echo FOUND || echo not-found"
not-found
The RUN step could read /root/.npmrc while it ran — that’s the whole point, it’s there
for npm install or git clone to authenticate with. But it’s mounted only for that one
step and never committed to a layer, which is why the finished image doesn’t have it.
Never Do This
# BAD — secret baked into image layer, visible with docker history
ENV DB_PASSWORD=supersecret
docker build -t badapp:1.0 .
docker history --no-trunc badapp:1.0
docker inspect --format '{{.Config.Env}}' badapp:1.0
$ docker build -t badapp:1.0 .
1 warning found (use docker --debug to expand):
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD") (line 2)
$ docker history --no-trunc badapp:1.0
IMAGE CREATED CREATED BY SIZE COMMENT
<missing> 5 months ago ENV DB_PASSWORD=supersecret 0B buildkit.dockerfile.v0
$ docker inspect --format '{{.Config.Env}}' badapp:1.0
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=supersecret]
Docker itself warns about this at build time — and the password sits in plaintext in both
docker history and docker inspect, readable by anyone who can pull or run the image,
with no special tooling required.
Minimal Base Images
docker pull ubuntu:22.04
docker pull debian:12-slim
docker pull alpine:3.19
docker pull gcr.io/distroless/nodejs18-debian12
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
$ docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
REPOSITORY:TAG SIZE
ubuntu:22.04 78.1MB
debian:12-slim 74.9MB
alpine:3.19 7.4MB
gcr.io/distroless/nodejs18-debian12:latest 115MB
| Image | Size (measured above) | Shell | Use when |
|---|---|---|---|
ubuntu:22.04 | 78.1MB | bash | Debugging, legacy apps |
debian:12-slim | 74.9MB | bash | Most general apps |
alpine:3.19 | 7.4MB | sh | Most apps — good default |
distroless/nodejs18-debian12 | 115MB | none | Production Node.js — size includes the Node runtime itself |
scratch | 0MB | none | Statically compiled binaries (Go, Rust) |
# Distroless Node.js
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
FROM gcr.io/distroless/nodejs18-debian12
WORKDIR /app
COPY --from=builder /app .
CMD ["src/index.js"]
docker run --rm gcr.io/distroless/nodejs18-debian12 sh -c "echo hi"
$ docker run --rm gcr.io/distroless/nodejs18-debian12 sh -c "echo hi"
node:internal/modules/cjs/loader:1143
throw err;
^
Error: Cannot find module '/sh'
...
Node.js v18.20.8
There’s no shell to run sh -c with — the image’s only entrypoint is the node binary
itself, which tries and fails to load /sh as a JavaScript module. An attacker who gets
code execution inside a distroless container has no shell, no package manager, and no
coreutils to pivot with.
Image Scanning
docker scout ships with Docker Desktop, but scanning requires being logged in to a
Docker ID — it won’t run anonymously:
docker scout cves secdemo:customuser
$ docker scout cves secdemo:customuser
Log in with your Docker ID or email address to use docker scout.
Trivy needs no login and no local install — run it as a container against the Docker socket, scanning any image already on the host:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity HIGH,CRITICAL secdemo:customuser
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity HIGH,CRITICAL secdemo:customuser
Report Summary
┌─────────────────────────────────────┬────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├─────────────────────────────────────┼────────┼─────────────────┼─────────┤
│ secdemo:customuser (alpine 3.20.10) │ alpine │ 0 │ - │
└─────────────────────────────────────┴────────┴─────────────────┴─────────┘
Trivy also flagged (not shown above) that Alpine 3.20 is past its support window on this image — a reminder that “zero vulnerabilities found” from a scanner is only as good as its vulnerability database’s coverage of the base image’s own age.
# Scan for secrets accidentally baked into images — scans a saved image tarball
docker save badapp:1.0 -o badapp.tar
docker run --rm -v "$(pwd):/scan" trufflesecurity/trufflehog:latest \
filesystem /scan/badapp.tar --no-verification
$ docker run --rm -v "$(pwd):/scan" trufflesecurity/trufflehog:latest filesystem /scan/badapp.tar --no-verification
🐷🔑🐷 TruffleHog. Unearth your secrets. 🐷🔑🐷
finished scanning {"chunks": 107, "bytes": 387122, "verified_secrets": 0, "unverified_secrets": 0}
TruffleHog scans every byte of the tarball — including badapp.tar’s image config, which
contains the leaked ENV DB_PASSWORD=supersecret from earlier — but reports zero secrets.
Its detectors match known credential formats (AWS keys, GitHub tokens, Slack webhooks,
and hundreds more), not arbitrary strings like supersecret. A scanner finding nothing is
not the same as there being nothing to find — docker history and docker inspect, shown
earlier, catch this exact leak reliably because they don’t need to recognize the secret’s
shape first.
Seccomp and AppArmor
Docker applies a default seccomp profile that blocks dozens of dangerous syscalls. One of them is creating a new user namespace — a technique behind several container-escape CVEs:
docker run --rm alpine:3.20 unshare --map-root-user --user id
docker run --rm --security-opt seccomp=unconfined alpine:3.20 unshare --map-root-user --user id
$ docker run --rm alpine:3.20 unshare --map-root-user --user id
unshare: unshare(0x10000000): Operation not permitted
$ docker run --rm --security-opt seccomp=unconfined alpine:3.20 unshare --map-root-user --user id
uid=0(root) gid=0(root) groups=0(root),0(root),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody),65534(nobody)
With the default profile, unshare --user is blocked at the syscall level before it can
even try. seccomp=unconfined removes that filter entirely — the command that failed now
succeeds, creating a new user namespace where the container process is “root.” This is why
disabling seccomp is a last resort for compatibility, not a default: it’s not one
permission, it’s the whole syscall filter.
A custom profile (--security-opt seccomp=./custom-seccomp.json) narrows the allowed list
further than the default, and --security-opt apparmor=<profile> layers a separate,
path-and-capability-aware mandatory access control system on top — both need a profile
file prepared for the specific app, which is outside what a single container demonstrates.
No New Privileges
no-new-privileges blocks privilege escalation via setuid binaries — even for a process
already running as a non-root user. A trivial C program compiled with the setuid bit set
and owned by root makes the effect concrete:
// whoami_euid.c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("uid=%d euid=%d\n", getuid(), geteuid());
return 0;
}
FROM alpine:3.20
RUN apk add --no-cache gcc musl-dev
COPY whoami_euid.c /tmp/whoami_euid.c
RUN gcc -o /usr/local/bin/whoami_euid /tmp/whoami_euid.c && \
chmod u+s /usr/local/bin/whoami_euid
RUN adduser -D -u 1000 appuser
USER appuser
CMD ["whoami_euid"]
docker run --rm secdemo:nnp
docker run --rm --security-opt no-new-privileges secdemo:nnp
$ docker run --rm secdemo:nnp
uid=1000 euid=0
$ docker run --rm --security-opt no-new-privileges secdemo:nnp
uid=1000 euid=1000
The binary’s setuid bit normally raises its effective UID to the owner’s — root — the
instant it runs, which is exactly what the first line shows. no-new-privileges forbids
gaining any privilege beyond what the process already has, setuid bit or not, so the
second run’s effective UID never leaves 1000.
# docker-compose.yml
services:
api:
security_opt:
- no-new-privileges:true
Dockerfile Best Practices Summary
# Pin exact image digest instead of mutable tag
FROM node:18.19.0-alpine3.19@sha256:abc123...
# Minimize layers — chain RUN commands
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Never store secrets in ENV
# Use --mount=type=secret for build-time secrets
# Drop to non-root before CMD
USER nonroot
# Use COPY, not ADD (ADD can fetch remote URLs and extract tarballs)
COPY config.json /app/config.json
Practice
- Run a plain
alpine:3.20container with noUSERdirective at all and check who it runs as, to confirm what “non-root” is actually opting out of.
Solution
docker run --rm alpine:3.20 whoami
$ docker run --rm alpine:3.20 whoami
root
Root is the default, not an exception — every USER line earlier in this lesson is
undoing this.
- Drop all capabilities and add back
CHOWNinstead ofNET_BIND_SERVICE. Confirm the effective-capability bitmask is different from theNET_BIND_SERVICEone.
Solution
docker run --cap-drop ALL --cap-add CHOWN alpine:3.20 sh -c "grep CapEff /proc/self/status"
$ docker run --cap-drop ALL --cap-add CHOWN alpine:3.20 sh -c "grep CapEff /proc/self/status"
CapEff: 0000000000000001
0000000000000001 (bit 0, CAP_CHOWN) instead of 0000000000000400 (bit 10,
CAP_NET_BIND_SERVICE) — each capability is its own bit in the mask, which is why
--cap-add can grant exactly one at a time.
- The read-only example only mounted
tmpfsover/tmp. Show that writing to/var/runfails until it gets the same treatment.
Solution
docker run --read-only alpine:3.20 sh -c "echo hi > /var/run/test.pid"
docker run --read-only --tmpfs /var/run alpine:3.20 sh -c "echo hi > /var/run/test.pid && cat /var/run/test.pid"
$ docker run --read-only alpine:3.20 sh -c "echo hi > /var/run/test.pid"
sh: can't create /var/run/test.pid: Read-only file system
$ docker run --read-only --tmpfs /var/run alpine:3.20 sh -c "echo hi > /var/run/test.pid && cat /var/run/test.pid"
hi
Every path needs its own --tmpfs (or named volume) if something under it needs to be
writable — --read-only doesn’t have a concept of “mostly read-only.”
- Scan
gcr.io/distroless/nodejs18-debian12itself with Trivy. Having no shell doesn’t mean having no vulnerabilities — confirm that.
Solution
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity HIGH,CRITICAL gcr.io/distroless/nodejs18-debian12
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity HIGH,CRITICAL gcr.io/distroless/nodejs18-debian12
Report Summary
┌─────────────────────────────────────────────────────┬────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├─────────────────────────────────────────────────────┼────────┼─────────────────┼─────────┤
│ gcr.io/distroless/nodejs18-debian12 (debian 12.10) │ debian │ 9 │ - │
└─────────────────────────────────────────────────────┴────────┴─────────────────┴─────────┘
9 HIGH/CRITICAL findings, all in OS packages (OpenSSL among them) that shipped with the base image. “No shell” shrinks what an attacker can do after getting in; it says nothing about whether the packages already installed have known CVEs — that’s what a scanner is for, and the two protections are complementary, not substitutes for each other.