Skip to content

CLI reference

The compose-lint command line lints Docker Compose files for security misconfigurations: check reports findings, fix previews and applies the auto-remediations, and init writes a starter .compose-lint.yml. check is the default — a bare compose-lint works, and auto-detects the Compose file. The authoritative help is compose-lint --help (and fix --help / init --help); this page is its web copy.

compose-lint [check] [OPTIONS] [FILE ...]   Lint files (default; bare invocation works)
compose-lint fix [OPTIONS] [FILE ...]       Auto-remediate auto-fixable findings
compose-lint init [OPTIONS] FILE            Generate a starter .compose-lint.yml
compose-lint --version                      Show version and exit (top level only)

check options:
  --format {text,json,sarif}   Output format (default: text)
  --fail-on {low,medium,high,critical}
                               Minimum severity to trigger exit 1 (default: high)
  -v, --verbose                Repeat the fix block and reference on every finding (text mode)
  -q, --quiet                  One line per finding — no fix, reference, or excerpt (text mode)
  --skip-suppressed            Hide suppressed findings from output
  --allow-partial-coverage     Grade what could be linted when part of the stack
                               could not be (a coverage gap), instead of failing
                               (exit 2)
  --no-merge-overrides         Lint each file alone instead of merging the
                               `compose.override.yml` Compose merges beside it
  --no-env                     Ignore the env files beside the Compose file: the
                               sibling `.env` (which Compose reads for COMPOSE_FILE
                               and `${VAR}` values) and every `env_file:` a service
                               names, which leaves CL-0020/CL-0021 blind to them
  --config PATH                Path to config file (default: .compose-lint.yml,
                               then .compose-lint.yaml, beside the first Compose
                               file, else in the working directory)
  --strict-config              Treat config diagnostics (unknown rule id or key, inert reason
                               or severity, stale exclude_services name, both config
                               spellings present) as errors, not warnings
  --explain CL-XXXX            Print the full documentation for a single rule
                               (through a pager on an interactive terminal)
  --no-pager                   Print --explain output directly, bypassing the pager
  --version                    Show version and exit (`compose-lint --version`; not
                               accepted after the `check` subcommand)

fix options:
  --apply                      Write fixes in place (default: print a dry-run diff)
  --only CL-XXXX               Restrict fixes to the named rule(s); repeatable
  --no-merge-overrides         Fix each file alone instead of merging the
                               `compose.override.yml` Compose merges beside it
  --no-env                     Ignore the sibling `.env` and every `env_file:`
  --config PATH                Path to config file (suppressions are honored)
  --strict-config              Treat config diagnostics (unknown rule id or key, inert reason
                               or severity, both config spellings present) and an
                               `--only` id that names no rule as errors, not warnings

init options:
  -o, --output PATH            Where to write the config (default: .compose-lint.yml
                               beside FILE, where check looks for it first)
  --force                      Overwrite an existing config file
  --no-merge-overrides         Baseline the file alone instead of merging the
                               `compose.override.yml` Compose merges beside it
  --no-env                     Ignore the sibling `.env` and every `env_file:`

init grades its FILE exactly as check grades it — the sibling override merged, the .env consulted for COMPOSE_FILE, every env_file: read — so the suppressions it writes are the findings check will report. Baseline with the same --no-merge-overrides / --no-env you gate with, or the two disagree.

--explain CL-XXXX prints one rule's documentation and exits. It refuses a FILE argument and --format json or --format sarif (exit 2): there is nothing to lint and no machine form of the doc. Every other check option is accepted and ignored, including --config, which is not read. --no-pager without --explain does nothing.

Values are case-insensitive wherever the CLI takes a fixed set or a rule id: --fail-on HIGH, --format JSON, --explain cl-0001 and --only cl-0007 all work, and so do a severity: CRITICAL in .compose-lint.yml and FORCE_COLOR=FALSE. Rule ids used as keys in .compose-lint.yml are matched exactly, as written, because they are keys in a document you author; cl-0001: there is reported as an unknown rule id.

