Skip to main content
Glama
Kevin9703

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