selfhosted-doctor
v0.3.0
Published
AI-ready security checks for self-hosted homelabs, starting with Docker Compose.
Maintainers
Readme
selfhosted-doctor
Before you open a self-hosted app to the internet, know if it's safe — and what to fix first.
You've got a stack running on your NAS or homelab. It works. Now you want to reach it from outside — a Cloudflare Tunnel, a port forward, a domain. That's the scary moment: is anything reachable that shouldn't be?
selfhosted-doctor reads your Docker Compose stack and answers one question in plain language:
npx selfhosted-doctor expose docker-compose.yml Dify · 2 entry points reachable from the internet (nginx 80/443, plugin_daemon 5003)
(reachable if this host has a public IP or a forwarded port — derived from the compose file, not probed)
⛔ DON'T EXPOSE YET
Genuinely dangerous — 1 thing:
● plugin_daemon port 5003 is published to 0.0.0.0
Anyone on the internet could reach plugin_daemon directly.
Fix: "5003:5003" → "127.0.0.1:5003:5003"
Also handle before going public:
○ nginx is your front door (publishes 80/443) — no access control detected in front of it
→ put it behind Cloudflare Access, a VPN, or an auth proxy
○ 5 internal services fall back to built-in default secrets (4 shared keys)
Fine on a private network; MUST be changed if this host is on the internet.
20 hygiene items (healthchecks, unpinned images, restart policies) won't get you hacked — run `scan` for the full list.
Fix the items above, then run `expose` again.That's a full upstream Dify stack — 30-plus services — and instead of a wall of red, you get one thing that's genuinely dangerous, the exact line to change, and a clear verdict. Fix it, re-run, and the verdict flips when it's safe.
It's local-first and strictly read-only: it parses your files (and, optionally, reads live container config via read-only docker inspect), never calls the Cloudflare API, and never changes anything. It's not trying to replace Trivy or Checkov — it catches the specific mistakes that bite self-hosters in the moment right before a private service becomes public.
Two commands
selfhosted-doctor expose ./my-stack # the decision: can I open this to the internet, and what do I fix first?
selfhosted-doctor scan ./my-stack # the full report: every finding, scored, for detail / CI / AIexpose is the one you reach for. It gives a single verdict —
| Verdict | Meaning | |---|---| | ⛔ DON'T EXPOSE YET | Something genuinely dangerous is reachable (public database, debug/admin port, a real hardcoded secret, Docker socket, privileged). Fix it first. | | 🔐 EXPOSE ONLY BEHIND ACCESS | No hard blockers, but a public service has no access control detected — put it behind Cloudflare Access / a VPN / an auth proxy. | | 🔎 CHECK MANUALLY | A port the tool couldn't resolve statically — decide for yourself. | | ✅ LOOKS OK TO EXPOSE | No active blockers; only hygiene left. |
— then the blockers (grouped per service, capped to what matters), the "handle before public" items, a one-line hygiene summary, and a note of what changes if you enable optional Compose profiles. It exits 1 on DON'T EXPOSE YET, so you can drop it into a pre-deploy check.
scan is the exhaustive view — every finding by severity, a risk score, and -f json / -f markdown output for CI, MCP, and AI.
selfhosted-doctor scan [path] -f json # machine-readable JSON (AI-ready)
selfhosted-doctor scan [path] -f markdown # Markdown report
selfhosted-doctor scan [path] -o report.md # write to a file (format inferred from extension)
selfhosted-doctor scan [path] --fail-on high # exit non-zero for CI when a high risk exists
selfhosted-doctor explain [path] # plain-language explanation of the findings
selfhosted-doctor mcp # run the read-only MCP server over stdiopath can be a Compose file (docker-compose.yml, compose.yml), a directory, or a docker inspect JSON export (auto-detected — see Scanning already-running containers). When you point it at a single Compose file, sibling .env and cloudflared config files in the same folder are scanned too. --fail-on high|medium|low makes scan exit 1 when a finding at or above that severity exists; the default is none.
Compose profiles
Services behind a Compose profiles: key are optional — they only run when you opt into that profile. selfhosted-doctor treats them accordingly:
selfhosted-doctor scan ./stack --profile milvus # score default + the "milvus" profile
selfhosted-doctor scan ./stack --profile milvus --profile opengauss # score default + several profiles
selfhosted-doctor scan ./stack --all-profiles # score every service, all profiles on--profile <name>(repeatable) scores your default services plus the named optional profile(s).--all-profilesscores every service, including all profile-gated ones.
By default the risk score reflects only your active/default services. Optional services behind Compose profiles: are reported as conditional and don't affect the score unless you pass --profile or --all-profiles. This is why a large upstream Compose file (like Dify's, which gates alternate vector DBs behind profiles) no longer collapses to 0/100 just because of services you never actually run.
Scanning already-running containers (no Compose file)
Most real NAS / homelab services have no Compose file — they were started from a NAS UI, Portainer, or a raw docker run. selfhosted-doctor can scan those too, by reading Docker's own docker inspect output. Both expose and scan accept it, and the input is auto-detected — no flag needed. The container config feeds the exact same model, rules, and verdict as a Compose file.
It stays 100% read-only: it only ever reads live container config via docker ps / docker inspect. It never creates, stops, removes, or changes anything.
The NAS recipe: export once, scan the file
On the box where the containers run, dump every running container to a file, then scan that file (from anywhere):
docker inspect $(docker ps -q) > containers.json
npx selfhosted-doctor expose containers.jsondocker inspect $(docker ps -q) produces a JSON array of every running container. selfhosted-doctor detects that shape automatically and maps each container to a service (published ports, env, privileged, host network, Docker-socket mounts, restart policy, healthcheck, user). This is the recommended path for a NAS: the export is a single read-only command, and the file can be scanned on a laptop, in CI, or fed to the MCP server.
Because these are the real, resolved values of running containers, a secret in a container's environment is a live secret — it's flagged (always redacted), not treated as a template placeholder.
--running: scan the local daemon directly
If the machine has both the Docker CLI and Node, skip the export and let the tool run the read-only docker ps + docker inspect for you:
selfhosted-doctor expose --running
selfhosted-doctor scan --runningIf the Docker CLI is missing or the daemon isn't reachable, it prints a clear message (and points you at the docker inspect > file.json recipe above) instead of a stack trace.
Still Compose-first for files on disk
Pointed at a path, selfhosted-doctor still scans Compose stacks exactly as before:
docker-compose.yml,docker-compose.yaml,compose.yml, andcompose.yaml- sibling
.env/.env.*files - Cloudflare Tunnel config near the stack
If you use Portainer, Dockge, or Synology Container Manager and can find the exported stack/compose YAML, scan that folder directly:
npx selfhosted-doctor scan /path/to/exported-stackNote on running containers: there is no Compose
profiles:concept for a live container, so every running container is treated as active. The${VAR:-default}fallback check also won't fire on inspected containers — their env is already resolved — which is expected.
What it checks
The scanner is deterministic — a set of rules across four areas, including default-secret-fallback detection (${VAR:-default} in Compose) and active/conditional/template classification of every finding:
Exposure & privilege (high)
exposed-port— ports published to0.0.0.0/ an unspecified hostdatabase-port-exposed— Postgres/MySQL/MariaDB/Mongo/Redis reachable from the hostprivileged—privileged: truehost-network—network_mode: hostdocker-socket—/var/run/docker.sockmounts
Secrets (high)
plaintext-secret— hardcoded secret values in active.env/ Compose files (values are always redacted; template files are downgraded to info — see below)default-secret-fallback— Compose${VAR:-default}fallbacks that ship a known default when the variable is unset
Image & container hygiene (medium / low)
latest-tag,unpinned-image,missing-healthcheck,missing-restart,runs-as-root,no-user,missing-resource-limits,missing-labels
Cloudflare Tunnel (static, no API calls)
cloudflared-no-access— a tunnel with no Access policy hintcloudflared-tunnel-to-risky— a tunnel routing to a sensitive app (e.g. Vaultwarden) without Access
Service-aware notes (service-notes) add context for Vaultwarden, Immich, Nextcloud, Jellyfin and more — e.g. "your exposed database sits behind Nextcloud" or "back up both the Immich library and its Postgres volume". Detection is image-first, so a nextcloud_db container running mariadb is correctly identified as a database.
Why isn't .env.example flagged as high severity?
Template and example env files — .env.example, .env.sample, .env.template, and anything under examples/ — are expected to contain placeholder defaults. Flagging them as high-severity plaintext secrets would be noise: they're not your running configuration, they're the starting point you copy from. So selfhosted-doctor reports them as low-priority info findings ("change these before you deploy") instead.
Real env files with literal secrets stay high. .env, .env.local, .env.production, and .env.prod are treated as active configuration, so a hardcoded secret in one of them still produces a HIGH finding.
Compose fallback defaults are flagged high, though. A value like POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456} means that a self-hoster who never sets DB_PASSWORD silently ships the well-known default password — so it's reported as Default secret fallback in service environment (HIGH) for active services. A plain ${DB_PASSWORD} reference with no fallback is not flagged, because nothing is baked in.
AI and MCP
The scanner is useful without AI. AI is an optional explanation layer, and the LLM never decides what is a finding — deterministic rules do; AI only rephrases them.
Plain-language explanation (offline mock provider, zero config):
selfhosted-doctor scan ./my-stack -f json -o report.json
selfhosted-doctor explain report.jsonRead-only MCP server — expose the scanner to Claude Code, Cursor, and other MCP clients:
// e.g. Claude Code / Claude Desktop MCP config
{
"mcpServers": {
"selfhosted-doctor": {
"command": "npx",
"args": ["-y", "selfhosted-doctor", "mcp"]
}
}
}Tools (all read-only): scan_compose, list_findings, list_exposed_services, generate_markdown_report. Secrets are redacted before anything leaves the tool.
Examples
Three intentionally-imperfect stacks live in examples/ and double as the test fixtures:
vaultwarden-cloudflare— Vaultwarden behind a Cloudflare Tunnel with no Access policyimmich-postgres— Immich with an exposed Postgres portnextcloud-db— Nextcloud + MariaDB + Traefik + Watchtower (docker socket, privileged, host network)
npx selfhosted-doctor expose examples/vaultwarden-cloudflareDevelopment
npm install
npm test # vitest
npm run build # tsup -> dist/
npm run scan -- examples/nextcloud-db # run the CLI from sourceThe pipeline is load files → parse Compose into a normalized model → run rules → assemble a Report → render. Rules live in src/core/rules/ (one file each) and are the only producers of findings; reporters, MCP, and AI all consume the same Report object. See docs/implementation-notes.md for design decisions, tradeoffs, and the roadmap.
Scope
Docker Compose stack -> deterministic scan -> exposure verdict (expose) + full report (scan, +AI explain, +MCP)Deliberately out of scope: no automatic fixes, no mutating Docker commands, no Cloudflare API calls, no live internet scanning, no Web UI. Docker access is read-only inspect only (docker ps / docker inspect) — the tool never creates, stops, removes, or changes any container, image, or config. Every command changes nothing.
Next direction: a real-probe mode that confirms which entry points are actually reachable from outside.
Disclaimer
selfhosted-doctor is a best-effort configuration checker, not a security guarantee. Always review findings manually before exposing services to the internet.
License
MIT
