Ansible-fed, SQLite-backed CMDB for your homelab — see every host, container, CVE, and Tailscale node in one offline-capable web UI, and query it all from Claude via MCP.
Dashboard: severity breakdown, 30-day vulnerability trend, fleet freshness (one stale host), and security posture — all from the built-in demo dataset.
More views: host detail · network. All screenshots are from the built-in fictional demo dataset.
git clone https://github.com/osaljehani/homelab-cmdb && cd homelab-cmdb
uv sync
uv run cmdb demoOr with Docker (no uv needed):
docker compose --profile demo up cmdb-demoEither path seeds a fictional sample fleet — 6 hosts, a Kubernetes cluster, Docker containers, and
trivy CVE scans — and serves it at http://127.0.0.1:8080 (http://localhost:8080 under Docker). The
demo uses a throwaway database in your temp directory; your real cmdb.db is never touched.
The
docker composedemo builds the image locally on first run. Prebuiltghcr.io/osaljehani/homelab-cmdbimages are published starting with the first release.
- Homelab-scale, not enterprise DCIM. NetBox and friends are excellent but heavyweight for a 5–20 host lab. This is one binary and one file.
- Single SQLite file, no external services. No Postgres, no Redis, no message broker — the
whole CMDB is
data/cmdb.db. - Agentless. Facts come from SSH + Ansible; nothing is installed on your hosts. File-based import works too, for machines you can't reach from the controller.
- One inventory, linked. Hosts, Docker containers, Kubernetes topology, trivy CVEs, and Tailscale state live in the same database — containers link to scanned images by ref.
- Interactive topology graph. See the whole lab at once: compose-nested containers, K8s clusters, subnets, the tailnet, and exposure rings for listening ports and serve/funnel.
- Fully offline. Fonts, HTMX, and Cytoscape are vendored — no CDN, no outbound calls. Charts are server-rendered SVG, no JS chart library. Graphite terminal-style dark theme, with a light theme included.
- LLM-native. A 23-tool MCP server lets you ask Claude "what's stale?" or "what's exposed?" against your live inventory.
- Hosts identity, OS, hardware, network, storage, security (AppArmor / SELinux / FIPS) and virtualization, imported from Ansible facts. Tag and search hosts.
- Security posture each host is judged hardened (AppArmor enabled or SELinux
enforcing/enabled) or exposed, surfaced as a dashboard panel, a Security column on the hosts
list, and a line in
cmdb hosts show. FIPS is reported as informational. - Docker inventory track containers per host (image, state, ports, compose project). Re-importing a host replaces its container set, so removed containers disappear.
- Kubernetes model clusters, nodes (by role) and namespaces, imported from
kubectl. - Tailscale per-host tailnet identity (Tailscale IP, MagicDNS name, tags, exit-node, online state) plus any serve/funnel-exposed services.
- Listening ports open TCP/UDP listeners per host (proto, address, port, owning process),
collected from
ss. - Image vulnerabilities ingest trivy image scans (per-image history, severity counts, browsable findings); flag expected-noisy images (e.g. a pentest arsenal) out of the rollup.
- Change history every import snapshots a host's meaningful fields and records a per-host diff timeline (e.g. a kernel upgrade or IP change), skipping volatile values like uptime.
- Dashboard severity breakdown with a 30-day vulnerability trend, security posture,
fleet freshness (stale-host detection via
CMDB_STALE_DAYS, Tailscale online/offline), a recent-changes feed and OS mix all as server-rendered SVG/CSS charts, no JS chart library. - Topology visualizer an interactive Cytoscape map of the whole lab: hosts with containers nested by compose project, K8s clusters with member roles, subnets and the tailnet (exit nodes, online state), exposure rings for listening ports and serve/funnel, and container→image edges colored by scan severity. Layers (infra / network / exposure / images) toggle client-side.
- Global search the nav search box finds hosts, containers and images by name, IP, MagicDNS name or image ref, with live results.
- On-demand collection pull facts, Docker, Kubernetes, Tailscale and port state live over SSH from the CMDB host (via Ansible), instead of running export scripts on each device. The Ansible inventory is generated from the database by default. See below.
- Generate Ansible inventory (YAML/INI) and SSH config from your inventory.
- MCP server query and manage the CMDB from LLM clients (Claude Code, Claude Desktop) via a Model Context Protocol server over stdio. See below.
- Read-only JSON API
GET /api/v1/{hosts,containers,clusters,images,vuln-summary}for scripting and dashboards, using the same response models as the MCP server. Interactive docs at/docs. Unauthenticated, like the rest of the app — keep the port LAN-only. - Export / restore dump the whole CMDB to JSON/YAML (
cmdb export, or download from the Generate page) and restore it into an empty database (cmdb restore). Portability and inspection — copyingdata/cmdb.dbremains the real backup.
See docs/ROADMAP.md for planned features.
The demo above serves a throwaway fleet. To stand up an empty instance for your own inventory, use one of the two paths below, then import Ansible facts.
docker compose upThe web UI is available at http://localhost:8080. The database persists in ./data/cmdb.db.
After the first release, docker compose pull cmdb fetches the prebuilt image — otherwise
docker compose up builds it locally, since the compose file has both image: and build:
and only pulls when the image is absent.
Requires Python 3.12+ and uv.
uv sync --all-groups
uv run cmdb db upgrade
uv run cmdb serve # web UI on :8080uv sync --all-groups includes the optional collect dependency group (ansible-core), needed
for on-demand collection. Use uv sync alone if you only need file-based import.
ansible -m setup localhost > homelab.jsonansible -i inventory.ini all -m setup > homelab.jsonBoth commands write all host output to a single file. The importer handles the multi-host format
automatically, splitting each hostname | SUCCESS => block and upserting each host separately.
If you prefer one file per host, use --tree to write directly into a directory:
ansible -i inventory.ini all -m setup --tree out/Open http://localhost:8080/import and upload the JSON file. Single-host and multi-host files are both accepted.
# Import a single file
uv run cmdb import ansible ./homelab.json
# Import a directory of per-host files (from --tree)
uv run cmdb import ansible ./out/For hosts you can't (or don't want to) reach over SSH from the controller, the export helpers in
scripts/ generate import JSON you can upload or import by file. The host must already exist in
the CMDB (imported via Ansible) records are matched to it by hostname/FQDN.
# Docker: run on the Docker host (requires docker + jq)
./scripts/docker-export.sh > my-host-docker.json
uv run cmdb import docker ./my-host-docker.json
# Kubernetes: run where kubectl is configured (requires jq)
./scripts/k8s-export.sh <cluster-name> "description" [--context <ctx>] > my-cluster.json
uv run cmdb import k8s ./my-cluster.jsonThe Docker JSON format is {"host": "<hostname>", "containers": [ ... ]}; raw
docker ps --format json fields are also accepted. Re-importing a host replaces its entire
container set. K8s import is additive re-runs never duplicate nodes or namespaces.
CMDB ingests container-image vulnerability scans but never runs a scanner itself. An external
job produces trivy output, wraps it in an envelope JSON, and pushes it in via
cmdb import trivy <file> or the web upload at /import. Two ready-to-use example scripts
scan on a schedule and import for you:
# Docker runtime scan: scans the images of running containers (requires docker + jq)
./scripts/trivy-scan.sh
# Optional OCI-registry scan: scans a Zot-style registry catalog (requires curl + jq)
./scripts/trivy-scan-registry.shRun them from the systemd timer template in scripts/systemd/ (or cron). A running container
appears under Images only once its image has been scanned & imported — the Images view is
scan-driven, separate from the container inventory. See
docs/image-scanning.md for the envelope contract, setup, and how to
roll your own scanner.
Instead of running export scripts on each device and uploading files, the CMDB host can reach out
over SSH and collect state itself, then load it through the same import pipeline. Collection
drives the ansible binary, so it needs:
-
The optional
collectdependency group (bundled in the Docker image):uv sync --all-groups # or: uv sync --group collect -
SSH access from the CMDB host to your machines. The Ansible inventory is generated from the database by default every host already in the CMDB is targeted automatically, with SSH credentials injected from config:
export CMDB_ANSIBLE_USER=ansible # SSH user export CMDB_SSH_PRIVATE_KEY=~/.ssh/id_ed25519 # SSH private key
To use a fixed, hand-written inventory instead, set
CMDB_ANSIBLE_INVENTORY=./inventory.ini(or pass-i/ upload a file on the/collectpage for a single run). Inventory resolution order is: explicit-i/ uploaded file →CMDB_ANSIBLE_INVENTORY→ generated from the database.
Then collect from the CLI or the Collect page in the web UI (which also adds a "Collect now" button on each host's detail page):
uv run cmdb collect facts # gather Ansible facts for all hosts
uv run cmdb collect docker # gather docker ps for all hosts
uv run cmdb collect k8s # gather Kubernetes topology (kubectl over SSH)
uv run cmdb collect tailscale # gather Tailscale identity + exposed services
uv run cmdb collect ports # gather listening TCP/UDP ports (ss)
uv run cmdb collect all # all of the above in one pass
uv run cmdb collect facts --limit web01 # restrict to a single host or groupNotes on behaviour:
collect allruns facts first (so host-keyed collectors can resolve hosts), then Docker, Kubernetes, Tailscale and ports.- Host-centric collectors skip cleanly. A host without
docker(e.g. a k3s-only node), a non-Kubernetes host, or a host withouttailscaleis skipped silently rather than erroring and a transient failure never wipes a host's existing data. Kubernetes collection lets a control-plane node enumerate its whole cluster, so workers are discovered without being reached directly. - Unreachable or failed hosts are reported in the run's notes. The
scripts/*-export.shfile-based path remains a fallback for hosts you can't SSH to from the controller.
uv run cmdb demo # seed a fictional demo fleet and serve it (never touches real data)
uv run cmdb hosts list # list all hosts
uv run cmdb hosts show <hostname> # show host detail
uv run cmdb hosts history <hostname> # show a host's change history (field diffs)
uv run cmdb hosts tag <hostname> <tag> # add a tag
uv run cmdb hosts untag <hostname> <tag> # remove a tag
uv run cmdb hosts note <hostname> [TEXT] # show or set a freeform note (--clear to remove)
uv run cmdb hosts set-field <hostname> <key> <value> # set a custom field
uv run cmdb hosts unset-field <hostname> <key> # remove a custom field
uv run cmdb import ansible <path> # import hosts from Ansible facts (file or directory)
uv run cmdb import docker <path> # import containers from docker-export.sh JSON
uv run cmdb import k8s <path> # import K8s topology from k8s-export.sh JSON
uv run cmdb collect facts [-i PATH] [--limit HOST] # collect facts over SSH
uv run cmdb collect docker [-i PATH] [--limit HOST] # collect containers over SSH
uv run cmdb collect k8s [-i PATH] [--limit HOST] # collect K8s topology over SSH
uv run cmdb collect tailscale [-i PATH] [--limit HOST] # collect Tailscale state over SSH
uv run cmdb collect ports [-i PATH] [--limit HOST] # collect listening ports over SSH
uv run cmdb collect all [-i PATH] [--limit HOST] # all collectors in one pass
uv run cmdb generate inventory --format yaml|ini # generate Ansible inventory
uv run cmdb generate ssh-config # generate SSH config
uv run cmdb export [PATH] [--format yaml] # dump the whole CMDB to JSON/YAML (stdout by default)
uv run cmdb restore <PATH> [--force] # restore a dump into an empty DB (--force wipes first)
uv run cmdb serve # start the web UI
uv run cmdb mcp # start the MCP server (stdio) for LLM clients
uv run cmdb db upgrade # run database migrationscmdb mcp runs a Model Context Protocol server over
stdio, exposing the CMDB to LLM clients (Claude Code, Claude Desktop, etc.) as callable
tools. It is a thin shell over the same domain layer as the CLI and web UI, so it queries
and mutates the same database. It runs pending migrations on startup, and is spawned on
demand by the client (no long-running process, no open ports).
Install the optional dependency group, then register it with Claude Code:
uv sync --group mcp
claude mcp add homelabcmdb \
--env CMDB_DB_PATH=/path/to/HomeLabCMDB/data/cmdb.db \
-- uv run --project /path/to/HomeLabCMDB cmdb mcpSet CMDB_DB_PATH to an absolute path so the server finds the database regardless of the
client's working directory. Point it at the same database the web container uses (the
./data/cmdb.db Docker volume by default) so both write a single source of truth with no
drift. The file must be writable by the user running the server (the parent directory too,
for SQLite's journal). Exposed tools include: list_hosts, get_host, add_tag /
remove_tag, delete_host, host_posture, posture_summary, host_history,
list_clusters / list_nodes, add_cluster / delete_cluster / add_node /
remove_node, generate_inventory_yaml / generate_inventory_ini / generate_ssh_config,
and import_ansible. (Agentless SSH collection is intentionally not exposed.)
uv sync --all-groups
uv run pytest tests/ -v # run all tests
uv run pytest tests/ -k "import" # run filtered tests
uv run ruff check cmdb/ # lint
just test # run the full suite via the justfileThe real, fixture-bearing test_*.py files live only on the maintainer's machine — they carry
real homelab hostnames, IPs, and usernames and are gitignored, never committed. What CI runs (and
what ships in this repo) is the committed test suite plus scrubbed *-example.py copies with
purely fictional fixture data (testhost / 192.168.1.10-style placeholders). On a fresh clone, each
tests/*-example.py is materialized into its tests/*.py counterpart — see
.github/workflows/ci.yml. Contributions must use fictional data
only (fictional hostnames, RFC 5737 IPs like 192.0.2.x, example.lan domains).
| Variable | Default | Purpose |
|---|---|---|
CMDB_DB_PATH |
./cmdb.db |
Path to the SQLite database file |
CMDB_HOST |
0.0.0.0 |
Bind address for the web server |
CMDB_PORT |
8080 |
Port for the web server |
CMDB_SECRET_KEY |
change-me-in-production |
Session secret set to a random value in production |
CMDB_STALE_DAYS |
7 |
Days without fresh facts before a host counts as stale on the dashboard |
CMDB_STORAGE_WARN_PCT |
85 |
Used-space percentage at which a mount appears in the dashboard storage warnings |
CMDB_ANSIBLE_INVENTORY |
(unset) | Fixed Ansible inventory path; overrides DB generation when set |
CMDB_ANSIBLE_USER |
(unset) | SSH user injected into the generated inventory |
CMDB_SSH_PRIVATE_KEY |
(unset) | SSH private key path injected into the generated inventory |
CMDB_ANSIBLE_SSH_ARGS |
(unset) | ansible_ssh_common_args for the generated inventory. Unset applies a sane default; an empty string disables it |
cmdb/
cli/ Typer CLI entry points (thin shell over domain)
web/ FastAPI routes and Jinja2 templates (thin shell over domain)
static/ Vendored assets (fonts, htmx, cytoscape) refresh via scripts/vendor-assets.sh
mcp/ MCP server exposing domain services as tools (thin shell over domain)
demo/ Demo-mode seeder and fictional fixture data (cmdb demo)
domain/
models.py SQLAlchemy models (Host, Tag, Container, K8s*, TailscaleService, ListeningPort, …)
services/ All business logic lives here (ansible, docker_import, k8s_import,
tailscale_import, ports_import, collect, generate, history, security,
dashboard, topology, search)
db/ Session + Alembic migrations
config.py Settings (CMDB_* environment variables)
scripts/ Export helpers (docker-export.sh, k8s-export.sh), vendor-assets.sh
tests/ pytest test suite
docs/ Roadmap and additional docs
data/ Database volume mount (Docker)


