Docker Advanced #3: Image Security — non-root, distroless, Trivy Scans
Pause everything we’ve built so far and look at it through a security lens. Container security is a book-length topic, but just a handful of high-leverage tools put the largest chunks of risk into view.
This post in the Docker Advanced series:
- #1 BuildKit and buildx
- #2 Multi-architecture images
- #3 Image security — non-root, distroless, scan (Trivy) ← this post
- #4 SBOM and signing (cosign)
- #5 Resource limits and cgroups
- #6 Production operations — restart policy, healthcheck, graceful shutdown
Container security in one picture #
Splitting threats two ways naturally splits the tools.
Runtime isolation Image hygiene
───────────────── ─────────────
• USER (non-root) • distroless / minimal base
• read-only root • minimal dependencies
• capabilities drop • Trivy / Grype CVE scans
• seccomp / AppArmor • hadolint Dockerfile lint
• no-new-privileges • SBOM ([#4])
• Signing ([#4])This post focuses on the immediately reachable tools — USER / read-only / capabilities on the left, distroless / Trivy / hadolint on the right. Deeper policies (seccomp, AppArmor) layer on top depending on the environment.
1) USER — drop to a non-root user #
Almost every base image starts as root. Container root isn’t the same as host root (user namespace isolation), but having root inside the container clearly expands the exploit surface for breaking isolation.
The basic pattern:
FROM python:3.14-slim
WORKDIR /app
RUN groupadd --system app && useradd --system --gid app --home /app app
COPY --chown=app:app requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app app.py .
USER app
CMD ["python", "app.py"]groupadd --system app && useradd --system --gid app ...— non-interactive system user (UID < 1000)COPY --chown=app:app— set ownership at copy timeUSER app— every following command (especiallyCMD) runs as that user
When the base image already has a non-root user #
Many common bases ship with one:
| Image | Pre-existing user |
|---|---|
node:* | node (UID 1000) |
nginx:* | nginx |
postgres:* | postgres (sometimes the entrypoint handles it) |
gcr.io/distroless/* | nonroot (UID 65532) |
FROM node:20-slim
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]Common “why doesn’t it work” failure points #
After dropping USER, the most common errors are permission-related:
- Bind to port 80/443 fails — non-root can’t open ports below 1024. Use a non-privileged port (8000/8080) inside the container, and map with
-p 80:8000to the host. - Can’t write
/var/log/xxx— directories created by root aren’t writable.chownthem or log to stdout (Intermediate #6). pip installran as root, runtime is non-root — global site-packages still readable. Fine.
2) Read-only root filesystem #
Does your container’s code actually write to disk? Look closely and often it doesn’t. If so, mount the root filesystem read-only — fewer writable surfaces for an intruder to drop something.
docker run --read-only myappservices:
web:
image: myapp
read_only: true
tmpfs:
- /tmp # /tmp as a writable in-memory mount
volumes:
- app-data:/app/data # persistent data → named volumeread_only: true alone can break apps that write temp files (e.g., libraries that cache to /tmp). The common pattern is to pair it with tmpfs: for /tmp — a writable in-memory mount.
Benefits:
- Even if the container is compromised, an attacker can’t drop tools to disk
- The app can’t tamper with its own code
- Becomes explicit which data is persistent and which is ephemeral
A common option for production containers. The first time you turn it on, you may need to identify and patch the breakages it exposes (library cache directories, etc.).
3) Capabilities drop #
Linux capabilities are root privilege split into fine-grained units. Containers start with 14 by default, but most apps work with zero.
docker run --cap-drop=ALL myappservices:
web:
image: myapp
cap_drop:
- ALL
cap_add: # add back only what's truly needed
- NET_BIND_SERVICE # bind to ports < 1024 (needed when USER is non-root)--cap-drop=ALL shrinks the attack surface dramatically with one line. If something breaks, add back capabilities one at a time.
Capabilities you might re-add:
NET_BIND_SERVICE— bind to ports below 1024 as a non-root userCHOWN,DAC_OVERRIDE,FOWNER,SETGID,SETUID— when an entrypoint manipulates permissions (most non-interactive entrypoints don’t need these)KILL— sending signals to other processes (rarely, when PID 1 manages children)
--security-opt no-new-privileges
#
Block in-container attempts to escalate privileges via setuid.
docker run --security-opt no-new-privileges myappservices:
web:
security_opt:
- no-new-privileges:truesetuid binaries (sudo, passwd, etc.) inside the container can’t escalate. Containers usually don’t include such binaries (distroless never does), so the safety margin is huge for one line of config.
4) Distroless again — security angle #
The distroless we used for slimming in Intermediate #1, now from a security angle:
- No shell — even after a breakin, no
bash/shmakes interactive shell access hard - No package manager — can’t
apt/yumin tools - No coreutils — no
cat,ls,wget,curl nonrootuser pre-provisioned —USER nonroot:nonrootis one line
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]The :nonroot tag is a variant where USER is preset. Writing USER nonroot:nonroot explicitly is still nice for clarity.
The trade-off (debugging difficulty) was covered in Intermediate #6. A common pattern: distroless for production, a regular base for the debug companion — two images of the same arch.
5) Chainguard Images — another minimal base #
A strong alternative to distroless. Minimal images frequently updated, made by Chainguard.
| Image | |
|---|---|
cgr.dev/chainguard/static | similar to distroless static |
cgr.dev/chainguard/python | minimal Python |
cgr.dev/chainguard/node | minimal Node |
cgr.dev/chainguard/nginx | minimal Nginx |
The hallmark is fast CVE update cadence, and SBOM / signatures shipping by default. They satisfy both this post’s themes and the next (#4)’s SBOM / signing in one base. Docker official images suffice in many cases, but you’ll see Chainguard often in environments with strict security gates.
6) Trivy — scanning for known CVEs #
A tool that finds known vulnerabilities (CVEs) in the packages baked into an image. Practically the standard.
brew install aquasecurity/trivy/trivy
# Or directly via Docker
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image myapp:1.0trivy image myapp:1.0Sample output:
myapp:1.0 (debian 12.5)
======================
Total: 8 (LOW: 1, MEDIUM: 4, HIGH: 2, CRITICAL: 1)
┌──────────────┬───────────────┬──────────┬──────────────────┬───────────────┬─────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version│ Fixed Version │ Title │
├──────────────┼───────────────┼──────────┼──────────────────┼───────────────┼─────────────────────────────┤
│ libssl3 │ CVE-2024-... │ HIGH │ 3.0.11-1 │ 3.0.13-1 │ ... │
│ ... │ │ │ │ │ │
└──────────────┴───────────────┴──────────┴──────────────────┴───────────────┴─────────────────────────────┘CI gate pattern #
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:1.0--exit-code 1— exit code 1 on findings (CI sees a failure)--severity— minimum severity to count--ignore-unfixed— exclude unfixable findings (so you aren’t blocked by things you can’t patch)
Pairs nicely with GitHub Actions:
- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/me/myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifSARIF output uploads results to the GitHub Security tab.
Other scanners #
- Grype — Anchore’s tool. Similar features, similar usage.
- Snyk — managed scan SaaS with rich integrations.
- Docker Scout — built into Docker Desktop.
They mostly read the same CVE DB — pick by environment / preference. The big jump is running any of them in CI, not which one.
7) Hadolint — Dockerfile lint #
Image security issues often start in how the Dockerfile is written. Hadolint catches that statically.
brew install hadolinthadolint DockerfileSample output:
Dockerfile:5 DL3008 warning: Pin versions in apt-get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:7 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:9 DL3018 warning: Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
Dockerfile:12 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
Dockerfile:20 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT argumentsCommon rules:
| Rule | Meaning |
|---|---|
| DL3008 | Pin apt package versions |
| DL3018 | Pin apk package versions |
| DL3009 | Missing rm -rf /var/lib/apt/lists/* |
| DL3015 | Recommend --no-install-recommends |
| DL3025 | CMD/ENTRYPOINT in exec form |
| DL3002 | USER not dropped to non-root |
| DL3007 | Base image uses latest |
A single hadolint Dockerfile line in CI noticeably improves base-image hygiene.
Security checklist #
A checklist worth running over a production container:
□ FROM with an explicit version tag (no latest)
□ Multi-stage to separate build tools ([#1])
□ USER dropped to non-root
□ COPY --chown for explicit ownership
□ apt cache cleaned within the same RUN
□ CMD / ENTRYPOINT in exec form
□ Build-time secrets via --mount=type=secret ([Intermediate #5])
□ hadolint passes
□ Trivy passes for HIGH/CRITICAL□ read_only: true + tmpfs (where feasible)
□ cap_drop: ALL + minimal cap_add
□ security_opt: no-new-privileges:true
□ Resource limits: mem_limit, cpus ([#5])
□ healthcheck defined ([Intermediate #4])
□ restart: unless-stopped ([Intermediate #4], [#6])
□ Secrets via secrets: or external manager ([Intermediate #5])
□ Bind -p to 127.0.0.1 for internal services like a DBWrap-up #
The picture from this post:
- Container security is runtime isolation + image hygiene, two halves.
USERdropping to non-root — biggest impact, lowest cost.read_only: true+ tmpfs,cap_drop: ALL,no-new-privilegesare safe defaults for production containers.- distroless / Chainguard narrow the attack surface — debugging is the trade-off.
- Trivy / Grype / Docker Scout as a CI gate for known CVEs.
- Hadolint lints the Dockerfile itself — a handful of rules catch a lot.
- A per-container checklist split across build / runtime.
In the next post (#4 SBOM and signing (cosign)) we go a step further — generating a machine-readable bill of materials (SBOM) for what’s inside the image, and verifying who built it with cosign signatures. The entry to supply-chain security.