Skip to main content

compose-lint

Security-focused linter for Docker Compose files. Catches dangerous misconfigurations before they reach production — and auto-fixes the unambiguous ones, dry-run first. Grounded in OWASP and the CIS Docker Benchmark.

CI PyPI Docker Docs Python License OpenSSF Scorecard OpenSSF Best Practices Mentioned in Awesome Docker

In a scan of 11,111 public Compose files on GitHub, 99% had at least one security finding, and more than one in four carried a literal credential. Read the State of Docker Compose Security report →

compose-lint scanning a docker-compose.yml with two services: under service: watchtower, a CRITICAL mounted Docker socket (CL-0001) with a box-drawing underline, fix block and reference URL, above a MEDIUM image pinned to a tag but not a digest (CL-0019); then under service: db, a HIGH plaintext credential (CL-0020) with POSTGRES_PASSWORD: hunter2 underlined — then the FAIL verdict, and compose-lint --explain CL-0001 reading the offline rule docs in its built-in pager: the title, severity derivation and references hold on the first page, the status line naming the controls — CL-0001 · Space next · b back · q quit — then a page-down continues into the doc, prompt still in place.

What it catches:

  • Privilege flaws — privileged: true, missing cap_drop, no-new-privileges not set, root user, host namespace sharing
  • Network exposure — wildcard port binds, network_mode: host
  • Supply-chain — unpinned images, missing digest pins
  • Filesystem and credential leaks — Docker socket mounts, sensitive host paths, plaintext credentials in environment:

Zero config, sub-second whether you lint one file or a hundred, and grounded in the OWASP Docker Security Cheat Sheet and CIS Docker Benchmark. Full rule docs at tmatens.github.io/compose-lint — the same pages --explain prints offline.

Installation

pip

pip install compose-lint

Or run it ad hoc without installing anything:

uvx compose-lint docker-compose.yml        # or: pipx run compose-lint docker-compose.yml

That resolves the newest release at install time. For a reproducible install (CI, production tooling), pin the version and install the dependency set from the repo's hash-pinned lockfile, which release automation keeps current:

curl -fsSLO https://raw.githubusercontent.com/tmatens/compose-lint/v0.29.0/requirements.lock
pip install --require-hashes -r requirements.lock   # dependencies, hash-pinned
pip install --no-deps compose-lint==0.29.0          # the tool, version-pinned

Every pip path needs Python 3.11+; the Docker image is self-contained.

Dockercomposelint/compose-lint

docker run --rm -v "$(pwd):/src" composelint/compose-lint:0.29.0

The Docker image is distroless, multi-arch, and runs nonroot — see Security posture below for SLSA, Sigstore, and OpenVEX details.

Running with full hardening

Want to dogfood compose-lint's own rules against the container that runs it? See the hardening guide for the fully-hardened docker run invocation, the flag-to-rule mapping, and digest-pinning instructions.

Quick Start

Run without arguments to auto-detect compose.yml, compose.yaml, docker-compose.yml, or docker-compose.yaml in the current directory:

compose-lint

Or pass files explicitly:

compose-lint docker-compose.yml docker-compose.prod.yml

Preview the auto-fixable findings as a unified diff, then apply them — reading the ⚠ behavior-changing labels first (see Fixing findings):

compose-lint fix              # dry-run diff, writes nothing
compose-lint fix --apply      # write the fixes in place

Don't recognize a rule ID in the output? --explain prints the full rule doc — what it catches, why it matters, the fix, and the OWASP/CIS reference — without leaving the terminal:

compose-lint --explain CL-0005

Docker equivalent:

docker run --rm -v "$(pwd):/src" composelint/compose-lint:0.29.0 docker-compose.prod.yml

Adopting on an existing repo

Most established stacks don't start clean. compose-lint init turns a file's current findings into a .compose-lint.yml baseline, so the gate can go in today and you triage afterwards:

compose-lint init docker-compose.yml          # writes ./.compose-lint.yml
compose-lint init docker-compose.yml -o ci.yml # write somewhere else
compose-lint init docker-compose.yml --force   # overwrite an existing config

Each finding becomes a per-service exclude_services entry with a TODO reason — never a global enabled: false, so a service you add later still trips the rule. Replace each reason with a real justification, or delete the entry and fix the issue. Details: generating a starter config.

