What a run reads¶
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
actually runs, not just the file you name: the sibling compose.override.yml
is merged, the sibling .env is resolved, env_file: targets are graded,
include: and cross-file extends: are followed — and a part of the stack it
cannot see is an error, never a silent pass.
Everything it opens is a document the one you named routes it to, and every one of them has to resolve inside that file's own directory or, through a symlink, inside the checkout. Nothing outside either is read, no matter what the document says, and no registry, daemon or image is consulted at all.
That holds for the files a run finds as well as the ones a document names.
A discovered compose.yml, its compose.override.yml, and the
.compose-lint.yml beside it or in the working directory are part of the checkout, and so
is a Compose file named on the command line, since CI hands the linter paths
the checkout chose. One committed as a symlink is followed only while its
target stays inside the directory the run started in (the checkout, in CI:
the Action runs in the workspace and pre-commit at the repository root) or
inside the link's own directory. A shared file symlinked into a monorepo's
service directories is therefore graded when you run from the repository
root, and its relative paths resolve beside the link, as Compose resolves
them. A target outside both is refused rather than followed: exit 2, a
coverage gap for a Compose document. The policy file is contained to the
working directory — or, when it was found beside a Compose file outside it, to
that file's directory — and one that leaves it is a configuration error. A plain
path you type is read as given, and so is --config.
The same rule holds for every file a document routes the run to: the .env,
a COMPOSE_FILE entry, an include: or extends: target, an env_file:,
and an include: entry's project_directory:. Each may reach anywhere inside
the repository that holds the Compose file — the nearest directory above
it with a .git entry — whether written with .. or reached through a link,
so a monorepo whose svc/compose.yml includes ../shared/compose.yml or
links svc/.env to the .env at the repository root is graded as Compose
deploys it, from whichever directory you run. Without a repository, the
boundary is the directory compose-lint was run from when that contains the
project, and otherwise the project directory. What a path says is still a
separate test: a reference written out of the repository, or an absolute one,
is refused whether or not a link is involved, and the .git directory itself
is never read (ADR-038).
COMPOSE_FILE is the one list that is still read relative to the project
directory alone: an entry written with .. is refused, though a linked entry
follows the repository rule.
| Source | Read because | Switch it off |
|---|---|---|
Sibling compose.override.yml |
docker compose up merges it with no flag |
--no-merge-overrides |
Sibling .env |
Compose reads it for ${VAR} and COMPOSE_FILE |
--no-env |
env_file: targets |
Compose merges them into the container environment | --no-env |
include: / extends: {file: ...} |
Compose merges them into the project | — (a gap is reported instead) |
Overlays are merged¶
docker compose up merges a compose.override.yml sitting beside the base
file, with no flag and no opt-in, so compose-lint grades the merged pair: the
run header names both documents, and each finding reports the file its
evidence is written in
(ADR-025).
--no-merge-overrides grades the base alone; fix only ever edits the file
it is fixing.
The override's spelling does not have to match the base's. Compose pairs any
of compose.yml, compose.yaml, docker-compose.yml and
docker-compose.yaml with whichever override is there, and when several are,
it takes the first of compose.override.yml, compose.override.yaml,
docker-compose.override.yml, docker-compose.override.yaml (measured on
Compose 5.5.0). compose-lint pairs them the same way.
A sibling .env is read, because Compose reads it¶
(ADR-026). Its COMPOSE_FILE
chooses the documents, exactly as it does for Compose — and, as for Compose,
the first file's directory is the project directory for all of them, so an
overlay listed as ops/dev.yml resolves ./data, its extends: {file:} and
its ${VAR}s from there rather than from ops/ — and ${VAR}
references resolve to what it supplies — volumes: ["${MOUNT}:/data"] with
MOUNT=/var/run/docker.sock is graded as the control-socket mount it
deploys. Two deliberate limits: values under environment: are never
resolved from a .env (that is where secrets live), and the ambient shell
environment is never read, so the same checkout lints the same on every
machine. --no-env ignores env files entirely.
A value the .env supplies is graded but not printed: wherever a finding,
its fix guidance or a note would quote it, the report shows the reference
instead (${DB_PASSWORD}), in text, JSON and SARIF alike. One limit: a value
shorter than 8 characters is shown as written. Short values (true,
latest, 1000) are also words the guidance itself uses, and replacing
them there would corrupt it.
A .env that is there but cannot be read — not UTF-8, or larger than the
256 KiB read cap — is not treated as absent. Compose reads it as raw bytes with
no cap, so its values still deploy, and a run that graded the rest without them
could pass a stack whose real configuration it never saw. It is a coverage
gap: exit 2, a JSON errors[] entry of kind coverage_gap, and a SARIF
notification with executionSuccessful: false. --allow-partial-coverage
accepts it and grades the rest. The same holds for an included file's own
.env, which Compose reads for that file.
A .env that resolves outside the repository — a committed symlink to a
file elsewhere on the machine — is not read at all, the same containment
every other file a run opens gets, and is the same coverage gap. So is an
include: entry whose project_directory: cannot be placed — written with
.. out of the repository, absolute, interpolated, or a directory symlink
out of reach: Compose reads that
directory's .env and resolves the entry's paths from it, so every file in
the entry is reported rather than graded against a directory Compose does not
use.
An env_file: is read too, and its keys are graded¶
(ADR-027).
Compose merges those files into the container's process environment, so a
credential written there reaches every surface CL-0020 describes — moving a
line out of environment: no longer silences CL-0020/CL-0021 without
changing what deploys. Only those two rules read env files; a finding names
the key and the file, never the value, and a path resolving outside the
repository is refused rather than read. A refusal, and a target that
exists but could not be read (not UTF-8, over the size cap, not a regular
file), is reported as an unread_input warning, so a JSON or SARIF consumer
sees it as well as a reader of stderr. A missing target is a note only,
because Compose refuses a required one and deploys without an optional one.
Neither fails the run, because only those two rules read the keys. A COMPOSE_FILE entry refused for leaving the project, or missing,
is a coverage gap instead: the whole list is ignored, so the documents graded
are not the ones Compose loads. An
env_file: written in an included or extended document is read from beside
that document, as Compose reads it.
include: and cross-file extends: are followed when they stay inside the project¶
(ADR-036),
under the same containment rule as env_file:: the referenced documents are
read and merged, so hardening they declare counts and danger they declare is
found. An include-only root — no services of its own, the monorepo idiom — is
lintable rather than refused. Each document's own relative paths resolve
against its own directory, and the merge order follows Compose's, which is not
the one -f a -f b uses: the including file wins, and an earlier include:
entry beats a later one.
Every extends: merge — in-file, cross-file, or inside an included document —
is the same field-by-field merge Compose uses for overlays, so a child's
!reset deletes an inherited key and !override replaces an inherited value
instead of adding to it. A service an included document declares with
extends: is resolved once, against that document's directory, and is not
resolved again by the including file.
Coverage gaps¶
What is not followed is still an error rather than a quiet pass, because
reporting clean over a partial view is the one failure mode a merge gate
cannot have. The complete list of conditions is in coverage gaps are not
findings. A gap means exit 2,
a JSON errors[] entry, and a SARIF toolExecutionNotifications record, and
the message says which condition it hit. Lint the merged output (docker
compose config) to cover everything, or pass --allow-partial-coverage to
accept the gap and grade what is visible.
A gap is not a finding, so --fail-on does not gate it; the stability rules
for adding or retiring a gap condition are in
Compatibility.
Size limits¶
A few inputs are refused outright, not because Compose refuses them but
because grading them would take gigabytes of memory. Each is exit 2 with a
JSON errors[] entry of kind parse, and --allow-partial-coverage does not
accept it, because nothing in that document was graded:
- a Compose file larger than 8 MiB (an included or extended file that large is a coverage gap instead);
- a document declaring more than 4,096 services, counted again once its
include:files are merged in; - a document whose
<<:merge keys copy more than 65,536 key/value pairs into the mappings that use them.
Real files are far below each: across an 11,111-file corpus the most services
in one document is 64, and the most merged pairs 968. A document that produces more than
20,000 findings is different: the findings graded before the stop are still
reported, so it is a coverage gap rather than a refusal. So are the limits that
bound the work behind a document: a value too long (8 KiB) or too deep to
interpolate, interpolation that would add more than 8 MiB to it, and a line map past 262,144 repeated records, where a !reset or
!override tag reached again is no longer applied. The most repeated records in
the corpus is 1,225.
Which files are graded¶
compose-lint targets the Compose
Specification used by Compose
v2 and v3: any file with a top-level services: key, or an include:-only
root. Three shapes are skipped with a stderr note rather than failing the run:
- Compose v1 files — services declared at the top level, no
services:wrapper. Docker retired Compose v1 in 2023. - Structural fragments — files containing only
volumes:/networks:/configs:/secrets:/x-*keys, typically merged via-f overlay.yml. Reached throughinclude:or as an overlay, a fragment is merged like any other document, as Compose merges it; only linted on its own is it skipped. - compose-lint's own
.compose-lint.yml, if a glob happens to sweep it in.
A skipped file contributes no findings and does not change the exit code. Genuinely unrecognised shapes still exit 2.
YAML that Compose accepts and compose-lint refuses¶
compose-lint parses with PyYAML, and three shapes that Docker Compose 5.5.0
deploys are refused by that parser. Each one fails closed: the file is
reported as invalid YAML with exit 2, which --fail-on does not gate, so it
can hold a pipeline red but never reports a clean pass over a file it did not
read.
- A tab as the whitespace before a comment:
privileged: true<TAB># note. Compose treats the tab as separating whitespace; PyYAML stops at it. Use a space before the#. - More than one document in a file: a
---line followed by a second document. Compose merges the documents, later ones winning; compose-lint expects exactly one. Split them into files and lint them together, or lintdocker compose configoutput. - A bare
=as a value:A: =. Compose reads it as the string"="; PyYAML resolves it to a YAML 1.1 type it cannot construct. Quote it:A: "=".
The stability rules for lifting one of these are in Compatibility.