I created lints Dockerfiles, not Compose files. or the CIS Docker Benchmark. The full methodology, including what the study deliberately doesn't claim, is in the
Filesystem not read-only (read_only: truemissing) — 91%
No capability restrictions (cap_drop: [ALL]missing) — 91%
Privilege escalation not blocked (no-new-privilegesmissing) — 90%
They're rated MEDIUM, not CRITICAL, because each one is a missing control rather than active misuse. That's also what makes them interesting. The Compose hardening triple is almost never set.
The fix takes about 30 seconds per service:
services:
app:
image: nginx:1.27@sha256:... # pin a digest, not just a tag
read_only: true # CL-0007
cap_drop: [ALL] # CL-0006
security_opt:
- no-new-privileges:true # CL-0003
Add a tmpfs: for whatever paths your app writes to and you've cleared the three most common findings in the corpus.
"Aren't these just optional config choices, not real vulnerabilities?" Mostly fair, and it's worth being exact about what a "finding" is here. The linter isn't claiming anything was exploited. It's flagging that a file leaves a recommended hardening control unset. Whether that matters is a judgment call that belongs to the org or the engineer, based on their context and risk tolerance. What the linter takes off your plate is the "did I even know this control existed?" part. read_only, cap_drop: [ALL], and no-new-privileges aren't my preferences about tidy YAML; they're named controls in the
A few things jump out:
Self-hosted app-store templates: 100%. Every single one trips at least one rule. They're built for "works on your LAN in one click," which in practice means exposed ports, root users, and big host mounts.
Popular repos aren't better than the long tail. Stars don't buy hardening discipline.
Canonical examples are config demos, not hardening exemplars. People copy them into production anyway.
That last point is most of the story. The examples teach the unhardened shape, and the shape propagates.
A fair caveat here, especially if you're running a homelab: threat model matters. A single-user box behind a firewall and Tailscale is a different risk calculus than something exposed to the internet, and a finding is usually something to decide about rather than an emergency. Start with what actually bites. A mounted Docker socket is full host takeover whether or not you meant to expose it, so fix those first and treat the MEDIUM pile as gradual cleanup. It's why the CI gate defaults to fail-on: high.
Finding 3: ~10% of ordinary files don't even parse
This is the one I didn't expect. In the long-tail tier, 9.6% of files don't parse as a valid Compose file at all, against well under 1% everywhere else:
Nearly four out of five findings are MEDIUM. That's the hardening-triple misses from Finding 1, which hit almost every file and are MEDIUM by design. CRITICAL findings are rarer but real: a mounted Docker socket, cap_add: ALL, a bind-mounted /. The Docker socket one alone, which is full host takeover, shows up on 6.4% of parsed files and 8% in the popular tier.
LOW is almost empty, and that's by construction. Only one of compose-lint's 21 rules is LOW (a healthcheck someone explicitly turned off), because the tool's whole scope is security misconfiguration, where the floor is MEDIUM. So read "0.0% LOW" as a fact about the tool, not as the small stuff being fine.
So why are all the flags off by default?
Because Docker optimizes for "it runs the first time." A writable filesystem, the full capability set, privilege escalation left on: that's the path of least surprise. Your container starts, your app works. Hardening is opt-in, and opting in means knowing the control exists, confirming your app still works without that capability or that write access, and adding a few lines per service.
And "knowing it exists" is the hard part. There's no single secure-Compose baseline to copy from. The controls are scattered across the Compose spec, the Docker run reference, the section in the report lays out every boundary.
Try it on your own files
compose-lint is MIT-licensed, zero-config, and depends only on PyYAML. A few ways to run it:
# one-off, locally
pipx install compose-lint && compose-lint docker-compose.yml
# or the published image (distroless, nonroot)
docker run --rm -v "$(pwd):/src" composelint/compose-lint
In CI, there's a GitHub Action:
# .github/workflows/compose-lint.yml
name: compose-lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: tmatens/[email protected]
with:
pattern: "**/*compose*.y*ml" # docker-compose.yml, compose.yaml, …
fail-on: high
fail-on: high (the default) fails only on HIGH/CRITICAL, so you can adopt it without drowning in the MEDIUM backlog on day one, then tighten later. There's also a pre-commit hook, JSON and SARIF output (SARIF feeds GitHub Code Scanning), and compose-lint --explain CL-0007 to print any rule's rationale and fix.
For what it's worth on a tool you'd wire into CI: every rule cites OWASP, CIS, or Docker docs, the image is distroless and nonroot, and releases ship SLSA provenance and Sigstore attestations. Details are in the
If you maintain a popular Compose example, I'd genuinely love a PR or an issue. Hardening the examples people copy is the highest-leverage fix there is.
SOCIAL SHARE CARD GENERATOR