The bridge between you and your homelab.
Bifrost is a self-hosted homelab dashboard built around two containers. bifrost-hub (FastAPI + SQLite + a Vue 3 SPA) receives agent reports, stores state and metric history, and serves the dashboard. bifrost-agent is a tiny static Go binary (alpine + smartmontools image), one per node: it collects system metrics, discovers local Docker containers, reads disk SMART health, and detects Kubernetes clusters running on the node. Agents connect outbound to the hub over a persistent WebSocket: no open ports on your nodes, and node-down detection is instant (socket close + application heartbeat).
- Live nodes — CPU, memory, load, network, temperatures streamed in real time; history at three resolutions (raw/1m/1h); per-node filesystems and SMART disk health.
- Docker services — every container on every node becomes a dashboard card with live state, health, usage, an auto-resolved icon and (usually) a working URL. Zero labels needed in the common case.
- Kubernetes — clusters are auto-detected by the agent; Ingresses, Services and CronJobs (with run history) appear alongside Docker services.
- Gateway — bifrost derives one
https://<name>.<your-domain>URL per service and publishes the routing table for your reverse proxy to consume. A Caddy + sidecar example makes the whole homelab reachable with wildcard TLS (examples/gateway). - Endpoints — agentless nodes (HAOS boxes, printers, routers) monitored by hub-side http/tcp/ping checks, with latency history — and routed like any other service.
- Node UIs — NAS portals and similar get their own card, a link on the node, a derived route and their real favicon.
- Service health checks — routed URLs are probed every minute: status, latency and TLS certificate expiry (warning at ≤ 14 days).
- Image update watch — running tags are compared against ghcr.io and Docker Hub every 6 h; cards grow a quiet package glyph when a newer semver tag exists.
- Speedtest — per-node download/upload/latency, run by the agent on demand.
- Control plane (opt-in) — restart, stop, pull, logs and more for every Docker container and Kubernetes workload from the dashboard, gated by an operator key; changes that must last (versions, replicas, new services) go through your GitOps repo, never around it (docs/control-plane.md).
- Alerts — rules by event kind and severity, delivered via ntfy, webhook or Telegram, with cooldowns and a test button.
- Bookmarks & widgets — YAML-synced bookmarks and an ambient rail (clock, weather, Home Assistant entities).
- The details — dark-first hand-built design system, CSS-only animations, EN/ES, drag-to-reorder edit mode, status as dots + tooltips everywhere.
# Hub — one instance, anywhere
services:
bifrost-hub:
image: ghcr.io/zurdi15/bifrost-hub:latest
ports: ["8000:8000"]
environment:
BIFROST_ENROLL_TOKEN: change-me # openssl rand -hex 32
volumes: ["./data:/data"]
restart: unless-stopped# Agent — one per node
services:
bifrost-agent:
image: ghcr.io/zurdi15/bifrost-agent:latest
pid: host
uts: host
environment:
BIFROST_AGENT_HUB_URL: http://hub.example:8000
BIFROST_AGENT_ENROLL_TOKEN: change-me
HOST_PROC: /host/proc
HOST_SYS: /host/sys
HOST_ROOT: /host/rootfs
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/rootfs:ro
- /etc/machine-id:/etc/machine-id:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stoppeddocker compose up -d
Open http://localhost:8000 on the hub: nodes appear in the dashboard as they connect, with the same enroll token on both sides. The commented compose files — SMART devices, node UI, bookmarks — are examples/docker-compose.hub.yml and examples/docker-compose.agent.yml.
Hub settings use the BIFROST_ prefix, agent settings the BIFROST_AGENT_ prefix. The full, commented list is in env.template.
Hub (BIFROST_*):
| Variable | Default | Description |
|---|---|---|
ENROLL_TOKEN |
change-me |
Shared secret agents enroll with |
AUTO_APPROVE |
true |
Auto-approve enrolling nodes |
SERVICE_DOMAIN |
(empty) | Enables URL derivation + gateway feed |
TELEGRAM_BOT_TOKEN |
(empty) | For the telegram alert notifier (rule target = chat id) |
DATA_DIR |
/data |
SQLite + bookmarks location |
BOOKMARKS_FILE |
<data>/bookmarks.yml |
Declarative bookmarks, synced both ways |
METRICS_INTERVAL_S … |
10/60/1800 |
Agent cadences, pushed on connect |
OPERATOR_KEY |
(empty) | Turns the control plane on: write actions ask for this key once per session (docs/control-plane.md) |
GITOPS_REPO / GITOPS_TOKEN |
(empty) | Your GitOps repo: versions, replicas and new services become commits there; drift between Git and what runs is shown on every card |
Agent (BIFROST_AGENT_*):
| Variable | Default | Description |
|---|---|---|
HUB_URL |
(required) | Where the hub lives |
ENROLL_TOKEN |
(required) | Must match the hub |
NODE_NAME |
hostname | Override the node name |
UI_PORT / UI_URL |
(empty) | Declare the node's own web UI |
SPEEDTEST_URL |
(built-in) | Download target for the speedtest (pick a mirror near you) |
METRICS_INTERVAL |
(from hub) | Local sampling override |
CONTROL |
false |
Let the hub restart / stop / pull / deploy containers on this node |
HOST_CONTROL |
false |
Let the hub reboot or shut the host down (needs privileged: true) |
The hub keeps two SQLite databases under DATA_DIR — bifrost.db (state) and metrics.db (history) — plus bookmarks.yml. Migrations run automatically when the hub starts. To back it up, stop the hub first: tarring a live WAL database gives you a torn copy. Agents are stateless; a node's identity derives from its machine-id fingerprint.
The dashboard has no authentication: protect it at the network layer (Tailscale, or a reverse proxy with auth). Agent enrollment is token-authenticated, and with AUTO_APPROVE=false new nodes wait as pending until you approve them in the UI. Agents hold a persistent WebSocket to the hub, so anything you put in front of the hub needs WebSocket upgrade support.
The agent mounts docker.sock read-only and keeps the hub current three ways: a full container list on connect, the Docker event stream (start/die/health) for instant card updates, and periodic stats samples (CPU/mem per container). Containers with no routable port (databases, workers) still get cards — they just don't get URLs.
For containers running with network_mode: host the agent additionally inspects the image's EXPOSE list: those ports are listening on the host itself, so they count as routable — no labels needed for things like NAS backup daemons.
The agent recognizes k3s / kubeadm / k0s on the node and hands the hub a kubeconfig when it can read one, plus the addresses the host answers on; the hub picks the first it can reach (resolved node name, reported IPs, WebSocket peer) and falls back through the rest if the API ever stops connecting. Cards of a cluster that has not synced for a while turn grey (unknown) with a notice, instead of reporting stale replica counts as health. The hub then talks to the API server directly: Ingresses define service URLs, Services map workloads, CronJobs feed the Jobs view with run history. System namespaces (kube-system, argocd, cert-manager, …) are hidden by default; per-workload annotations (below) tune the rest.
Set one variable on the hub and every service gets a URL by convention:
environment:
BIFROST_SERVICE_DOMAIN: example.net| Source | Rule | Route |
|---|---|---|
| Docker container | exactly one published TCP port (or EXPOSE on host-net) |
https://<container>.example.net → node:port |
| Docker container | several ports | excluded until you pick one with bifrost.port |
| k8s Ingress | its real host | already routed by the cluster; shown, never overridden |
| Endpoint node | its http/tcp check target | https://<endpoint>.example.net → target host:port |
| Agent node with a UI | ui_port |
https://<node>.example.net → node:ui_port |
Conflict rules, in order: hand-written proxy vhosts always win (the sync skips their hostnames) → explicit bifrost.url beats convention → derived names yield to k8s Ingress hosts → duplicate hostnames keep the first claimant and the rest are excluded. The Gateway view shows every routed service (with live check status), every cluster Ingress, and every excluded container with the reason, so nothing fails silently.
Labels are the escape hatch, not the requirement:
| Docker label / k8s annotation | Effect |
|---|---|
bifrost.url |
explicit URL — wins over everything |
bifrost.port |
pick the routable port when several are published |
bifrost.path |
redirect / to a subpath (e.g. /admin/) |
bifrost.expose |
false opts out of routing |
bifrost.hide |
hide the card from the dashboard |
bifrost.name / bifrost.icon / bifrost.group |
card cosmetics |
On Kubernetes the routing half is the Ingress's job; the cosmetic ones (bifrost.name/icon/group/hide) work as annotations on the workload.
The hub publishes the result at GET /api/v1/gateway/routes?domain=<domain> — a stable, minimal feed (host/node/port/path) meant for reverse-proxy sync. examples/gateway ships a Caddy setup with a caddy-sync sidecar: wildcard DNS record, DNS-01 wildcard certs, generated handle blocks hot-reloaded through Caddy's admin API, and a catch-all fallthrough (e.g. to a k8s ingress controller).
Some things can't run an agent. Add them as endpoints (Nodes → Endpoints, in edit mode, or POST /api/v1/nodes/endpoints) with an http, tcp or ping check. The hub probes them, keeps latency history, alerts on transitions — and because the check target names a host and port, the endpoint joins the gateway feed like any container.
A node whose OS has a web portal (NAS boxes, routers) can declare it:
environment:
BIFROST_AGENT_UI_PORT: "9999" # → http://<node>:9999
# or the full thing, if port alone isn't enough:
BIFROST_AGENT_UI_URL: https://nas.example.net(Also settable from the node detail view or PATCH /api/v1/nodes/{uuid}; agent env wins — it's declarative config.) The node then gets: a link on its node card and detail header, a machine-styled card in the dashboard's nodes group (front-panel LEDs driven by the node's real CPU and network telemetry), a derived gateway route, and its portal's actual favicon — the hub fetches /favicon.svg|ico|png, apple-touch-icon.png, then falls back to parsing the portal's <link rel="icon">.
- Infrastructure-agnostic. Any mix of bare Docker hosts, NAS boxes, Raspberry Pis and Kubernetes clusters. Bifrost adapts to what it finds.
- Push, not pull. Agents dial the hub. Nodes never expose anything.
- Zero labels by default. Convention derives the URL; labels exist only for the exceptions.
- Open by design. The dashboard has no authentication — protect it at the network layer (Tailscale, reverse proxy). Agent enrollment is token-authenticated.
- One binary, one socket, zero state. Agents are stateless; identity derives from the host machine-id fingerprint.
- Beautiful on purpose. A hand-built design system, CSS-only animations, dark-first.
Requirements: uv, Go 1.23+ and Node 22+.
./dev.sh # hub :8000 + frontend :5173, both with hot reload
./dev.sh --seed # same, filled with lifelike fixtures: fake agents, nodes, services, disks, bookmarks, k8s jobs
cd agent && BIFROST_AGENT_HUB_URL=http://localhost:8000 BIFROST_AGENT_ENROLL_TOKEN=change-me go run ./cmd/bifrost-agent # a real agent against the local hubhub/ Python 3.13 · FastAPI · SQLAlchemy 2 · Alembic · uv
agent/ Go · gopsutil · coder/websocket
frontend/ Vue 3 · Vite · TypeScript · Pinia · Tailwind 4 · Storybook
docker/ Multi-arch Dockerfiles (amd64 + arm64)
examples/ docker-compose examples, gateway (Caddy + sync), k8s RBAC
docs/ Architecture, protocol, agent, k8s, HAOS, roadmap
Use the dashboard at http://localhost:5173; the API and its docs live at http://localhost:8000/api/docs. Tests: cd hub && uv run pytest · cd agent && go test ./... · cd frontend && npm test. Deeper docs — architecture, protocol, agent, Kubernetes, HAOS, roadmap — live in docs/.
MIT — see LICENSE.