Example Output

Given this docker-compose.yml:

services:
  traefik:
    image: traefik:v3.0@sha256:aaaabbbbccccddddeeeeffff00001111222233334444555566667777888899990
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 256m
    cpus: 0.5
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "8080:80"

  db:
    image: postgres:16@sha256:bbbbccccddddeeeeffff000011112222333344445555666677778888999900001
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 1g
    cpus: 1.0
    environment:
      POSTGRES_PASSWORD: hunter2
    volumes:
      - pgdata:/var/lib/postgresql/data
    tmpfs:
      - /tmp
      - /run

volumes:
  pgdata:

and this .compose-lint.yml (suppressing CL-0001 for traefik with a tracked reason):

rules:
  CL-0001:
    exclude_services:
      traefik: "SEC-1234 approved  socket proxy planned for 2026-Q3"

running compose-lint docker-compose.yml produces:

files: docker-compose.yml  ·  config: .compose-lint.yml  ·  fail-on: high

docker-compose.yml

  service: traefik  (line 10)
    line  severity    rule     message
      10  SUPPRESSED  CL-0001  Docker runtime socket mounted via '/var/run/docker.sock:/var/run/docker.sock'. This gives the container full control over the Docker runtime — equivalent to root on the host.
          reason: SEC-1234 approved — socket proxy planned for 2026-Q3
      12  MEDIUM      CL-0005  Port '8080:80' is bound to all interfaces. Docker bypasses host firewalls (UFW/firewalld), potentially exposing this port to the public internet.
          12 │       - "8080:80"
             │          ───────
          fix: Bind to localhost: 127.0.0.1:8080:80
               If public access is needed, use a reverse proxy with TLS.
          ref: https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-5a-be-careful-when-mapping-container-ports-to-the-host-with-firewalls-like-ufw

  service: db  (line 22)
    line  severity    rule     message
      22  HIGH        CL-0020  Service has credential-shaped env key 'POSTGRES_PASSWORD' with a literal value. Env vars are exposed via `docker inspect`, `/proc/<pid>/environ`, `docker compose config`, process listings, and CI logs — any process or operator with daemon access can read them.
          22 │       POSTGRES_PASSWORD: hunter2
             │       ─────────────────
          fix: Move 'POSTGRES_PASSWORD' to Compose's `secrets:` primitive. If the image supports the `*_FILE` convention (Postgres, MySQL, MariaDB, MinIO, etc.), set `POSTGRES_PASSWORD_FILE: /run/secrets/<name>` and declare the secret under the top-level `secrets:` block sourced from a gitignored file or `external: true`. Otherwise, have the entrypoint read the secret file at startup and export the value into the workload's environment.
          ref: https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-12-utilize-docker-secrets-for-sensitive-data-management
docker-compose.yml: 1 high, 1 medium  ·  1 suppressed (not counted)
✗ FAIL  ·  1 finding at or above high

Exit code is 1: one finding at or above the default --fail-on high threshold. Suppressed findings are shown but not counted. That file is synthetic; for worked remediations of real stacks, see the examples gallery.

Grades what actually deploys

For a single Compose file with no siblings, a run reads that file and nothing else. When there is more, compose-lint grades the configuration Compose would actually run, not just the file you named:

  • It merges what Compose merges. The sibling compose.override.yml, the sibling .env (for ${VAR} references and COMPOSE_FILE, never for environment: values), env_file: targets, and include: / cross-file extends: are all resolved, so a socket mount hidden behind a variable or an override is graded as the mount it deploys.
  • It never reads outside the project. Every document has to resolve inside the named file's own directory. The ambient shell environment is not read, and no registry, daemon, or image is consulted, so the same checkout lints the same on every machine.
  • A part of the stack it cannot see is exit 2, not a silent pass. A reference that is missing, interpolated, or leaves the project is reported as a coverage gap. Lint the docker compose config output to cover it, or pass --allow-partial-coverage to grade what is visible.

Any Compose Specification file works: one with a top-level services: key, or an include:-only root. Compose v1 files (services at the top level, retired by Docker in 2023) and structural fragments with no services are skipped with a stderr note rather than failed.

Full detail, including the merge order and the flags that switch each source off (--no-merge-overrides, --no-env): What a run reads.

Rules

