Docker Advanced #3: Image Security — non-root, distroless, Trivy Scans

8 min read

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.

Two halves of security
   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:

Dropping USER
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 time
  • USER app — every following command (especially CMD) runs as that user

When the base image already has a non-root user #

Many common bases ship with one:

ImagePre-existing user
node:*node (UID 1000)
nginx:*nginx
postgres:*postgres (sometimes the entrypoint handles it)
gcr.io/distroless/*nonroot (UID 65532)
node — one liner
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:8000 to the host.
  • Can’t write /var/log/xxx — directories created by root aren’t writable. chown them or log to stdout (Intermediate #6).
  • pip install ran 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
docker run --read-only myapp
compose.yaml
services:
  web:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp        # /tmp as a writable in-memory mount
    volumes:
      - app-data:/app/data    # persistent data → named volume

read_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.

Drop all capabilities
docker run --cap-drop=ALL myapp
compose.yaml
services:
  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 user
  • CHOWN, 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.

Block privilege escalation
docker run --security-opt no-new-privileges myapp
compose.yaml
services:
  web:
    security_opt:
      - no-new-privileges:true

setuid 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/sh makes interactive shell access hard
  • No package manager — can’t apt/yum in tools
  • No coreutils — no cat, ls, wget, curl
  • nonroot user pre-provisionedUSER nonroot:nonroot is one line
Go + distroless
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/staticsimilar to distroless static
cgr.dev/chainguard/pythonminimal Python
cgr.dev/chainguard/nodeminimal Node
cgr.dev/chainguard/nginxminimal 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.

Install (Homebrew)
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.0
Basic scan
trivy image myapp:1.0

Sample output:

trivy 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 #

Fail the build on HIGH or higher
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:

.github/workflows/scan.yml (excerpt)
- 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.sarif

SARIF 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.

Install
brew install hadolint
Run
hadolint Dockerfile

Sample output:

hadolint result
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 arguments

Common rules:

RuleMeaning
DL3008Pin apt package versions
DL3018Pin apk package versions
DL3009Missing rm -rf /var/lib/apt/lists/*
DL3015Recommend --no-install-recommends
DL3025CMD/ENTRYPOINT in exec form
DL3002USER not dropped to non-root
DL3007Base 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:

Image / Dockerfile
□ 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
Runtime / compose.yaml
□ 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 DB

Wrap-up #

The picture from this post:

  • Container security is runtime isolation + image hygiene, two halves.
  • USER dropping to non-root — biggest impact, lowest cost.
  • read_only: true + tmpfs, cap_drop: ALL, no-new-privileges are 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.

X