Configuration¶
compose-lint reads .compose-lint.yml from the current working directory by default, and .compose-lint.yaml when that name is absent. If both exist, .compose-lint.yml is used and a stderr warning says so (an error under --strict-config, see Validation) — two policy files side by side is almost always one being edited while the other is in force. Use --config PATH to point at a different file. compose-lint init writes .compose-lint.yml.
Running in Docker: mount the directory, not the file. The image's working directory is
/src, so the config has to be inside it:
bash docker run --rm -v "$(pwd):/src" composelint/compose-lint # config found docker run --rm -v "$(pwd)/docker-compose.yml:/src/docker-compose.yml" \ composelint/compose-lint # config NOT foundThe second form leaves
.compose-lint.ymloutside the container, so every suppression is silently absent and findings you disabled come back. compose-lint cannot tell that apart from having no config at all, so it does not fail — but when a run reports findings with no config in effect it says so on stderr, naming the directory it looked in.
Which files get linted¶
Given explicit paths, compose-lint lints exactly those. With no arguments it looks in the current directory only. A COMPOSE_FILE list in the .env there chooses the documents, as it does for Compose (What a run reads); without one, it looks for exactly four names:
compose.yml compose.yaml docker-compose.yml docker-compose.yaml
Finding none is an error (exit 2), not a pass — a gate reporting success over an unlinted repository is the one outcome this tool must never produce.
The pre-commit hook is deliberately broader. It selects any path matching (^|/)(docker-)?compose([._-][^/]*)?\.ya?ml$ — a name that is compose or docker-compose, optionally followed by ., - or _ and a suffix — so compose.prod.yml, docker-compose.override.yml and stack/compose.yml are linted by the hook and not by a bare compose-lint run:
| path | compose-lint / GitHub Action |
pre-commit hook |
|---|---|---|
compose.yml |
linted | linted |
compose.prod.yml |
not found | linted |
docker-compose.override.yml |
merged into whichever of the four names exists (when there are several overrides, Compose's own pick: compose.override.yml, then .yaml, then this pair) |
linted |
stack/compose.yml |
not found | linted |
compose2.yml, composed.yml |
not found | skipped: no separator after compose |
The asymmetry is intentional. pre-commit hands the hook a filename-filtered list of files you actually changed, so matching broadly is useful and safe. A bare compose-lint has no such list and must not guess which of a repository's YAML files are Compose files.
What this means for you: if your Compose files are not among the four default names, pass them explicitly (compose-lint compose.prod.yml) — the CLI has no glob flag, so expand one in your shell (compose-lint compose.*.yml) or let your runner do it. The GitHub Action does take a glob, via its pattern: input (files: for explicit paths). Otherwise a repository that passes pre-commit can report "no Compose files found" in CI.
tests/test_discovery_parity.py holds all three surfaces to this table, so the hook can never become narrower than the CLI and the Action's list cannot drift from it.
Generating a starter config¶
Rather than hand-author the file from this page, run compose-lint init to turn a stack's current findings into a .compose-lint.yml you then triage:
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
Every finding becomes a per-service exclude_services entry with a placeholder reason for you to fill in or delete:
rules:
CL-0001: # CRITICAL — Host control socket exposed
exclude_services:
proxy: "TODO: justify or fix"
CL-0007: # LOW — Filesystem not read-only
exclude_services:
web: "TODO: justify or fix"
worker: "TODO: justify or fix"
- Per-service, not global.
initnever writesenabled: false; it names the exact services where each rule fired, so a service you add later still trips the rule instead of being silently uncovered. - All severities are included and annotated; review the CRITICAL and HIGH entries first and prefer fixing over suppressing.
- It refuses to overwrite an existing
.compose-lint.ymlwithout--force, so a generated file can't clobber suppressions you've already triaged. - It grades what
checkgrades. The siblingcompose.override.ymlis merged, the.envbeside the file is consulted forCOMPOSE_FILE, and everyenv_file:a service names is read — exactly ascheckdoes — so the baseline covers the findings the gate will actually see.--no-merge-overridesand--no-envnarrowinitthe same way they narrowcheck; baseline with whichever flags you gate with. - A clean file writes nothing —
initreports that there is nothing to suppress and exits 0. - Status goes to stderr;
inittakes a singleFILE(no directory discovery).
The generated file is a starting point. Replace each TODO reason with a real justification (enabled: false plus a reason is the right shape if a rule is universally inapplicable), or delete entries you intend to fix.
Disabling and adjusting rules¶
rules:
CL-0001:
enabled: false
CL-0003:
enabled: false
reason: "SEC-1234 — Approved by J. Smith, expires 2026-07-01"
CL-0005:
severity: medium
enabled: false keeps the rule running but marks every finding SUPPRESSED. Suppressed findings do not count toward the --fail-on threshold but remain visible for auditability. The reason field is surfaced in every output format:
- Text: shown after the
SUPPRESSEDlabel. - JSON:
suppression_reasonfield on each finding. - SARIF:
suppressions[].justification(recognized by GitHub Code Scanning).
With no reason, JSON has no suppression_reason key and the SARIF
suppression has no justification, so a present reason always means a person
wrote one. The text report still says where the suppression came from
(disabled in .compose-lint.yml).
severity: re-grades a rule's findings rather than suppressing them, and it
leaves its own record so a reader can tell a re-graded finding from one the rule
declared that way. Re-grading is the quietest way to neutralise a rule — three
lines can take a CRITICAL below the default gate — so it is reported:
- Text:
(severity overridden from critical)after the finding. - JSON:
severity_overridden_fromon the finding (absent when not overridden). - SARIF:
properties.severityOverriddenFromon the result.
Re-stating a rule's own severity records nothing, because nothing changed.
severity: and enabled: false on the same rule do not combine: a disabled
rule's findings are all suppressed, and a suppressed finding is never
re-graded, so the severity: is inert. compose-lint warns rather than
silently keeping one of the two (see Validation); the value is
still checked and kept, so re-enabling the rule later needs no second edit.
Duplicate keys are rejected. Listing the same rule twice is a config error rather than a last-wins merge: a policy file that disables a rule "with a reason" and then re-enables it further down reads, to a human, as the first entry — and used to behave as the second.
To hide suppressed findings entirely:
compose-lint --skip-suppressed docker-compose.yml
Per-service exclusions¶
When a rule is valid for some services but architecturally incompatible with others (e.g. CL-0003 no-new-privileges and an image whose entrypoint switches users), use exclude_services to suppress it only where needed:
rules:
CL-0003:
exclude_services:
minecraft: "entrypoint switches users via su-exec"
backup: "forks as different user"
CL-0007:
exclude_services:
- legacy-worker # list form when no reason is needed
Excluded services still produce SUPPRESSED findings, with the per-service reason flowing to suppression_reason / SARIF justification — same shape as a global disable.
Behaviour¶
- Exact-match service names. A name that no linted file defines warns on stderr and is an error under
--strict-config; by default it does not change the exit code, since Compose files and config evolve independently and a rename must not break the linter. The check runs after the scan, because only then is the set of services known. - Global
enabled: falsewins over per-service exclusions: if a rule is disabled globally, every service is suppressed regardless ofexclude_services. - No inline suppression syntax — there is no
# compose-lint: disablecomment form. Suppressions are tracked in config so reviewers can audit them.
Validation¶
A .compose-lint.yml that silently fails to take effect is a security risk — the user believes a rule is suppressed or re-tuned when it is not. compose-lint validates the file on load:
- Unknown rule IDs warn.
rules:keys are checked against the registered rule set. A typo (CL-001) or a retired ID (CL-9999) prints a stderr warning so the override isn't silently dropped. - Unknown top-level keys warn. Only
rulesis recognized at the top level, plus two escape hatches that are accepted silently: any key starting withx-, which holds YAML anchors for reuse the way Compose's own extension fields do, and a<<:merge key inside a mapping that pulls one in. A misplaced CLI flag (e.g. a top-levelfail_on:) or any other key warns instead of being ignored. This is also the path a leftoverprofiles:block now takes — the profile-enrichment preview was withdrawn in 0.15.0 (ADR-019), so the key is simply unrecognized and warns like any other. - Unknown per-rule keys warn. Inside a rule block, only
enabled,reason,severity, andexclude_servicesare recognized. A typo'dseverty:warns. - A
reasonwithoutenabled: falsewarns. Inside a rule block,reasonis the justification that goes with a suppression. On its own it suppresses nothing — the rule stays on and still fails the build — so a lonereason:warns and names the rule id. - A
severityon a disabled rule warns.enabled: falsesuppresses every finding the rule produces, and a suppressed finding is never re-graded, so theseverity:beside it has no effect. The warning names the rule id; the value is still validated. - An
exclude_servicesname no linted file defines warns. Checked once every file has been read, so a service that was renamed, or never existed under that spelling, is not silently left uncovered by an exclusion the config appears to grant. - Both config spellings present warns. When
.compose-lint.ymland.compose-lint.yamlboth exist in the working directory,.compose-lint.ymlis used and the other is ignored; the warning says which one was read. enabledmust be a real boolean. A quoted'false',0, or any non-boolean is a hard error (exit 2), not a silent no-op that would leave the rule on. YAML's boolean keywords (true/false,yes/no,on/off) all parse to a real boolean and work as expected.- A blank section is empty, not an error. In YAML a key with no value is
null, sorules:, a blank per-rule block, and a blankexclude_services:are read as the empty mapping — exactly as if you had written{}. Stubbing a section out is a normal thing to do, not a typo, so the blank value itself does not warn. Every other wrong type stays a hard error:rules: helloandexclude_services: 5still exit 2.
Warnings never change the exit code; only the hard errors above do.
Pass --strict-config to check or fix to promote every warning above (unknown rule id, unknown top-level or per-rule key, an inert reason: or severity:, both config spellings present, and — in check — an exclude_services name no linted file defines) to a hard error (exit 2). Use it in CI, or wherever stderr is redirected, so a typo can't silently disable the wrong rule. The unknown-service error is raised after the scan, so --format json and --format sarif still carry the findings, with the error in errors[] / toolExecutionNotifications.
Output formats¶
--format selects the output (text default, json, sarif). Text writes a human banner, per-file summary, and verdict; json and sarif emit only the machine document on stdout so redirects stay clean.
JSON¶
JSON output is a versioned envelope (see ADR-015):
{
"version": "2",
"tool": { "name": "compose-lint", "version": "0.24.0" },
"findings": [
{
"file": "docker-compose.yml",
"line": 5,
"rule_id": "CL-0001",
"severity": "critical",
"service": "proxy",
"message": "...",
"fix": "...",
"references": ["..."],
"suppressed": false
}
],
"errors": [
{ "file": "broken.yml", "message": "missing 'services' key", "kind": "parse" }
],
"warnings": []
}
versionis the envelope schema version. New top-level fields are added without bumping it; a bump signals a breaking change.findings[]carries one object per finding.filenames the document the evidence is written in andlineis a line within that document — the two always agree. Every path in the envelope is relative to the working directory, with/separators, and absolute only for a file outside it; SARIF's artifacturicarries the same value.severityis one ofcritical,high,medium,low.rule_idis an opaque string: match exact values, never theCL-XXXXpattern (compatibility.md).- Four keys are conditional, present only on the branch that produces them:
suppression_reason(a suppressed finding whose config gave a reason),severity_overridden_from(the config regraded it),graded_file(the evidence is written in a document other than the one graded: an overlay, an included or extended file, or anenv_file:), andsource_file(a deprecated alias offile, kept for consumers written against schema 1; removing an output field is a MAJOR, so it stays until 2.0, ADR-037). lineisnullwhen compose-lint has no line infilefor the offending key. A key a service inherits, throughextends:or an overlay, names the line that wrote it, so this is rare, but a consumer must handle it.fixisnullwhen a rule has no guidance to give. Both keys are always present.errors[]lists the conditions that made the run exit 2, andwarnings[]the ones reported without failing it. Both are always present, and each entry is{file, message, kind}.kindis a closed set you can filter on instead of parsingmessage:parse(a file could not be read or parsed),coverage_gap(part of the stack was not graded; the conditions are listed in coverage gaps are not findings),rule_crash(a rule raised; as a SARIF warning, a rule's fixer raised and that finding is reported without a suggested change),unread_input(anenv_file:target was refused or could not be read, so the keys it supplies were not graded; always a warning), andrun(not about one file: no Compose files found, a configuration error). Arunentry hasfile: "".warnings[]carries coverage gaps accepted with--allow-partial-coverage, which used to leave no machine-readable trace,unread_inputentries, and, in SARIF only, arule_crashfor each fixer that failed. Files skipped as not-applicable (Compose v1 / fragments / a compose-lint config, ADR-013) are neither and do not appear.- Every exit-2 path writes the envelope, except the two that fail before a format is known: an argument the parser rejects, and an
--explainerror.
Schema 2 changed what file means
In schema 1, file always named the document being graded while line indexed wherever the evidence came from — so on a merged run or one reading an env_file:, both default behaviour, the pair named a real line of the wrong file. file now names the evidence's document, and the graded one moved to graded_file. If you consumed source_file to work around this, file answers it directly.
SARIF¶
--format sarif emits a SARIF 2.1.0 log for GitHub Code Scanning. Every errors[] and warnings[] condition appears as an invocations[].toolExecutionNotifications record whose descriptor.id is its kind, at level error or warning; suppressed findings use the native suppressions[] array with the reason in justification. A log holds at most 5,000 results, because GitHub Code Scanning rejects a larger file outright; past that, the rest are dropped with a warning notification, and the exit code still reflects every finding. Use --format json for the complete set. Columns in a suggested fix count UTF-16 code units, as the run's columnKind declares (SARIF suggested changes).