ID Severity Description Auto-fix OWASP CIS
CL-0001 CRITICAL Host control socket exposed Rule #1 5.32
CL-0002 CRITICAL Privileged mode enabled Rule #3 5.5
CL-0003 MEDIUM Privilege escalation not blocked Rule #4 5.26
CL-0004 MEDIUM Image not pinned to version Rule #13 5.28
CL-0005 MEDIUM Ports bound to all interfaces Rule #5a 5.14
CL-0006 MEDIUM No capability restrictions Rule #3 5.4
CL-0007 LOW Filesystem not read-only Rule #8 5.13
CL-0008 HIGH Host network mode Rule #5 5.10
CL-0009 HIGH Security profile disabled Rule #6 5.2, 5.3, 5.22
CL-0010 HIGH Host namespace sharing Rule #3 5.16, 5.17, 5.21, 5.31
CL-0011 HIGH Strong host-adjacent capability added Rule #3 5.4
CL-0013 HIGH Sensitive host path exposed Rule #8 5.6
CL-0014 LOW Logging driver disabled
CL-0016 CRITICAL Dangerous host device exposed 5.18
CL-0017 LOW Shared mount propagation 5.20
CL-0018 MEDIUM Explicit root user Rule #2
CL-0019 MEDIUM Image tag without digest Rule #13
CL-0020 HIGH Credential-shaped env key with literal value Rule #12
CL-0021 HIGH Credential embedded in connection-string env value Rule #12
CL-0022 LOW tmpfs mount re-enables exec/suid Rule #8
CL-0024 CRITICAL Host-code-execution capability added Rule #3 5.4
CL-0025 CRITICAL Root-equivalent host path mounted writable Rule #8 5.6
CL-0026 MEDIUM No resource limits (memory/CPU) Rule #7 5.10, 5.11
CL-0027 MEDIUM Bounded-grant capability added Rule #3 5.4
CL-0028 HIGH Host-reaching capability added Rule #3 5.4
CL-0029 HIGH Host-availability capability added Rule #3 5.4
CL-0030 HIGH Host-disclosure capability added Rule #3 5.4

Rules marked ✅ have a mechanically unambiguous remediation that compose-lint fix applies for you, dry-run first — see Fixing findings. Every other rule reports specific fix guidance for a change only you can choose, and is never auto-edited.

The gaps in the numbering — CL-0012, CL-0015, CL-0023 — are retired ids kept fallow forever: reusing one would silently change the meaning of a suppression someone has already written (ADR-005, ADR-028).

Severity Levels

Findings are rated LOW, MEDIUM, HIGH, or CRITICAL. Each rule's severity is derived from a two-axis matrix — the attacker precondition the misconfiguration creates, and the impact scope it reaches — under a stated attacker baseline and a stated Docker posture. See docs/severity.md for the full scoring matrix, the derivation of every rule, and the override mechanism.

Configuration

Create .compose-lint.yml to disable rules, exclude specific services, or adjust severity:

rules:
  CL-0001:
    enabled: false
    reason: "SEC-1234  approved 2026-07-01"
  CL-0003:
    exclude_services:
      minecraft: "entrypoint switches users via su-exec"
  CL-0005:
    severity: medium

Suppressed findings still appear, marked SUPPRESSED, with the reason carried into JSON and SARIF; they do not affect the exit code, and --skip-suppressed hides them. A severity: override is reported as such. See docs/configuration.md for per-service semantics, precedence, and the output-format mapping.

CLI Reference

Three subcommands: check (the default — a bare compose-lint works), fix, and init. Every flag is described in compose-lint --help and the CLI reference, along with color control (NO_COLOR / FORCE_COLOR) and end-of-options semantics. Text output prints each rule's fix block once per file; -v repeats it on every finding, -q gives one line per finding.

Fixing findings

compose-lint fix auto-remediates the findings whose edit is mechanically unambiguous — one correct value, in one place, with no collateral change to the rest of the file: adding read_only: true or no-new-privileges:true, binding a published port to 127.0.0.1, restoring a disabled logging driver or seccomp profile, and similar. It is dry-run by default: it prints a unified diff and writes nothing.