Exit codes

Every subcommand exits 0, 1 or 2, and nothing else (ADR-006). fix and init produce an artifact rather than a verdict, so neither exits 1: for them, findings are the input, not the failure.

check fix init
0 No finding at or above --fail-on. The diff was printed, or the fixes were written, or there was nothing to fix. Findings left to fix by hand, and coverage gaps it reported, do not change this. The config was written, or there were no findings to write one from.
1 A finding at or above --fail-on. Never. Never.
2 compose-lint could not run, or could not see the whole stack: bad arguments; a file missing, not valid Compose, or over a size limit; a crashed rule; a config error, or a config warning under --strict-config; or a coverage gap not accepted with --allow-partial-coverage. Bad arguments; a file missing or not valid Compose; a crashed rule or fixer; the 20,000-findings limit; a computed fix that fails the re-parse or verify check; a refused write (read-only file, symlink, hard link, unwritable directory); a config error. Nothing is written to that file, and the rest of the batch still runs. Bad arguments; the file missing or not valid Compose; a crashed rule; the 20,000-findings limit; an existing config without --force; a failed write.

Color

Color is on when stdout is a terminal. It covers check's report, --explain on a terminal (see Pager), and fix's dry-run diff, which is colored the way git diff colors one: removals red, additions green, @@ lines cyan, the behavior-changing caveats yellow. fix's status lines on stderr follow the same scheme — applied N fix(es) green, N to fix by hand: bold, rule ids dim as in check — and are colored when stderr, rather than stdout, is a terminal, so fix > changes.diff on a terminal still colors the status around the plain patch. Set NO_COLOR to any non-empty value to disable it (even on a terminal); it wins over everything. FORCE_COLOR, when set, overrides terminal detection: 0 or false, in any case, turns color off, and any other value, the empty string included, turns it on. That is the convention of no-color.org and of Node's supports-color. Use it to keep color through a pipe, e.g. into less -R or a CI log that renders ANSI.

