Compose Guard
Compose Guard lints Docker Compose files for security holes, misconfigurations and reliability problems before they reach production.
Try it in your browser → The playground runs this exact Python package with Pyodide, so you can lint a Compose file without installing anything. Your YAML never leaves the browser.
Why it exists
Compose files are usually copied from tutorials and grow by trial and error. They reach production
with latest image tags, databases published on every network interface, the Docker socket
mounted into a container, plain-text passwords in environment:, no healthchecks and no restart
policy. docker compose config happily accepts all of that, and nothing complains until a
database is found by a scanner or a service stays down after a reboot.
Compose Guard reads your Compose files and tells you, with the exact line and a concrete fix, what to change. It is small, has one dependency (PyYAML), and fits into CI as a gate or as a GitHub code scanning source.
Features
- 15 documented rules for security (privileged mode, Docker socket, dangerous capabilities,
disabled seccomp/AppArmor, host namespaces, sensitive host paths, exposed databases, hard-coded
secrets) and reliability (unpinned images, healthchecks, restart policies,
depends_onconditions, memory limits, fixed container names), plus CD000 for entries Compose itself would reject. - Accurate line numbers, including through YAML anchors, aliases and
<<merge keys: a finding inherited from anx-block points at the line where the value is defined. - Text, JSON and SARIF 2.1.0 output. SARIF findings show up in GitHub code scanning.
- Inline suppressions (
# compose-guard: ignore CD005) and a config file with global and per-service ignores and severity overrides. Suppressed findings are counted, never lost. - CI-friendly exit codes with a configurable threshold (
--fail-on). - Never prints secrets. Findings about environment variables name the key, never the value.
- Safe parsing. YAML is parsed with
yaml.composeandSafeLoader; no Python objects are constructed from your file, and alias bombs are rejected.
Installation
Python 3.10 or newer:
pip install compose-guard
compose-guard --version
Or install from a clone:
git clone https://github.com/yagnang-vyas/compose-guard.git
cd compose-guard
python -m venv .venv && . .venv/bin/activate
pip install .
compose-guard --version
Quick start
# Lint compose.yaml (or compose.yml, docker-compose.yaml, docker-compose.yml) in this directory
compose-guard
# Lint specific files, or standard input
compose-guard deploy/compose.yaml deploy/compose.prod.yaml
cat compose.yaml | compose-guard -
# Fail CI on warnings too, and write SARIF for code scanning
compose-guard --fail-on warning --format sarif > compose-guard.sarif
# See every rule with its rationale and fix
compose-guard --list-rules
python -m compose_guard works the same as compose-guard.
Sample output
examples/insecure/compose.yaml is a deliberately insecure
small shop stack (web app, PostgreSQL, Redis, a worker and Adminer) with fake credentials. It
triggers all 15 rules:
$ compose-guard examples/insecure/compose.yaml
examples/insecure/compose.yaml:6:3 warning CD007 [web] Service has no healthcheck although it publishes ports
fix: Add a healthcheck that proves the service is serving, for example test: ["CMD-SHELL", "pg_isready -U app"] for PostgreSQL, with interval, timeout and retries.
examples/insecure/compose.yaml:6:3 warning CD008 [web] Service has no restart policy
fix: Set 'restart: unless-stopped' (or 'always' / 'on-failure').
examples/insecure/compose.yaml:6:3 note CD010 [web] Service has no memory limit
fix: Set 'mem_limit: 512m' or 'deploy.resources.limits.memory: 512M'.
examples/insecure/compose.yaml:7:5 warning CD001 [web] Image 'example/shop-web:latest' uses the 'latest' tag
fix: Pin a specific version tag (postgres:16.4) or a digest (image@sha256:...).
examples/insecure/compose.yaml:8:5 note CD015 [web] Service sets a fixed container_name
fix: Remove 'container_name' and refer to the service by its service name.
examples/insecure/compose.yaml:13:7 error CD006 [web] Environment variable 'DJANGO_SECRET_KEY' has a hard-coded secret value
fix: Load it from the environment or an untracked .env file (KEY: ${KEY}), or use Docker secrets with a *_FILE variable.
examples/insecure/compose.yaml:15:9 warning CD009 [web] Depends on 'db' only being started, not healthy, although it has a healthcheck
fix: Use the long form: depends_on: {db: {condition: service_healthy}}.
examples/insecure/compose.yaml:18:3 note CD010 [db] Service has no memory limit
fix: Set 'mem_limit: 512m' or 'deploy.resources.limits.memory: 512M'.
examples/insecure/compose.yaml:19:5 warning CD001 [db] Image 'postgres' has no tag, so it resolves to 'latest'
fix: Pin a specific version tag (postgres:16.4) or a digest (image@sha256:...).
examples/insecure/compose.yaml:21:9 error CD005 [db] PostgreSQL container port 5432 is published on all interfaces
fix: Remove the port mapping and connect over the Compose network, or bind it to loopback, for example '127.0.0.1:5432:5432'.
examples/insecure/compose.yaml:24:7 error CD006 [db] Environment variable 'POSTGRES_PASSWORD' has a hard-coded secret value
fix: Load it from the environment or an untracked .env file (KEY: ${KEY}), or use Docker secrets with a *_FILE variable.
examples/insecure/compose.yaml:30:5 warning CD008 [db] Restart policy is 'no'
fix: Set 'restart: unless-stopped' (or 'always' / 'on-failure').
examples/insecure/compose.yaml:32:3 warning CD007 [cache] Service has no healthcheck although other services depend on it (web)
fix: Add a healthcheck that proves the service is serving, for example test: ["CMD-SHELL", "pg_isready -U app"] for PostgreSQL, with interval, timeout and retries.
examples/insecure/compose.yaml:32:3 note CD010 [cache] Service has no memory limit
fix: Set 'mem_limit: 512m' or 'deploy.resources.limits.memory: 512M'.
examples/insecure/compose.yaml:35:9 error CD005 [cache] Redis container port 6379 is published on all interfaces
fix: Remove the port mapping and connect over the Compose network, or bind it to loopback, for example '127.0.0.1:5432:5432'.
examples/insecure/compose.yaml:38:3 note CD010 [worker] Service has no memory limit
fix: Set 'mem_limit: 512m' or 'deploy.resources.limits.memory: 512M'.
examples/insecure/compose.yaml:40:5 error CD002 [worker] Container runs in privileged mode
fix: Remove 'privileged: true' and grant only the specific capabilities or devices needed.
examples/insecure/compose.yaml:41:5 warning CD013 [worker] 'pid: host' shares the host's namespace
fix: Remove the setting so the container gets its own namespace.
examples/insecure/compose.yaml:43:9 error CD011 [worker] Dangerous capability SYS_ADMIN is added
fix: Remove the capability, or replace it with the narrowest one the service needs.
examples/insecure/compose.yaml:45:9 error CD012 [worker] The seccomp security profile is disabled
fix: Remove the 'unconfined' option. If a specific call is blocked, use a custom profile that allows only that call.
examples/insecure/compose.yaml:48:9 error CD006 [worker] Environment variable 'PAYMENT_API_TOKEN' has a hard-coded secret value
fix: Load it from the environment or an untracked .env file (KEY: ${KEY}), or use Docker secrets with a *_FILE variable.
examples/insecure/compose.yaml:50:9 error CD003 [worker] The Docker socket /var/run/docker.sock is mounted into the container
fix: Remove the socket mount. If the service really needs the Docker API, put a filtering proxy (for example docker-socket-proxy) in front of it and allow only the calls it needs.
examples/insecure/compose.yaml:51:9 error CD014 [worker] Sensitive host path /etc is mounted
fix: Mount only the specific application directory the service needs, read-only if possible.
examples/insecure/compose.yaml:53:9 warning CD009 [worker] Depends on 'db' only being started, not healthy, although it has a healthcheck
fix: Use the long form: depends_on: {db: {condition: service_healthy}}.
examples/insecure/compose.yaml:56:3 note CD010 [adminer] Service has no memory limit
fix: Set 'mem_limit: 512m' or 'deploy.resources.limits.memory: 512M'.
examples/insecure/compose.yaml:57:5 warning CD001 [adminer] Image 'adminer' has no tag, so it resolves to 'latest'
fix: Pin a specific version tag (postgres:16.4) or a digest (image@sha256:...).
examples/insecure/compose.yaml:58:5 warning CD004 [adminer] Container uses the host's network stack
fix: Remove 'network_mode: host' and publish only the ports you need with 'ports:'.
10 errors, 11 warnings, 6 notes, 0 suppressed in 1 file
examples/hardened/compose.yaml is the same stack with every
finding fixed: pinned images, loopback-only ports, secrets from .env and Docker secrets,
healthchecks with condition: service_healthy, restart policies, memory limits, no privileged
settings or host mounts.
$ compose-guard examples/hardened/compose.yaml
No problems found in 1 file.
Web playground
Live at compose-guard.netlify.app.
web/ holds its source: a landing page and a browser playground that runs this exact
Python package with Pyodide (WebAssembly). Your YAML stays in the browser. See web/README.md for the architecture and Netlify deployment.
Rules
Generated from compose-guard --list-rules, which also prints each rule's fix.
| ID | Severity | Rule | Why it matters |
|---|---|---|---|
| CD000 | error | Invalid configuration | An entry that does not follow the Compose specification is either rejected by Docker Compose or silently means something other than intended, and the other rules cannot check it. |
| CD001 | warning | Unpinned image | An image without a tag, or with the 'latest' tag, can change underneath you: a redeploy or a fresh host silently pulls a different version, which breaks reproducibility and can introduce untested or vulnerable code. |
| CD002 | error | Privileged container | A privileged container gets every Linux capability and access to all host devices, and most isolation between it and the host is turned off. A compromise of the container is effectively a compromise of the host. |
| CD003 | error | Docker socket mounted | Access to the Docker socket is root access to the host: anything in the container can start a privileged container that mounts the host filesystem. This applies even to a read-only mount, because the socket accepts API calls either way. |
| CD004 | warning | Host network mode | With 'network_mode: host' every port the container listens on is open on the host's interfaces, bypassing Compose port mapping, and the container can reach services bound to the host's loopback address. |
| CD005 | error | Database port exposed on all interfaces | Publishing a database port without a loopback host IP makes it reachable from every network the host is on, and Docker's iptables rules bypass host firewalls such as ufw. Exposed databases are found and attacked within hours. |
| CD006 | error | Hard-coded secret in environment | A password, token or key written into the Compose file ends up in version control, in 'docker inspect' output and in anyone's copy of the repository. Rotating it means rewriting history. |
| CD007 | warning | No healthcheck | Without a healthcheck Docker only knows whether the process is running, not whether it is serving. Dependent services cannot wait for it to be ready, and a hung process is never reported as unhealthy. An explicitly disabled healthcheck is reported as a note. |
| CD008 | warning | No restart policy | Docker's default is not to restart containers. After a crash or a host reboot the service stays down until someone notices. |
| CD009 | warning | depends_on without health condition | Plain 'depends_on' only waits for the dependency's container to start, not for it to be ready, so the service races its database on every cold start. When the dependency has a healthcheck, Compose can wait for it properly. Dependencies without a healthcheck are reported by CD007 instead. |
| CD010 | note | No memory limit | Without a memory limit one leaking or overloaded container can consume all host memory and trigger the kernel's OOM killer against unrelated services, including the database. |
| CD011 | error | Dangerous capability | SYS_ADMIN is close to full root, SYS_MODULE loads kernel modules, SYS_PTRACE can read other processes' memory, NET_ADMIN reconfigures host-visible networking, and ALL grants everything. Each one widens the path from a compromised container to the host. |
| CD012 | error | Security profile disabled | Docker's default seccomp and AppArmor profiles block system calls and file accesses that containers almost never need but exploits often do. Running unconfined removes that layer of defence. |
| CD013 | warning | Host namespace shared | Sharing the host's PID namespace lets the container see and signal host processes; sharing IPC exposes host shared memory; 'userns_mode: host' disables user-namespace remapping so container root is host root. |
| CD014 | error | Sensitive host path mounted | Mounting the host root, system configuration (/etc), root's home, kernel interfaces (/proc, /sys), the boot partition or Docker's own data directory gives the container a way to read secrets from, or tamper with, the host. |
| CD015 | note | Fixed container_name | A fixed container name prevents scaling the service beyond one replica and clashes when two Compose projects on the same host use the same name. Compose already gives each container a unique, predictable name. |
Suppressions and configuration
Inline comments
services:
db:
image: postgres:16.4
ports:
# compose-guard: ignore CD005 -- only reachable through the VPN
- "10.8.0.1:5432:5432"
container_name: db # compose-guard: ignore CD015
# compose-guard: ignore-file CD010
# compose-guard: ignore ID[,ID...]suppresses those rules on its own line. When the comment is on a line by itself it also covers the line directly below.# compose-guard: ignore-file ID[,ID...]anywhere suppresses those rules for the whole file.- Anything after
--is a free-text reason. Unknown rule IDs are an error, so a typo cannot silently suppress nothing.
Config file
Compose Guard reads .compose-guard.yml from the current directory, or the file given with
--config PATH:
ignore: [CD015] # ignore these rules everywhere
services:
worker:
ignore: [CD007] # ignore these rules for one service
severity:
CD010: warning # change a rule's severity
fail_on: warning # default for --fail-on
Unknown keys or rule IDs are rejected with a list of valid ones. Command-line flags win:
--ignore CD010,CD015 adds to the config file's ignore list and --fail-on overrides
fail_on.
Suppressed findings always appear in the summary (2 suppressed). --show-suppressed lists them;
SARIF output always includes them, marked as suppressed (inSource for comments, external for
configuration).
CI usage
As a gate, any CI system only needs the exit code:
compose-guard --fail-on warning
On GitHub, upload SARIF so findings appear as code scanning alerts on the lines they refer to. Code scanning must be enabled for the repository (Settings → Code security), and private repositories need GitHub Advanced Security.
name: Compose Guard
on: [push, pull_request]
permissions:
contents: read
security-events: write
jobs:
compose-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install compose-guard
- run: compose-guard compose.yaml --format sarif --fail-on never > compose-guard.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: compose-guard.sarif
category: compose-guard
This repository's own SARIF demo workflow does the same for the insecure example. It only runs when triggered manually, so the example does not create alerts on every push.
JSON output
--format json prints one object with a stable shape (schema_version 1). For this input on
standard input:
services:
db:
image: postgres:16.4
restart: unless-stopped
mem_limit: 1g
ports:
- "5432:5432"
$ compose-guard --format json - < compose.yaml
{
"schema_version": 1,
"tool": {
"name": "compose-guard",
"version": "0.1.0"
},
"files": [
"<stdin>"
],
"summary": {
"error": 1,
"warning": 1,
"note": 0,
"suppressed": 0
},
"findings": [
{
"rule_id": "CD007",
"severity": "warning",
"file": "<stdin>",
"line": 2,
"column": 3,
"service": "db",
"message": "Service has no healthcheck although it publishes ports",
"fix": "Add a healthcheck that proves the service is serving, for example test: [\"CMD-SHELL\", \"pg_isready -U app\"] for PostgreSQL, with interval, timeout and retries."
},
{
"rule_id": "CD005",
"severity": "error",
"file": "<stdin>",
"line": 7,
"column": 9,
"service": "db",
"message": "PostgreSQL container port 5432 is published on all interfaces",
"fix": "Remove the port mapping and connect over the Compose network, or bind it to loopback, for example '127.0.0.1:5432:5432'."
}
]
}
findingsholds unsuppressed findings, sorted by file, line and rule ID.serviceis the service name;lineandcolumnare 1-based.summarycounts unsuppressed findings by severity, plus suppressed findings.- With
--show-suppressed, asuppressedarray lists suppressed findings with an extra"suppression": "inline"or"config"field.
Exit codes
| Code | Meaning |
|---|---|
| 0 | No unsuppressed finding at or above --fail-on (default error) |
| 1 | At least one such finding |
| 2 | Usage or input error: bad flag, missing file, YAML error, invalid config. The message goes to stderr as compose-guard: error: <file>:<line>: <message> |
| 130 | Interrupted (Ctrl-C) |
| 141 | Output pipe closed early, for example by | head |
Limitations
- Each file is linted on its own. Override files are not merged the way
docker compose -f a.yaml -f b.yamlmerges them. ${VAR}interpolation is detected but not resolved, and.envfiles are not read. Interpolated values are treated as unknown: an interpolated image tag is not called unpinned, an interpolated secret is not called hard-coded, and an interpolated host IP is not judged.- Compose Guard only sees the Compose file. A healthcheck defined in an image's Dockerfile, or a firewall in front of a published port, is invisible to it; suppress the finding with a reason.
- Suppression comments are found by scanning the raw text, so a
# compose-guard:marker inside a multi-line string would also be read as a suppression. - It does not lint Dockerfiles or Kubernetes manifests and does not fix files automatically.
Prior art
- hadolint lints Dockerfiles, not Compose files.
- Checkov and KICS are broad infrastructure-as-code scanners that include Docker Compose checks among many other formats.
docker compose configvalidates and renders a Compose file but does not judge it.
Compose Guard focuses on Compose only and stays small and dependency-light, with accurate line numbers through anchors and merge keys, per-line suppressions and SARIF output.
Development
python -m venv .venv && . .venv/bin/activate
make install # pip install -e ".[dev]"
make check # ruff, ruff format --check, mypy --strict, pytest with >=90% branch coverage
make format # apply formatting and safe lint fixes
- SPEC.md describes the design (especially the loader) and records every judgment call under "Decisions". AGENTS.md holds the ground rules for contributors.
- Each rule has
tests/fixtures/bad_cdNNN.yamlandgood_cdNNN.yaml. Bad fixtures mark expected findings with# expect: CDNNNand the test checks the exact lines. UPDATE_GOLDEN=1 pytestregeneratestests/golden/insecure.json;python scripts/update_readme.pyregenerates the sample output and rule table above. A test fails if either is stale.
Releasing
- Run
make checkandpython scripts/update_readme.py(andpython web/scripts/export_snapshot.pyif the rule catalogue changed). - Update CHANGELOG.md, bump
__version__insrc/compose_guard/__init__.py. - Tag and publish a GitHub Release (
vX.Y.Z). The Publish workflow builds the sdist/wheel and uploads to PyPI via Trusted Publishing. - On PyPI, configure a Trusted Publisher for this repository (
compose-guard, workflowpublish.yml, environmentpypi) before the first release upload.
Roadmap
- Merge override files the way
docker compose -fdoes and lint the result. - Optional
.envsupport for resolving interpolation. - More rules:
read_onlyroot filesystems,no-new-privileges, missinguser:, published admin UIs. - A pre-commit hook and a ready-made GitHub Action.
License
MIT, see LICENSE.
Metadata
Release files for compose-guard 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| compose_guard-0.1.0.tar.gz | 157.7 kB | With provenance | Details for compose_guard-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| compose_guard-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for compose_guard-0.1.0-py3-none-any.whl |
Total release size: 200.9 kB
Release files / compose_guard-0.1.0.tar.gz
| Download URL | compose_guard-0.1.0.tar.gz |
|---|---|
| Size | 157.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
174a9fd1c1cc791331cb58206ee287a192eb64497d7bb63bb324fc1f9915ba15
|
|
BLAKE2b-256 checksum How to use checksums |
2b452b14e051d7149c4f87c066931b61353f9e928572bd20259a74ff1899c565
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / compose_guard-0.1.0-py3-none-any.whl
| Download URL | compose_guard-0.1.0-py3-none-any.whl |
|---|---|
| Size | 43.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
34b76e6453e94eb2f9020fa4563562bb85ed6b8d1b7260cf53b21eed8e45a52d
|
|
BLAKE2b-256 checksum How to use checksums |
7678f613d24d358347ee9fff08054d9efba5c6ff52814e1abd395266863b8f4e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for compose-guard 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for compose_guard-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.