selfhosted-doctor
by Kevin9703
README.md
# 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:
```bash
npx selfhosted-doctor expose docker-compose.yml
```
```text
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
```bash
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 / AI
```
**`expose`** 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.
```bash
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 stdio
```
`path` 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](#scanning-already-running-containers-no-compose-file)). 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:
```bash
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-profiles` scores 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):
```bash
docker inspect $(docker ps -q) > containers.json
npx selfhosted-doctor expose containers.json
```
`docker 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:
```bash
selfhosted-doctor expose --running
selfhosted-doctor scan --running
```
If 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`, and `compose.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:
```bash
npx selfhosted-doctor scan /path/to/exported-stack
```
> Note 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 to `0.0.0.0` / an unspecified host
- `database-port-exposed` — Postgres/MySQL/MariaDB/Mongo/Redis reachable from the host
- `privileged` — `privileged: true`
- `host-network` — `network_mode: host`
- `docker-socket` — `/var/run/docker.sock` mounts
**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 hint
- `cloudflared-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):
```bash
selfhosted-doctor scan ./my-stack -f json -o report.json
selfhosted-doctor explain report.json
```
**Read-only MCP server** — expose the scanner to Claude Code, Cursor, and other MCP clients:
```jsonc
// 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/`](examples/) and double as the test fixtures:
- `vaultwarden-cloudflare` — Vaultwarden behind a Cloudflare Tunnel with no Access policy
- `immich-postgres` — Immich with an exposed Postgres port
- `nextcloud-db` — Nextcloud + MariaDB + Traefik + Watchtower (docker socket, privileged, host network)
```bash
npx selfhosted-doctor expose examples/vaultwarden-cloudflare
```
## Development
```bash
npm install
npm test # vitest
npm run build # tsup -> dist/
npm run scan -- examples/nextcloud-db # run the CLI from source
```
The 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`](docs/implementation-notes.md) for design decisions, tradeoffs, and the roadmap.
## Scope
```text
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
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessUnresponsive