Your whole home server on one screen — and you can act on it right there.
简体中文 · English
Security alerts, disk capacity, service health, host load, network throughput, certificate expiry, exposed ports, live connections, Docker containers, drive SMART — thirteen collectors on a single page. And not just watching: ban an IP, restart a container, get a disk-full ETA, and receive a push on your phone when something breaks. It also manages multiple machines: ban once, enforced everywhere.
The frontend has no build step and no dependencies. Charts are hand-written SVG. Three files, that's it.
Live demo → https://homelab.88688.team (fully simulated data, click anything)
- What it's for
- Features
- Getting Started
- Configuration
- Container permissions
- Security notes
- Features in detail
- Deploying to a remote host
- Managing multiple machines
- External watchdog
- Troubleshooting
- Architecture
- Development
- Roadmap
- Contributing
- License
When you run a NAS or a small server at home, its state is scattered across a
dozen places: the NAS web UI for capacity, Portainer for containers, cscli for
attacks, SSH plus df -h for disks — and nothing at all reminds you that a
certificate expires next week. When something actually goes wrong, you go
digging through all of them.
This dashboard pulls them onto one page, and lets you act where you found the problem: see an IP brute-forcing SSH, hit "ban"; see a container down, restart it on the spot.
Grafana is great at time series and multiple data sources, weak at "one-screen card overview", and it can't do one-click IP bans — it's a read-only observability surface, not a control panel. If you already run Prometheus, the two coexist happily: Grafana for trend detail, this for "is everything OK right now".
- One Linux box that's always on (NAS, mini PC, retired laptop)
- Docker installed, a handful of services running
- You want to know if someone is attacking it, and to strike back directly
- Large-scale clusters — it handles a handful to a dozen machines (see Managing multiple machines); beyond that reach for Prometheus and Ansible
- Long-term high-resolution metrics — history is minute-level sampling; use Prometheus
- Multi-user access control — login is single-user, no RBAC, and none is planned
| Tab | What's in it |
|---|---|
| Overview | Security posture, managed nodes, storage, host load, network, service health, port exposure, containers, drives, certificates |
| Firewall | Ban list (manual / detected / community blocklist), top attack sources, country and ASN breakdown, allowlist, one-click ban and unban |
| Security Center | Exposure gaps, enforcement pipeline, zoomable Leaflet world/China attack and active-ban map, cross-node incident handling, optional CTI, read-only 1Panel WAF visibility, and reversible temporary bans |
| Connections | Who is connected right now, with GeoIP attribution, human-readable TCP states, grouped by service port |
| Ports | Every listening port, graded as public / LAN-reachable / localhost-only |
| History | Trend charts, event timeline, operation audit, capacity forecast, sampling health |
| Containers | Start/stop/restart, log viewer, protected list, btrfs snapshot cleanup command generation |
| Settings | Edit alert rules in the UI, mute individual alerts, push toggle |
Thirteen collectors: host network containers services crowdsec storage
certs remote nodes ports connections engine disks. Each runs on its own
loop and fails independently — one broken collector doesn't blank out the rest.
Want to see it in action: https://homelab.88688.team. Screenshots of your own deployment are welcome in
docs/screenshots/.
https://homelab.88688.team — nothing to install, click around freely.
All data on the demo is simulated; none of it comes from a real machine. Actions like banning an IP or restarting a container really do take effect and show up in the UI, but they only mutate in-memory state and reset every hour. To run demo mode on your own machine (say, to show a colleague), use the demo compose file:
docker compose -f docker-compose.demo.yml up -d --buildThe demo instance mounts no host paths, runs with cap_drop: ALL, and binds only
to loopback — it cannot read anything from its host, which is precisely why it's
safe to expose.
Required
- A Linux host — it reads
/proc,/sysand/proc/mdstat; macOS and Windows are not supported - Docker and docker compose (v2, i.e.
docker compose, notdocker-compose)
Optional but strongly recommended
- CrowdSec — required for the firewall, bans and attack-source features. Without it the dashboard still runs; those cards just report "unavailable" and nothing else is affected
- smartmontools — needed for drive SMART monitoring (
smartctl) - btrfs-progs — only if you use btrfs and want snapshot info
If you just want to see what it looks like, skip every optional item and jump to Step 4.
CrowdSec is an open-source intrusion detection system: it reads your logs (nginx, sshd, smb…), identifies attacks, and produces "ban decisions". It does not block anything itself — blocking is the bouncer's job.
Debian / Ubuntu
curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsecRHEL / CentOS / Fedora / Rocky
curl -s https://install.crowdsec.net | sudo sh
sudo dnf install crowdsecAlpine
sudo apk add crowdsecOthers: see the official install docs.
Verify the service is up:
sudo systemctl status crowdsec
sudo cscli metrics # parsing stats means it's workingCrowdSec auto-detects the services on your machine and installs matching rule sets (collections). Check what you got:
sudo cscli collections listYou'd typically see crowdsecurity/nginx, crowdsecurity/sshd,
crowdsecurity/linux and friends. Install anything missing:
sudo cscli collections install crowdsecurity/nginx
sudo systemctl reload crowdsecDon't skip this. CrowdSec only decides who should be banned; the bouncer is what actually keeps them out. Without it, clicking "ban" in the dashboard writes the decision successfully — and traffic keeps flowing in.
# Debian / Ubuntu (iptables)
sudo apt install crowdsec-firewall-bouncer-iptables
# If your system uses nftables
sudo apt install crowdsec-firewall-bouncer-nftables
# RHEL family
sudo dnf install crowdsec-firewall-bouncer-iptablesConfirm it registered:
sudo cscli bouncers listYou should see a cs-firewall-bouncer-xxxxx with a check mark under Valid.
Bans take effect with a delay. The bouncer polls the LAPI for decisions every 10 seconds by default, so there's roughly a 10-second gap between clicking ban and iptables actually dropping the traffic. That's normal, not a stuck UI.
CrowdSec finds attacks by reading logs. No logs, no detections. Configuration
lives in /etc/crowdsec/acquis.yaml or /etc/crowdsec/acquis.d/*.yaml.
The config generated at install time is usually fine, but if your services run in Docker, the log paths often aren't where CrowdSec expects, so you'll need to add them. For example, an nginx container writing to a host bind mount:
# /etc/crowdsec/acquis.d/my-nginx.yaml
filenames:
- /var/log/nginx/*.log
- /opt/nginx/logs/access.log # your own path
labels:
type: nginxReload and check:
sudo systemctl reload crowdsec
sudo cscli metricsLook at the parse rate in the "Acquisition Metrics" table. If a source shows
Lines parsed: 0 with a large Lines unparsed, the type label is wrong or the
parser is missing — that source is burning CPU for nothing. The dashboard's
"Detection engine" card flags these zero-yield sources for you.
Parse rate must be read per source. A global parse rate is meaningless: most lines in syslog have no matching parser by design, and mixing it in will drag a perfectly healthy nginx source down to single digits, making you think the whole system is broken.
git clone https://github.com/mxx1111/Homelab.git
cd Homelab
cp config.example.yaml config.yamlOpen config.yaml and change at least these:
site_name: MyNAS # push notification prefix; distinguishes machines
storage:
volumes:
- label: System
path: /hostfs # the host root is mounted here inside the container
warn: 80
crit: 90
- label: Data # add your own mount points
path: /hostfs/mnt/data
warn: 80
crit: 90
services: # health probes, issued from the host itself
- name: My blog
url: http://127.0.0.1:8080/
expect: [200, 302]
certs:
targets: # domains to check for expiry; empty list is fine
- host: example.com
port: 443Then start it:
docker compose up -d --buildOpen http://<your-server>:8770.
Check the logs on first run to see how each collector fared:
docker logs -f homelab-dashboardOut of the box every write operation is refused — bans, unbans, container restarts and alert-rule edits all return 403. The dashboard can modify your firewall and control your containers.
Recommended: enable login
# config.yaml
auth:
enabled: true
username: admin
password: "your-password"Once logged in, write operations are allowed without a separate token. Sessions last 7 days by default; five failed attempts from one IP locks it for 15 minutes.
The password can be plain text, or a hash. Prefer the hash — config.yaml tends
to get pasted into issues while troubleshooting, and a hash costs you nothing
when it leaks:
docker exec homelab-dashboard python -m backend.hashpw 'your-password'
# paste the whole output line into auth.passwordWith multiple nodes this step is not optional. Once nodes are attached, one page can change the firewall on every machine and holds the SSH keys to all of them — no authentication means your whole fleet hangs off an open web page.
Also available: an operation token (for scripts)
firewall:
write_token: "some-random-string" # openssl rand -hex 24Requests carrying the X-Panel-Token header may write without logging in. This
exists for scripting — a one-line curl to ban an IP shouldn't have to negotiate
a session cookie first. The UI also grows a token field, kept in localStorage.
Fallback: declare the network trusted
firewall:
allow_anonymous_write: trueOnly when the dashboard is genuinely reachable from your LAN or VPN only, and you don't plan to attach nodes.
Restart afterwards:
docker compose restartConfiguration lives in config.yaml, copied from config.example.yaml.
Lookup order: HOMELAB_CONFIG env var → /etc/homelab-dashboard/config.yaml →
config.yaml in the repo root → config.example.yaml (fallback, so a fresh
clone starts up).
| Section | Purpose |
|---|---|
site_name |
Push notification prefix, also shown in the page header |
server |
Listen address and port, default 0.0.0.0:8770 |
intervals |
Per-collector interval in seconds; slow ones get longer gaps |
storage.volumes |
Volumes to monitor and their thresholds. The host root is /hostfs inside the container |
storage.snapshot_mounts |
Mount points to scan for btrfs snapshots; leave empty if not btrfs |
services |
Health probes; expect is the list of acceptable HTTP status codes |
certs.targets |
Domains and ports to check for expiry |
network.interface |
Empty means auto-pick the busiest physical NIC |
crowdsec |
LAPI URL, database path, credentials file |
firewall |
Write switch and token, see Step 5 |
history |
SQLite history path and retention |
notify |
Server酱 push; the SendKey comes from the environment, not from here |
alerts.rules |
Thresholds and switches — also editable from the Settings tab |
ports |
Port labels, declared public ports, allowlist script path |
disks.warn_hours |
Power-on hours before a drive is flagged, default 35000 (~4 years) |
actions |
Container operation switch and protected names |
remote_hosts |
Remote hosts to collect from over SSH |
The defaults are deliberate. Two worth calling out:
disks: 1800— SMART queries spin up sleeping mechanical drives. Too short an interval keeps them awake forever, wasting power and drive life.storage: 300— btrfs snapshot scanning walks subvolumes and takes seconds on a machine with many snapshots.
Values under alerts.rules are defaults. Changes made in the Settings tab are
stored in SQLite and deep-merged over the config file per field — so changing one
threshold won't reset the others, and no restart is needed. There's a "restore
defaults" button.
Alerts don't fire on the first blip: sustain_seconds (default 120) requires the
condition to persist that long, which suppresses false positives from occasional
probe timeouts.
The dashboard reads host data through mounts and two capabilities.
It does not need privileged. Review each line before deploying — drop the
mounts for features you don't want and the dashboard degrades gracefully.
| Mount / capability | Why | Without it |
|---|---|---|
network_mode: host |
Reads /proc/net/* for throughput, listening ports, live connections |
Network, ports and connections stop working |
/:/hostfs:ro |
Host root filesystem capacity | Storage reports the container's own overlay layer |
/var/run/docker.sock:ro |
Container list and resource usage | Containers tab is empty |
/usr/bin/docker:ro |
Reuses the host docker CLI instead of shipping one | Same as above |
/var/lib/crowdsec/data:ro |
Reads alert details from SQLite | Attack sources and alert stats unavailable |
/etc/crowdsec/local_api_credentials.yaml:ro |
Bans/unbans require writing to the LAPI | Read-only, can't ban |
cap_add: SYS_ADMIN |
The btrfs subvolume list ioctl |
Snapshot features unavailable |
cap_add: SYS_RAWIO + /dev:ro + device_cgroup_rules |
smartctl reading drive SMART |
Drive health card unavailable |
/sys/class/thermal:ro |
CPU temperature | Temperature blank |
./data:/app/data |
The only writable mount — the history database | History lost on restart |
Put host-specific mounts in docker-compose.override.yml (compose merges it
automatically, and it's already gitignored) so upgrades don't touch the main file:
# docker-compose.override.yml
services:
dashboard:
volumes:
- /mnt/data:/mnt/data:roIt reuses the CrowdSec agent's own machine credentials
(/etc/crowdsec/local_api_credentials.yaml, mounted read-only) to log into the
LAPI, then calls /v1/decisions with the resulting JWT.
Why not write to SQLite directly: a direct database write never notifies the bouncer, so the rule never reaches iptables. All writes must go through the LAPI.
This dashboard aggregates the state of an entire machine on one page — service inventory, exposed ports, container list, internal topology — which is exactly the intelligence an attacker wants. Add firewall and container control on top, and carelessly exposing it to the internet is a disaster.
The tradeoffs it makes:
- Writes are refused by default. With neither login nor
write_tokenconfigured, every write endpoint returns 403. Enable login, set a token, or explicitly declareallow_anonymous_write: true. - Login is single-user. Username/password, in-memory sessions, failed-attempt rate limiting. No RBAC, no multi-user, no password recovery — for a one-person setup those are just attack surface you'd have to maintain. Sessions live in memory and die with a restart; the cost is logging in again, the benefit is never having to persist a session key.
- Protected networks can't be banned. Private ranges (
10/8,172.16/12,192.168/16), loopback and the Tailscale CGNAT range (100.64/10) are hard-protected in code so you can't lock yourself out. Extend the list viacrowdsec.protected_networks. - Snapshot deletion only generates a command. Volumes are mounted read-only; the dashboard hands you a command to run on the host yourself. Granting a web page the right to delete data isn't worth what it saves.
- Only writes are audited. The UI polls every 5 seconds — logging GETs would bury the useful entries under tens of thousands of rows a day. Page visits are recorded once per IP per hour.
Recommended deployment
- Bind to your LAN only, restrict source ranges with a firewall
- For remote access use Tailscale / WireGuard / ZeroTier — don't port-forward
- If you must expose it, enable login and put an authenticating reverse proxy in front (Authelia, Cloudflare Access, …)
- Attaching nodes makes login mandatory — at that point the page can change the firewall on every machine and holds SSH keys to all of them
To report a vulnerability, see SECURITY.md.
The ban list is split into three kinds because they behave differently:
- Manual — you banned it, you can unban it
- Detected — matched a CrowdSec scenario, can be unbanned
- Community blocklist — CrowdSec central intelligence, often tens of thousands of entries. Unbanning is undone on the next sync, because the decision isn't local
A community blocklist can hold 15k+ entries, which would overwhelm the frontend. The strategy: return the first two kinds in full, sample only the most recent community entries, and count the total with a separate SQL COUNT — so the number you see is accurate even though the list is truncated. Search queries the database directly and isn't limited this way.
CrowdSec's native whitelist is a parser-level YAML config that requires a service reload. The dashboard runs in a container: it has neither write access to the config directory nor any business restarting a systemd service on the host.
So this is implemented as a watchdog instead: after each collection cycle it compares the ban list and immediately calls the LAPI to unban anything that matches the allowlist. The cost is up to one cycle of latency; the benefit is that it never touches CrowdSec's configuration.
Uses Server酱 (a Chinese WeChat push service).
Don't put the SendKey in config.yaml — use .env:
cp .env.example .env
echo "HOMELAB_SENDKEY=your-sendkey" >> .env
docker compose up -dKeys starting with sctp use Server酱³, others use the Turbo endpoint; detected
automatically.
Want a different push provider (Telegram, Bark, Gotify, ntfy)?
backend/notify.pyis about 60 lines with a singlesend()entry point — PRs very welcome.
Three layers keep alerts from spamming you: the sustain_seconds duration gate,
the repeat_hours reminder interval, and per-alert muting (timed or permanent,
managed in Settings). A recovery notice is sent when the condition clears.
Every listening port is graded into three levels:
- Public — declared in
ports.public_ports, shown in red - LAN reachable — bound to
0.0.0.0but not forwarded - Localhost only — bound to
127.0.0.1
If you manage allowed ports with an iptables script containing a line like
PORTS="22,80,443", point ports.homeguard_path at it for more accurate
grading. Leave it empty to skip.
public_ports must be filled in by hand — only you know which ports your
router forwards; it isn't visible from inside the machine.
Minute-level samples in SQLite, 90 days by default. Charts merge metrics that share a unit: CPU with memory, upload with download, all volumes together — a shared Y axis is what makes them comparable.
The Y axis fits the data range but enforces a minimum span: storage sits at 40% for months, and pure auto-scaling would stretch a 0.1% wobble across the whole card, making it look like the drive is about to explode.
Capacity forecasting is linear extrapolation, but gives no answer with less than 24 hours of data. Extrapolating from startup noise produces a scary, fake number — better to say "not enough data".
Reallocated and pending sectors turn red the moment they're non-zero — those are current state, and non-zero means a real problem.
Reported_Uncorrect gets special handling: it's a cumulative historical count
that never decreases. The raw number can't tell you whether those errors happened
yesterday or six years ago. The dashboard reads the power-on timestamps from the
SMART error log, compares them against current power-on hours, and if nothing new
has appeared in over a year it's classified as ancient history — pulled out of
the alert and noted separately.
It also checks /proc/mdstat: many NAS systems wrap a single disk in a raid1
array, which looks redundant but is really [1/1], a single member. This
"nominal redundancy" is called out explicitly.
deploy.sh packages the code, uploads it, builds the image remotely and restarts
the container.
cp deploy.env.example .deploy.env
# edit .deploy.env, set HOMELAB_HOST (ssh alias or user@host)
./deploy.sh # build and deploy
./deploy.sh --config # also overwrite the remote config.yaml with the local one
./deploy.sh --no-build # restart only, don't rebuild the imageTwo gotchas:
--no-builddoes not update code.backend/andfrontend/are COPYed into the image; onlyconfig.yamlis mounted. Code changes require a rebuild.- Use
--configwhen you add new config keys, otherwise the remote won't see the new section. The old config is backed up asconfig.yaml.bak.
By default the remote config.yaml is preserved, so ad-hoc tweaks made on the
server survive deploys.
One dashboard for every machine: ban an IP in one place and have it take effect everywhere.
The work splits in two, and the halves are independent — doing only the firewall half is perfectly useful on its own:
| Built on | What it takes | |
|---|---|---|
| Unified firewall | CrowdSec's native distributed design | a few config lines, nothing to install |
| State aggregation | the dashboard pulling over SSH | one collector script per node |
Node agents need to reach the central LAPI, which means the machine hosting it has to be reachable. From most to least secure:
- Private network (recommended) — WireGuard / Tailscale / ZeroTier / LAN. The LAPI listens only on the private range, so the port simply doesn't exist on the public internet. There is no "what if my allowlist is wrong" question.
- Public + reverse proxy + strong auth (acceptable) — HTTPS proxy, source-IP allowlist, and CrowdSec's own machine authentication. All three; drop one and you drop a tier.
- Exposing 8080 straight to the internet (don't) — CrowdSec machine auth is login/password over plain HTTP. That's broadcasting your credentials.
Examples below use 10.0.0.1 for the central machine's private address.
On the central machine, make the LAPI listen beyond loopback:
# /etc/crowdsec/config.yaml
api:
server:
listen_uri: 0.0.0.0:8080Bind 0.0.0.0 rather than the private address directly: the latter makes
crowdsec's startup depend on your VPN being up first — if the VPN is late,
crowdsec fails to start and local protection goes down with it. Bind 0.0.0.0
and restrict by firewall instead; same exposure, no ordering dependency:
# allow the private network and localhost, drop everything else
iptables -A INPUT -i lo -p tcp --dport 8080 -j ACCEPT
iptables -A INPUT -s 10.0.0.0/24 -p tcp --dport 8080 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -j DROPOn each node — order matters. Register and confirm it works before disabling the local LAPI, so there's never a window without protection:
# 1. Install CrowdSec (same as Step 1 above)
curl -s https://install.crowdsec.net | sudo sh
sudo apt-get install -y crowdsec
# 2. Register with the central LAPI. This overwrites the credentials file
sudo cp /etc/crowdsec/local_api_credentials.yaml{,.bak}
sudo cscli lapi register --machine node1 --url http://10.0.0.1:8080
# 3. [on the central machine] approve it
sudo cscli machines validate node1
# 4. Disable the local LAPI — add enable: false under api.server
# Note the key is absent by default (defaults to true): you're adding, not editing
sudo systemctl restart crowdsec
# 5. Bouncer — configure it immediately after install. The local LAPI is already
# off, so between install and config the bouncer is disconnected
sudo apt-get install -y crowdsec-firewall-bouncer-iptables
# [on the central machine] cscli bouncers add node1-fw -o raw -> api key
sudo vi /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
# api_url: http://10.0.0.1:8080/
# api_key: <the key from above>
sudo systemctl restart crowdsec-firewall-bouncerTo verify: cscli machines list on the central machine should show every node
with a live heartbeat. Then ban an IP and confirm it actually lands on another
machine:
# ipset names carry a shard suffix (-0/-1/-2). Querying plain
# crowdsec-blacklists returns "set does not exist", which reads like failure
for s in $(ipset list -n | grep '^crowdsec-blacklists'); do
ipset test $s 1.2.3.4 2>/dev/null && echo "hit in $s"
doneOnce this works, the dashboard shows alerts from every node — with no dashboard configuration at all. It reads the central machine's CrowdSec database, and that's where all nodes write. The ban list gains a "detected by" column.
Everything CrowdSec doesn't cover comes over SSH. Use a restricted key. The dashboard holds private keys to every node; with a forced command, a leaked key only grants read access to monitoring data. Without one, it grants a shell on your entire fleet.
Generate a dedicated key on the central machine:
mkdir -p /opt/homelab-dashboard/secrets && chmod 700 $_
ssh-keygen -t ed25519 -N "" -C "homelab-panel" \
-f /opt/homelab-dashboard/secrets/id_panelMount it into the container (docker-compose.override.yml):
services:
homelab-dashboard:
volumes:
- ./secrets:/app/secrets:roOn each node, install the collector script and pin the key to it:
sudo mkdir -p /opt/homelab
sudo install -m 755 scripts/node-collect.sh /opt/homelab/node-collect.sh
# add this line to authorized_keys, with the public key generated above
command="/opt/homelab/node-collect.sh",restrict ssh-ed25519 AAAA... homelab-panelrestrict (OpenSSH 7.2+) disables port forwarding, agent forwarding, X11, pty
and user-rc in one word. Together with command=, that key can do exactly one
thing. Check it:
ssh -i secrets/id_panel root@10.0.0.2 "cat /etc/shadow"
# should print the collector output, not the contents of /etc/shadowFinally list the nodes in the dashboard config:
nodes:
- name: node1
host: root@10.0.0.2
key: /app/secrets/id_panel
- name: node2
host: root@10.0.0.3
port: 4522 # non-standard SSH port
key: /app/secrets/id_panel
intervals:
nodes: 60The overview page gains a "nodes" card: one row per machine with load, memory, the fullest disk, container counts, locally enforced bans and collection latency. Click a row to expand.
The config names an SSH target and nothing else — no networking scheme is baked in. Whether you reach nodes over VPN, LAN or the public internet is not the dashboard's business.
Once nodes are configured, a picker appears at the top right. Switch to a node and the tabs show that machine's data; the header gets an orange bar so you know you're not looking at the local machine — mistaking machine B's data for machine A's is the single easiest mistake to make here.
| Tab | In node view |
|---|---|
| Overview | Host, storage, containers, ports, local enforcement, services |
| Firewall | Attack sources and country breakdown for that machine. The ban list is not filtered — decisions come from the central LAPI and every one applies to all nodes |
| Ports | Full listening inventory |
| Containers | Read-only list, see below |
| Connections / History / Settings | Explained as not applicable |
- Node collection is read-only. The dashboard never executes anything on a node, and the restricted key wouldn't let it. So containers can't be started or stopped in node view — supporting that means issuing a second key bound to an action script, which erodes what the restricted key buys you. Whether that trade is worth it depends on how much you need the feature.
- Nodes collect less than the local machine: network throughput, live
connections, certificate expiry and drive SMART are missing. Throughput needs
continuous sampling to compute a delta; SMART needs root reading the device
directly. Add what you need to
scripts/node-collect.sh. - History is central-only. Each node keeps its own; the central machine aggregates current state, not time series.
The alert engine runs on the machine being monitored, so when that machine dies the alerting dies with it — silence exactly when you most need a warning.
scripts/watchdog.sh covers that blind spot. It must run on a different
always-on machine (a cheap VPS is plenty):
mkdir -p /opt/homelab-watchdog && cd /opt/homelab-watchdog
# upload watchdog.sh and watchdog.env.example
chmod +x watchdog.sh
cp watchdog.env.example watchdog.env && vi watchdog.env # set SENDKEY and TARGETS
./watchdog.sh --test # verify push works
( crontab -l 2>/dev/null; echo "*/5 * * * * /opt/homelab-watchdog/watchdog.sh" ) | crontab -Probe your public entry point rather than an internal IP — that single check verifies the machine is alive, the network is up, and the reverse proxy works. It takes 3 consecutive failures (~15 minutes) to alert, which filters out restarts and network blips.
It starts, but many cards say "unavailable"
Expected. No CrowdSec means no firewall data; no smartctl means no drive data.
docker logs homelab-dashboard explains why each collector failed.
Storage numbers look wrong
Inside the container / is its own overlay layer, not the host root. Paths in
config.yaml must use /hostfs, not /.
Ban returns 403
Writes are locked by default, see Step 5.
Ban succeeded but the IP still gets through
- Check the bouncer is installed:
sudo cscli bouncers list - Wait 10 seconds — that's the polling interval
- Check iptables:
sudo iptables -L CROWDSEC_CHAIN -n | head - Make sure it isn't a protected range (private, loopback and Tailscale are refused)
Attack sources are always empty
CrowdSec isn't detecting anything, usually a log source misconfiguration. Run
sudo cscli metrics and look at "Acquisition Metrics"; Lines parsed: 0 means a
wrong type label or a missing parser. See Step 3.
Drive card says it can't list disks
Needs SYS_RAWIO, the /dev mount, device_cgroup_rules, and smartmontools
on the host. Note that mounting /dev does not by itself grant block-device
access — those device_cgroup_rules lines are required.
Container clock is off by hours
Mount /etc/localtime:/etc/localtime:ro (already in the shipped compose file).
History says "not enough data"
The sampling window is too short. Capacity forecasting needs 24 hours; trend charts need at least 2 samples.
13 collectors (independent loops) ─┬─ in-memory cache ─→ FastAPI ─→ frontend (5s poll)
├─ SQLite metrics & events, 90-day retention
└─ alert engine ─→ push ─→ phone
Collection is decoupled from requests. Slow collectors (btrfs snapshot scans take seconds, SSH to a remote host takes a second) run in the background at their own pace; the frontend only ever reads the cache and never waits. When a collector fails, the last good data is retained so the card doesn't go blank.
History persistence and alert evaluation run on a separate 30-second loop rather than following collector timing. Both are blocking (SQLite writes, HTTP pushes) and are dispatched to a thread pool so they never stall the event loop.
backend/
main.py FastAPI routes, auth and audit middleware
auth.py single-user login: sessions, password hashing, rate limiting
hashpw.py CLI to generate a password hash
cache.py collection scheduling and in-memory cache
config.py config loading
alerts.py alert rule engine, overrides, muting
firewall.py LAPI client and allowlist
history.py SQLite: metrics/events/whitelist/audit/settings
notify.py push notifications
actions.py container operations and snapshots
asn_names.py ISP name normalization
collectors/ the twelve collectors
frontend/
index.html page skeleton
app.css styles
app.js rendering and interaction (zero dependencies)
scripts/
watchdog.sh external watchdog
node-collect.sh collector script installed on managed nodes
Full docs at /api/docs (generated by FastAPI). Main endpoints:
GET /api/summary all collector data in one call
GET /api/section/{name} a single collector
GET /api/history/multi multiple series
POST /api/firewall/ban ban
POST /api/firewall/unban unban
POST /api/containers/{name}/{action} container operations
PUT /api/alerts/settings edit alert rules
Write operations require the X-Panel-Token header when write_token is set.
Run it without Docker:
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml
python run.pyThe frontend has no build step — edit the files under frontend/ and refresh.
Note that when running outside a container the /hostfs path assumption doesn't
hold, so point storage.volumes at real paths.
Done: SQLite history, storage growth forecasting, network and load charts, attack-source aggregation with one-click bans, container restart and logs, push alerting, external watchdog, port exposure audit, access audit, UI-editable alert rules, drive SMART monitoring, ISP and scenario name normalization, dashboard login, multi-machine management (multi-node CrowdSec + SSH state collection).
Not yet:
- Per-container detail page — historical resource usage for a single container
- Real snapshot deletion — currently generates a command. Login makes this feasible now, though granting delete rights still deserves more thought
- Per-node history — only current state is aggregated; each node's time series stays on that node
- UI internationalization — the interface is currently Chinese only
- Mobile layout polish — usable, but wide tables scroll awkwardly
Issues and PRs welcome — see CONTRIBUTING.md.
Especially interested in:
- Other distros / NAS platforms — path differences on Synology, QNAP, unRAID
- UI translation — the interface is Chinese only today; English would be a great first contribution
- New push providers — Telegram, Bark, Gotify, ntfy;
notify.pyis tiny - New collectors — the interface is one
collect(cfg)function returning a dict