A self-hosted dashboard for a Docker homelab — server telemetry, container health, and an app launcher in one page.
🌐 Live Website & Quick Install Guide · 📦 GHCR Container Package
Guardian reads your machine directly: /proc and /sys for host metrics, the Docker socket for containers. No agent, no database, no cloud. It runs in about 40 MB of RAM and pushes updates over Server-Sent Events rather than polling.
- Showcase
- What it does
- How it works
- Quick start
- Environment variables
- Enabling shutdown and reboot
- Runtime settings
- API reference
- Local development
- Design notes
- Project layout
| Dashboard & Host Telemetry | App Launcher & In-Tile Widgets |
|---|---|
![]() |
![]() |
| Real-time telemetry, Intel GPU stats, power, and storage gauges | App launcher with live in-tile widgets (Jellyfin, Bazarr, etc.) |
Endpoint Health Prober
Automated HTTP health checks across container ports with latency and status codes
| Signal | Read from | Notes |
|---|---|---|
| CPU utilisation | /proc/stat |
Total plus per-core, drawn as a strip so one pegged thread is visible inside a calm average |
| I/O wait | /proc/stat |
Reported separately from utilisation — a disk-bound host can sit at 3% CPU while everything stalls |
| Load average | /proc/loadavg |
Shown as a percentage of thread count, because "2.35" means nothing on its own |
| Memory & swap | /proc/meminfo |
Used, available, buffers/cache, swap |
| Temperature | /sys/class/hwmon, /sys/class/thermal |
hwmon first — that's where coretemp package readings live |
| Network | /proc/net/dev |
Per-interface throughput; virtual and overlay interfaces are excluded from "primary link" |
| Storage | /proc/mounts + statfs |
Every real filesystem with its true device and fs type |
| Uptime & availability | /proc/uptime, /proc/stat |
Live continuous operation down to the second, boot record, and lifetime CPU efficiency |
| S.M.A.R.T. & drive health | smartctl, /sys/block |
Per-drive SMART status (PASSED/FAILING), temperatures, NVMe wearout & TBW, power-on hours, and critical sector alerts |
Each metric tile opens a history page: full-size charts with 1H / 6H / 24H / 7D / 30D ranges, current / average / peak / minimum, and a table view.
Guardian calls both /containers/json and /containers/{id}/json, because the first one alone will happily report a crash-looping container as "running":
- Restart counts, exit codes, OOM kills and the daemon's own error string
- Healthcheck logs — the last few probe results with their output, so "unhealthy" comes with a reason
- Live CPU and memory over a persistent stats stream, so values are ~1 second old rather than up to 30
- Shared network namespaces resolved by name (a container on
network_mode: container:gluetunsays via gluetun) - Container logs with stdout and stderr separated, filtering, follow mode, and adjustable tail
Anything needing attention is sorted to the front of the grid, and a summary line says either how many containers have a problem or that everything is healthy.
An HTTP prober checks every container's published port on a 60-second cycle, reporting latency and distinguishing 2xx, redirects, 401/403, client errors and server errors. Targets are derived from what is actually running — plus any port your bookmarks point at, which covers host services that aren't containers.
Auto-discovers containers and turns them into clickable tiles. Custom icons (60+ presets or any image URL), custom launch URLs with {host} / {lan} / {tailscale} placeholders, categories, search (/ to focus), pinning, and bookmarks for anything outside Docker.
- Docker reclaimable space with one-click prune of dangling images only
- Application logs — Guardian's own structured log, filterable by level and scope, persisted across restarts
- Shutdown / reboot, off by default and heavily gated (details below)
Running off-host? Without
/proc,/sysor a Docker socket, Guardian serves clearly-labelled sample data and names the source it couldn't reach. It never presents synthesized values as real measurements.
┌──────────── browser ────────────┐
│ React SPA · SSE · hash routes │
└───────────────┬─────────────────┘
│ /api/live (SSE, heartbeated)
┌───────────────▼─────────────────┐
│ Express server │
│ │
│ one sampling loop ──────────► snapshot ──► every reader
│ │ │
│ ├─ /proc, /sys (host telemetry) │
│ ├─ docker.sock (containers, stats streams) │
│ ├─ HTTP prober (endpoint health) │
│ └─ tiered history (30 days, on disk) │
└─────────────────────────────────┘
One sampler, many readers. The CPU and network collectors diff kernel counters, so calling them twice in quick succession makes the second read report near-zero. A single loop samples on a fixed cadence and publishes an immutable snapshot; /api/status and every SSE client serve that snapshot rather than re-sampling.
Tiered history. A month of 15-second samples would be ~180,000 points per metric. Guardian keeps every sample for 6 hours, 5-minute averages for 7 days, and hourly averages for 30 days — about 3,600 points total, which fits in memory, serialises to a small JSON file, and survives restarts.
Streaming container stats. One long-lived connection per running container instead of a request per container per poll. Connection count stays flat as the poll rate changes.
Grab docker-compose.yml.example directly or paste the compose configuration below:
# Download and start in one step
curl -sSL https://raw.githubusercontent.com/vineetkishore01/Guardian/main/docker-compose.yml.example -o docker-compose.yml
docker compose up -dOr create docker-compose.yml manually:
services:
guardian:
image: ghcr.io/vineetkishore01/guardian:latest
container_name: guardian
restart: unless-stopped
ports:
- "3001:3001"
volumes:
# Container discovery, stats and logs (/var/run/docker.sock for rw actions)
- /var/run/docker.sock:/var/run/docker.sock
# Host telemetry
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/os-release:/host/etc/os-release:ro
- /etc/hostname:/host/etc/hostname:ro
# Storage — mount root (rslave allows submounts like /mnt or /media to resolve)
- /:/host/root:ro,rslave
# Persisted icons, bookmarks, history and logs
- guardian_data:/data
environment:
- HOST_PROC=/host/proc
- HOST_SYS=/host/sys
- HOST_ETC=/host/etc
- HOST_ROOT=/host/root
- PORT=3001
- NODE_ENV=production
deploy:
resources:
limits:
memory: 128M
cpus: '0.50'
volumes:
guardian_data:Then open http://<your-server>:3001.
git clone https://github.com/vineetkishore01/Guardian.git
cd Guardian
docker compose up -d --buildEverything is optional — Guardian starts with no configuration and discovers what it can.
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
Port the HTTP server listens on. |
BIND_HOST |
0.0.0.0 |
Address to bind. Set to 127.0.0.1 to accept only local connections, e.g. behind a reverse proxy. |
Inside a container the host's filesystems are bind-mounted elsewhere, so each collector's root is configurable. Values must match the container-side path you mounted to.
| Variable | Default | Description |
|---|---|---|
HOST_PROC |
/proc |
procfs mount point. Source of CPU, memory, load, uptime, network and mount data. Without it, host metrics fall back to sample values. |
HOST_SYS |
/sys |
sysfs mount point. Source of temperatures (hwmon and thermal). |
HOST_ETC |
/etc |
Used to read os-release for the distro name. Without it Guardian reports the kernel name instead of e.g. "Debian GNU/Linux 13". |
HOST_ROOT |
/ |
The root filesystem to measure. Set to /host/root when you bind-mount / elsewhere. |
HOST_NAS |
/mnt/nas |
An additional volume to measure. Despite the name it can be any path. |
HOST_MOUNTS |
(empty) | Comma-separated extra mount points to watch, e.g. /host/mnt/backup,/host/srv. Filesystems found in /proc/mounts are picked up automatically; use this for anything that isn't. |
| Variable | Default | Description |
|---|---|---|
DOCKER_SOCKET |
/var/run/docker.sock |
Path to the Docker daemon socket. Read-only access is enough for everything except pruning. Without it, Guardian shows a labelled sample container list. |
| Variable | Default | Description |
|---|---|---|
DATA_DIR |
/data |
Where guardian.json (icons, bookmarks, settings), history.json (30-day metrics) and logs.json are written. Falls back to ./data when the directory does not exist, which is what happens during local development. |
These only seed the initial values; both are editable in Settings afterwards and stored in guardian.json.
| Variable | Default | Description |
|---|---|---|
SERVER_IP |
(empty) | LAN address used to build app URLs when the launch target is set to LAN. Left empty, the LAN option is not offered. |
TAILSCALE_IP |
(empty) | Tailscale address, used when the launch target is set to Tailscale. Left empty, the option is not offered. |
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
Minimum level recorded: debug, info, warn or error. Entries appear in the dashboard's log panel and on stdout, so docker logs guardian keeps working. |
| Variable | Default | Description |
|---|---|---|
ENABLE_POWER_CONTROLS |
false |
Set to the exact string true to allow shutdown and reboot. While unset the control is hidden entirely — not shown disabled. |
GUARDIAN_POWER_COMMAND |
(auto-detect) | Custom command to run, with {action} expanded to poweroff or reboot. Split on whitespace and executed as an argument array — never through a shell. Auto-detection tries systemctl, then shutdown. |
This is the only endpoint that can take the machine down, so it is deliberately hard to trigger by accident:
- It does nothing unless
ENABLE_POWER_CONTROLS=true. - The caller must type the machine's hostname to confirm.
- Every attempt, including refusals, is written to the application log.
environment:
- ENABLE_POWER_CONTROLS=trueGuardian runs in a container, so systemctl poweroff acts on the container, not the host — you'd get a button that restarts Guardian. The dialog warns you when it detects this. To reach the host, supply a command that crosses the boundary:
volumes:
- /path/to/key:/keys/guardian:ro
environment:
- ENABLE_POWER_CONTROLS=true
- GUARDIAN_POWER_COMMAND=/usr/bin/ssh -i /keys/guardian -o StrictHostKeyChecking=yes admin@host sudo systemctl {action}Restrict the key in the host's authorized_keys so it can only run those two commands:
command="/usr/bin/sudo /bin/systemctl $SSH_ORIGINAL_COMMAND",no-port-forwarding,no-pty ssh-ed25519 AAAA...
Alternatives are pid: host plus nsenter, or mounting the systemd D-Bus socket. Both grant the container substantially more power over the host than a scoped SSH key.
⚠️ Guardian has no authentication. Anyone who can reach the port can read everything and, with power controls on, shut the machine down. Keep it on a trusted network or behind an authenticating reverse proxy.
Editable in the UI, stored in guardian.json, no restart required:
| Setting | Effect |
|---|---|
| App launch target | Whether tile URLs are built from the browser's address, the LAN IP, the Tailscale IP, or a custom domain. |
| LAN / Tailscale address | The addresses those options use. Also seeds the {lan} and {tailscale} placeholders. |
| Dashboard title | Shown in the browser tab. |
| Telemetry interval | 5–300 seconds. Applied immediately — the sampling loop re-arms without a restart. |
| Backup | Exports icons, categories and bookmarks as JSON. |
| Method | Route | Purpose |
|---|---|---|
GET |
/api/health |
Liveness, SSE client count, open stats streams, last sample time |
GET |
/api/status |
Current telemetry snapshot |
GET |
/api/live |
SSE stream of snapshots, with heartbeats |
GET |
/api/history/:metric |
One metric's history — ?range=1h|6h|24h|7d|30d |
GET |
/api/history |
Several at once — ?metrics=cpu,ram&range=24h |
GET |
/api/logs |
Application log — ?level=&scope=&limit= |
DELETE |
/api/logs |
Clear the log buffer |
GET |
/api/containers/:id/logs |
Container logs — ?tail=200 |
GET |
/api/config |
Full persisted config, for backup |
POST |
/api/config/settings |
Update settings |
POST |
/api/containers/:name/custom |
Per-container icon, URL, category, pin, hide |
POST |
/api/custom-apps |
Create or update a bookmark |
DELETE |
/api/custom-apps/:id |
Delete a bookmark |
POST |
/api/docker/prune |
Prune dangling images |
POST |
/api/probes/refresh |
Force a health-probe cycle |
GET |
/api/power |
Whether power control is available, and why not if it isn't |
POST |
/api/power/:action |
shutdown or reboot, body { "confirmation": "<hostname>" } |
Metric keys: cpu, ram, swap, temp, netRx, netTx, disk.
# Install
npm install && (cd client && npm install) && (cd server && npm install)
# Client on :5173 with hot reload, server on :3001
npm run dev
# Or build and serve the production bundle from the server
npm run build && npm startOn a non-Linux machine the host and Docker collectors fall back to sample data and the dashboard says so — the UI is fully developable without a Linux host.
To exercise the Docker code paths without a daemon, point DOCKER_SOCKET at any process speaking the Engine API subset Guardian uses (/containers/json, /containers/{id}/json, /containers/{id}/stats, /containers/{id}/logs, /system/df).
A single neutral ramp carries the interface, one brand hue marks anything interactive, and three status hues are reserved for state. Colour always encodes meaning: a metric turns amber or red because it crossed a threshold, never for decoration. A healthy dashboard is almost entirely monochrome, so anything coloured is worth looking at.
The dark theme is AMOLED true black — the page plane is #000, where an OLED panel switches the pixel off. Since shadows are invisible on black, elevation comes from brighter borders and a lit top edge instead.
Chart series use a four-slot categorical palette assigned in fixed order and validated in both themes against the real surfaces for lightness band, chroma floor, colour-vision separation and contrast. Charts start the y-axis at zero, break the line across data gaps rather than interpolating across them, and offer a table view so nothing depends on colour alone.
Guardian/
├── Dockerfile Multi-stage production image
├── docker-compose.yml Local development deployment
├── docker-compose.yml.example Standalone production template (GHCR image)
├── server/ Node · TypeScript · Express
│ └── src/
│ ├── collectors/
│ │ ├── host.ts /proc & /sys — CPU, memory, temps, network
│ │ ├── disk.ts Filesystem discovery via /proc/mounts
│ │ └── docker.ts Socket client, stats streams, log demux
│ ├── prober.ts HTTP health checks on discovered ports
│ ├── history.ts Tiered 30-day metric store
│ ├── logger.ts Structured application log
│ ├── power.ts Guarded shutdown / reboot
│ ├── store.ts Persistence + payload validation
│ └── index.ts Sampling loop, SSE, API routes
└── client/ React 18 · Vite · Tailwind
└── src/
├── components/
│ ├── ui/ Card, Button, Dialog, Input, Badge, Progress, Tabs
│ ├── layout/ Header, PowerMenu, SettingsModal, PruneBanner
│ ├── metrics/ MetricCard, CoreStrip, HostStatsBar, StorageGauges
│ ├── apps/ AppGrid, AppCard, Add/Edit modals
│ ├── logs/ LogsPanel, ContainerLogsModal
│ ├── services/ ServicesTable
│ └── charts/ LiveSparkline, TimeSeriesChart
├── pages/ MetricDetailPage
├── hooks/ useLiveTelemetry
└── lib/ formatters, severity, router, metrics, icons
This project is licensed under the MIT License - see the LICENSE file for details.