compose-lint fix on a docker-compose.yml: the dry-run prints three behavior-changing caveat lines (CL-0009's re-applied seccomp profile, CL-0007's read_only, CL-0005's rebind to 127.0.0.1) above a unified diff adding read_only: true, replacing seccomp:unconfined with no-new-privileges:true, and rebinding "8080:8080" to "127.0.0.1:8080:8080", summarised as 3 fixes available with 1 finding needing manual review — then fix --apply writes the same three edits and compose-lint check re-lints to a PASS verdict, the un-auto-fixable tag-only image pin (CL-0019) still reported below the threshold.

Auto-fixable does not mean harmless. fix will not corrupt or reflow your file, but it will change how your stack behaves: read_only: true breaks a container that writes to its root filesystem, and rebinding a port to 127.0.0.1 cuts off remote clients. Each such edit is labelled ⚠ behavior-changing in the diff with the breakage named. Read those lines before --apply, and roll out to staging first.

compose-lint fix docker-compose.yml            # preview the diff, write nothing
compose-lint fix --apply docker-compose.yml    # write the fixes in place
compose-lint fix --only CL-0007 --apply .      # restrict to one rule

Context-dependent findings (capability lists, socket mounts) are reported for manual review, and a region using YAML anchors, merge keys, or ${VAR} interpolation is refused rather than guessed. The full contract is in the fix guide.

How it compares

Tool Compose security rules Auto-fix Scope Zero config
compose-lint Yes Yes — dry-run diff first Docker Compose Yes
KICS Yes Yes (remediate command) Broad IaC (Terraform, K8s, Compose, ...) No
Hadolint No — Dockerfile only No Dockerfile Yes
dclint Yes — schema/structure only Style/formatting only Docker Compose Yes
Trivy No — image/CVE + IaC misconfig scanning, no dedicated Compose ruleset No Dockerfiles, images, IaC Yes
Checkov No — no dedicated Compose ruleset No Broad IaC (Terraform, K8s, ...) No

A capability snapshot, verified July 2026 — check each tool's docs for current state.

Not in scope: compose-lint does not validate Compose schema, scan images for CVEs, or lint Dockerfiles. Pair it with dclint for schema/structure, Hadolint for Dockerfiles, and Trivy for image CVEs.

Versioning & stability

compose-lint follows Semantic Versioning. From 1.0, the CLI, exit codes, config schema, and JSON/SARIF output are stable. New and tightened rules ship in MINOR releases, so pin a version or use --fail-on if you need deterministic CI. See docs/compatibility.md for the full stability promise and deprecation policy. Release-by-release changes are in CHANGELOG.md; planned work is on the roadmap.

Exit Codes

Code Meaning
0 No findings at or above the --fail-on threshold
1 One or more findings at or above the --fail-on threshold
2 compose-lint couldn't run, or couldn't see the whole stack (invalid args, file not found, invalid Compose file, a rule crashed, or a coverage gap — see Grades what actually deploys)

The default threshold is high — medium and low findings don't fail CI unless you opt in:

compose-lint --fail-on low docker-compose.yml   # fail on everything
compose-lint --fail-on critical docker-compose.yml  # only critical

CI Integration

GitHub Actions

Runs compose-lint and uploads findings to GitHub Code Scanning:

# .github/workflows/lint.yml
name: Compose Lint
on: [push, pull_request]

permissions: {}

jobs:
  compose-lint:
    runs-on: ubuntu-latest
    permissions:
      contents: read          # checkout
      security-events: write  # upload the SARIF to Code Scanning
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: tmatens/compose-lint@d0434054779e9026c6082bc47ecc818ec2aa981d # v0.28.0
        with:
          sarif-file: results.sarif

The uses: line pins a commit SHA, which is what OpenSSF Scorecard grades for and what Renovate keeps fresh; from 1.0, tmatens/compose-lint@v1 floats with releases instead. The permissions: blocks are part of the recipe: the job holds only the two scopes it uses. If you want the SARIF file without the Code Scanning upload, set upload-sarif: "false" and drop security-events: write. Every input, and the reasoning behind the pin and the permissions, is in the GitHub Action guide.

Or install from PyPI directly:

      - uses: actions/setup-python@v6
        with:
          python-version: "3.13"
      - run: pip install compose-lint
      - run: compose-lint docker-compose.yml

Forgejo Actions

compose-lint runs on Forgejo Actions too, with two practical differences (cross-instance action URLs and a checkout/node quirk in job containers). The recipe is in the Forgejo guide, and the weekly forgejo-smoke workflow runs it against a live Forgejo.

SARIF output

compose-lint --format sarif docker-compose.yml > results.sarif

Pre-commit

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/tmatens/compose-lint
    rev: v0.29.0
    hooks:
      - id: compose-lint

The hook ships args: [--], and setting args: replaces that default. Keep -- last if you pass flags, so a repository path can never be read as an option (why):

      - id: compose-lint
        args: [--fail-on, low, --]

Agent-written Compose

If a coding agent writes Compose in your repo, the two gates above already cover it: the pre-commit hook catches it before the commit, the Action before the merge, on the same terms as anyone else's. If you are driving compose-lint from an agent or script, parse --format json (a versioned envelope) and read Automation and agent use, which covers what agents get wrong: exit 2 is a coverage gap, fix is a dry run, there are no inline suppressions, and --explain works offline.

Security posture

compose-lint is built to be safe to depend on:

  • Runtime image: distroless Python on Debian, multi-arch (linux/amd64 + linux/arm64), nonroot UID 65532, no shell or package manager at runtime. See ADR-009.
  • Supply chain: every release ships SLSA build provenance and Sigstore attestations. Published to PyPI via Trusted Publishers (OIDC) — no manual twine upload, no long-lived API tokens.
  • Vulnerability transparency: each release ships an OpenVEX document declaring known pip CVEs not_affected: pip code is stripped from the runtime image.
  • External audit: tracked on OpenSSF Scorecard and OpenSSF Best Practices Baseline 2; CodeQL runs on every PR, ClusterFuzzLite fuzzes code-touching PRs, and Docker Scout scans the published image daily.
  • Reporting vulnerabilities: see SECURITY.md.

Contributing

See CONTRIBUTING.md for development setup and how to add rules.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

compose_lint-0.29.0.tar.gz (4.2 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

compose_lint-0.29.0-py3-none-any.whl (334.4 kB view details)

Uploaded Python 3

File details

Details for the file compose_lint-0.29.0.tar.gz.

File metadata

  • Download URL: compose_lint-0.29.0.tar.gz
  • Upload date:
  • Size: 4.2 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for compose_lint-0.29.0.tar.gz
Algorithm Hash digest
SHA256 60c707f9c89d7de297baa37ad2eaa66c48fd85cb004fd7cd2fd1d0351787045b
MD5 528a2267e9e2f9da199731727a48703a
BLAKE2b-256 c809a5c59f9184e3bb7a0a69201084d0d1fc0836ce06963acd30bab092c1f386

See more details on using hashes here.

Provenance

The following attestation bundles were made for compose_lint-0.29.0.tar.gz:

Publisher: publish.yml on tmatens/compose-lint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file compose_lint-0.29.0-py3-none-any.whl.

File metadata

  • Download URL: compose_lint-0.29.0-py3-none-any.whl
  • Upload date:
  • Size: 334.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for compose_lint-0.29.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3d7925b1c4a46279478131f6182287a73a041f983554af5de803be527fffad39
MD5 7e167a3af266208844cdd970a2175980
BLAKE2b-256 a3fbcf4e4fb78ecaefe6b385ccabaaa0c5eb0cd534d32af8ab7c4f1bac744808

See more details on using hashes here.

Provenance

The following attestation bundles were made for compose_lint-0.29.0-py3-none-any.whl:

Publisher: publish.yml on tmatens/compose-lint

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.29.0 This release

2 files

0.28.0

2 files

0.27.0

2 files

0.26.0

2 files

0.25.0

2 files

0.24.0

2 files

0.23.0

2 files

0.22.0

2 files

0.21.0

2 files

0.20.0

2 files

0.19.0

2 files

0.18.0

2 files

0.17.0

2 files

0.16.0

2 files

0.15.2

2 files

0.15.1

2 files

0.15.0

2 files

0.14.1

2 files

0.14.0

2 files

0.13.0

2 files

0.12.2

2 files

0.12.1

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.0

2 files

0.8.0

2 files

0.7.1

2 files

0.7.0

2 files

0.6.0

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.3.7

2 files

0.3.6

2 files

0.3.5

2 files

0.3.4

2 files

0.3.3

2 files

0.3.2

2 files

0.3.0

2 files

0.2.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page