We Scanned 16 Popular Docker Compose Projects — Here's What We Found
We ran dockguard against the official docker-compose.yml files for 16 of the most-recommended self-hosted projects on r/selfhosted. The scan turned up 252 findings across 25 services. Every project had at least one critical issue. Authentik alone had 15 criticals.
This post walks through the numbers, the five issues that show up everywhere, and the one-line fixes for each.
TL;DR
| Metric | Count |
|---|---|
| Projects scanned | 16 |
| Total services | 25 |
| Critical findings | 49 |
| Warnings | 155 |
| Info | 48 |
| Total findings | 252 |
| Avg per project | 15.8 |
| Projects with at least one critical | 16 / 16 (100%) |
The projects: Authentik, Audiobookshelf, Gitea, Grafana, Home Assistant, Jellyfin, Linkding, Mealie, n8n, Nextcloud, Paperless-ngx, Pi-hole, Portainer, Traefik, Uptime Kuma, Vaultwarden. Two more (Immich, wg-easy) failed to parse — dockguard parser bugs, on us to fix.
Methodology
Each project's compose file was pulled from its official GitHub repo or, where the repo didn't ship one, the recommended quick-start example from its documentation. We did not modify the files. The scan was run with dockguard scan on the unedited YAML.
dockguard is a single Go binary — no Docker daemon required to scan, no SaaS roundtrip, no telemetry. Findings are categorised as critical, warning, or info based on what an attacker or runaway process could do given the configuration as-shipped.
Per-project breakdown
| Project | Services | Critical | Warning | Info | Total |
|---|---|---|---|---|---|
| Authentik | 4 | 15 | 25 | 8 | 48 |
| Paperless-ngx | 3 | 2 | 19 | 6 | 27 |
| Gitea | 2 | 4 | 13 | 4 | 21 |
| n8n | 2 | 4 | 13 | 4 | 21 |
| Nextcloud | 2 | 4 | 13 | 4 | 21 |
| Traefik | 2 | 2 | 13 | 2 | 17 |
| Pi-hole | 1 | 4 | 6 | 2 | 12 |
| Grafana | 1 | 2 | 6 | 2 | 10 |
| Mealie | 1 | 2 | 6 | 2 | 10 |
| Portainer | 1 | 2 | 6 | 2 | 10 |
| Vaultwarden | 1 | 2 | 6 | 2 | 10 |
| Audiobookshelf | 1 | 1 | 6 | 2 | 9 |
| Home Assistant | 1 | 2 | 5 | 2 | 9 |
| Jellyfin | 1 | 1 | 6 | 2 | 9 |
| Linkding | 1 | 1 | 6 | 2 | 9 |
| Uptime Kuma | 1 | 1 | 6 | 2 | 9 |
Authentik is the outlier — it's also the most complex of the bunch (auth server, worker, database, redis), so the surface is larger. Even the simplest single-service apps (Uptime Kuma, Linkding) average 9 findings.
The five issues that show up in almost everything
| Issue | Occurrences | Severity |
|---|---|---|
| Plaintext secrets in env vars | 26 | Critical |
Missing security_opt: no-new-privileges |
25 | Warning |
| No memory limit | 25 | Warning |
| No CPU limit | 25 | Warning |
| Running as root | 25 | Warning |
| Writable root filesystem | 25 | Warning |
| Full Linux capabilities | 24 | Warning |
Ports exposed to 0.0.0.0 |
22 | Critical |
| No healthcheck defined | 25 | Info |
| Restart without healthcheck | 23 | Info |
Five patterns explain the bulk of the report. Each has a fix that's usually one line of YAML.
1. Plaintext secrets in compose files
26 occurrences. Critical. Database passwords, admin tokens, encryption keys — all sitting in plain text in the file most users commit to git. Vaultwarden's quick-start ships with ADMIN_TOKEN as plaintext. Authentik defaults PG_PASS and AUTHENTIK_SECRET_KEY to literal strings. n8n's example pins DB_POSTGRESDB_PASSWORD inline.
Fix: pull secrets from a .env file (gitignored) or use Docker secrets.
services:
app:
image: vaultwarden/server
environment:
ADMIN_TOKEN: ${ADMIN_TOKEN} # read from .env, not committed
2. Every project exposes ports to all interfaces
22 occurrences. Critical. ports: "8080:80" binds to 0.0.0.0:8080 — every network interface on the host. Most self-hosters run behind a reverse proxy on the same machine, so the container only needs to listen on localhost.
Fix: one prefix.
ports:
- "127.0.0.1:8080:80"
This single change closes the most common accidental-exposure path on a home server with a public IP.
3. Nobody drops Linux capabilities
24 of 25 services. By default, a container gets a broad set of Linux capabilities — CAP_CHOWN, CAP_NET_RAW, CAP_SYS_CHROOT, and a dozen more. A web app reading and writing files needs none of them. An attacker who pops a shell in the container inherits the lot.
Fix: drop everything, add only what you need.
services:
app:
cap_drop:
- ALL
# cap_add: [NET_BIND_SERVICE] # only if binding to a low port
For most web apps the cap_add block stays empty.
4. Zero compose files set resource limits
25 occurrences each for memory and CPU. A single misbehaving container can consume all host RAM or pin every core. On a shared homelab box that takes everything down with it.
Fix:
services:
app:
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
(Outside Swarm mode, mem_limit and cpus at the service level also work.)
5. No healthchecks, but restart: always
25 services with no healthcheck. 23 of those set restart: always or restart: unless-stopped. Without a healthcheck, "running" means the container's PID 1 is alive — not that the app inside actually serves requests. A container can wedge in a broken state and restart back into the same wedged state indefinitely.
Fix: define a healthcheck and let restart react to it.
services:
app:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
What this means
None of these projects are insecure software. Authentik, Gitea, Nextcloud, Vaultwarden — these are mature, well-audited codebases. The issue is that the example compose files they ship are optimised for "works on first try" rather than "safe in production." Users copy the example, run docker compose up -d, and inherit defaults nobody actually intended for an internet-facing host.
The fixes are short. They are also tedious to apply manually across 20-service stacks, which is the gap dockguard fills:
$ go install github.com/narrowcastdev/dockguard@latest
$ dockguard scan docker-compose.yml
The scanner is local-only — your compose file never leaves the machine. Source, install one-liner, and the full ruleset are at github.com/narrowcastdev/dockguard.
Methodology caveats
- We scanned the project's own example, not what any specific user is running. Many users layer their own overrides on top — those changes can fix or worsen what we found.
- Some images set sensible defaults internally (non-root users, dropped caps in the entrypoint) that the compose file doesn't reveal. The scanner sees the compose surface only.
- Two projects (Immich, wg-easy) failed to parse. Both use YAML features dockguard doesn't handle yet. Filed as bugs.
If you maintain one of the projects above and want the full per-finding report, the scanner output is reproducible from the public compose files — or open an issue on dockguard and we'll send it.