Command Palette

Search for a command to run...

UnylyUnyly
Весь каталог

Selfhosted Doctor

БесплатноНе проверен

Read-only MCP server that scans Docker Compose, Cloudflare Tunnel, and .env files for security risks in self-hosted homelabs.

GitHubEmbed

Описание

Read-only MCP server that scans Docker Compose, Cloudflare Tunnel, and .env files for security risks in self-hosted homelabs.

README

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 / 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.

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). 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-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):

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:

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:

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
  • privilegedprivileged: true
  • host-networknetwork_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):

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:

// 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 policy
  • immich-postgres — Immich with an exposed Postgres port
  • nextcloud-db — Nextcloud + MariaDB + Traefik + Watchtower (docker socket, privileged, host network)
npx selfhosted-doctor expose examples/vaultwarden-cloudflare

Development

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 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

from github.com/Kevin9703/selfhosted-doctor

Установка Selfhosted Doctor

У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.

▸ github.com/Kevin9703/selfhosted-doctor

FAQ

Selfhosted Doctor MCP бесплатный?

Да, Selfhosted Doctor MCP бесплатный — установка в пару кликов через Unyly без оплаты.

Нужен ли API-ключ для Selfhosted Doctor?

Нет, Selfhosted Doctor работает без API-ключей и переменных окружения.

Selfhosted Doctor — hosted или self-hosted?

Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.

Как установить Selfhosted Doctor в Claude Desktop, Claude Code или Cursor?

Открой Selfhosted Doctor на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.

Похожие MCP

Compare Selfhosted Doctor with

Не уверен что выбрать?

Найди свой стек за 60 секунд

Автор?

Embed-бейдж для README

Похожее

Все в категории development