On Windows, a classic console window (the cmd.exe and PowerShell default on Windows 10 and Server) shows color only once a program asks it to interpret escape sequences; compose-lint asks. A console that cannot — Windows 8.1, or Windows 10 before version 1511 — gets plain text instead of raw ←[31m codes. Windows Terminal, VS Code's terminal and Git Bash render color as on any other platform.

Pager

--explain pages its rule doc through less -RFX when stdout is a terminal (ADR-034) — -F means a doc that fits one screen prints and exits with no pager interaction. The default pager labels its controls in the status line (CL-XXXX · Space next · b back · q quit) instead of less's bare :; a custom PAGER keeps its own prompt. PAGER selects a different pager, and a blank PAGER disables paging; so do --no-pager, a non-empty NO_PAGER, and an unset or dumb TERM; a pager binary that isn't installed falls back to a plain dump. Piped or redirected output never pages and is byte-identical to the pre-pager behavior — the rule doc's raw markdown — so scripts and CI need no changes. The findings report itself never pages.

On a terminal the doc is also rendered rather than dumped, whether or not it pages: headings and **bold** print bold, `code` cyan, a link keeps its text (a web link adds its address, dimmed), tables line up or become one record per row, and prose re-wraps to the terminal, at most 100 columns. Color follows the Color rules; without it the markup is still stripped, and code keeps its backticks.

End of options

-- marks the end of options: everything after it is a file path, never a flag, and never a subcommand — compose-lint -- init lints a file named init. That matters to any integration that assembles a command line from repository content. The pre-commit hook ships args: [--] because pre-commit builds the command as entry + args + filenames — without the separator, a repository directory named --config=cfgdir holding a compose.yml arrives as --config=cfgdir/compose.yml and installs an attacker-authored policy for the run. compose-lint cannot insert the separator itself: flags after a positional (compose-lint init docker-compose.yml -o ci.yml) are documented and must keep working, so placing -- is the caller's job. If you set args: in your pre-commit config, keep -- last.

Automation and agent use

Everything below is also true of a shell script; it is written out because a coding agent driving compose-lint tends to pattern-match on "linter" and get these five wrong. Nothing here enumerates rules — --explain CL-XXXX is the single source for what a rule means and how to fix it, it reads the rule docs shipped inside the wheel, and it needs no network.

Exit 2 is not "the lint failed". Exit 1 means findings at or above the threshold — that is the failure to act on. Exit 2 means compose-lint could not run, or could not see the whole stack. An unresolved include: or cross-file extends:, a .env Compose reads that compose-lint could not, or any other coverage gap means part of the stack was never graded, so the run cannot honestly report a verdict. An include: or cross-file extends: whose target resolves inside the project directory is followed and merged, so it is not a gap; the message on one that is says which condition it hit. Treating exit 2 as a findings failure invents remediation work that does not exist. Either resolve the coverage gap or downgrade it deliberately with --allow-partial-coverage, which demotes it to a warning (on stderr, and in warnings[]) as long as something is left to grade. fix reports gaps without failing on them, except at the 20,000-findings limit, where it writes nothing to that file and exits 2; it is not the gate.

fix is a dry run by default, and its refusals are the safety property. A bare compose-lint fix prints a diff and writes nothing; --apply writes in place through an atomic swap, guarded by a re-parse and a verify-apply pass. It deliberately refuses regions it cannot rewrite safely — anchors, merge keys, ${VAR} interpolation — and reports findings whose remediation is context-dependent (CL-0006 capability lists, CL-0001 socket mounts) as fixes to make by hand, each with its rule's guidance, rather than guessing at a value only the operator can choose. That is the contract, not an unfinished fixer: see the fix design contract. When fix declines a finding, hand-editing the same change into place discards the guard that made the decline correct.

Two consequences for an agent reporting on a fix run. An edit that alters runtime behavior carries a ⚠ behavior-changing line naming what breaks — surface it, because the label is the mitigation; nothing else withholds the risky fix. And the diff goes to stdout while status goes to stderr, so compose-lint fix file.yml > changes.diff captures the diff without the summary. The ⚠ lines lead that stdout block, above the diff, so the redirect captures them too; repeat them to the reader. The dry-run diff is for review, not a patch format: a line a CI runner could read as a workflow command is escaped, so it is not promised to git apply. To change the files, run fix --apply; to get a patch, run git diff after it. Under --apply there is no diff, and the ⚠ lines go to stderr with the rest of the status.

Suppression has one shape. Findings are suppressed in .compose-lint.yml with a reason, which flows through to suppression_reason in JSON, justification in SARIF, and after SUPPRESSED in text — the suppressed finding is still reported, so a suppression stays visible rather than disappearing. There are no inline suppression comments, and no comment syntax to guess at. compose-lint init generates a baseline config from the findings check reports for a file — the merged stack, override and env files included — as per-service exclude_services entries with placeholder reasons to replace. Deleting the offending service to clear a finding is not a fix; neither is a global enabled: false where the finding is about one service.

Severity is derived, not chosen. Each rule's severity is what the two-axis matrix in severity.md produces for its cell, under a stated attacker baseline and Docker posture. Report the severity the tool emits and quote --explain for the reasoning; re-ranking a finding because it feels more or less urgent in context is exactly the failure mode the model exists to prevent. If it genuinely reads wrong, that is an issue worth filing, not a number worth adjusting in a report.

Parse --format json, never the text output. JSON is a versioned envelope (ADR-015): a top-level version, a tool block, findings, and the errors and warnings channels, each entry tagged with a kind (see configuration.md). New top-level fields are additive and do not bump version, so a consumer can read what it knows and ignore the rest. Text output is for humans and is not a contract — its banner, summaries and verdict go to stdout only in text mode, so JSON and SARIF redirects stay clean. Piped output is never paged or colored, so no flag is needed to make a run scriptable.

Two smaller things worth knowing when assembling a command line programmatically: pass -- before the file paths (see End of options) whenever any part of the command comes from repository content, and pin a version if the run gates CI, because new and tightened rules ship in MINOR releases.