Skip to content

Dashy

The homelab homepage. One card per service, grouped by what the service is for rather than by which host it happens to run on.

This is not a Compose stack. Dashy runs natively in its own LXC, installed from the community-scripts helper. The directory lives here anyway so the dashboard content sits alongside the rest of the service inventory — but there is no docker-compose.yaml, and docker compose commands do not apply to it.

GuestCT 270 dashy on pve-dell, unprivileged
Address192.168.1.25, port 4000
VersionDashy 4.5.0
Servicedashy.service — /usr/bin/node server.js
Working dir/opt/dashy
Config/opt/dashy/user-data/conf.yml
Resources2 cores, 2 GB RAM, 6 GB rootfs, onboot: 1

user-data/conf.yml is the canonical copy and the one to edit. It holds 86 services across 11 sections: Control Plane, Kubernetes · prod-talos, Observability, Identity & Certificates, Registries & Code, Network & Access, Knowledge & Documents, Media, Media Automation, Downloads, Automation.

The service list was taken from Traefik’s live router table, not from compose labels — on this fleet those describe only a fraction of what is routed. Every hostname was HTTP-checked before being added, and every hl- icon slug was checked against the dashboard-icons repo so none render broken.

Boxarr, CrossWatch and GameVault come from the docker-apps-cifs stack, documented in ../../docker-compose-apps/docker-apps-cifs/README.md. When a tile is added before its backend, note that 404 means no route while 502 means routed with no backend — the wildcard zone resolves either way.

The board covers three sources. Most tiles are Proxmox guests behind the LXC Traefik. Nine come from the prod-talos Kubernetes cluster, reconciled by Flux from the fleet-infra repo — that cluster runs its own Traefik and its own Cloudflare tunnel. Only workloads actually listed in a cluster kustomization were added:

TileSource in fleet-infra
Kite, Dozzle (Talos), whoamiapps/prod-talos-cluster/
Traefik (Talos)apps/prod-talos-cluster/traefik/
Goldilocksinfrastructure/prod-talos-cluster/goldilocks/
Grafana, Prometheus, Alertmanagerinfrastructure/.../kube-prometheus-stack/
Dexapps/prod-talos-cluster/dex/

Harbor and Nexus were already on the board and also run on that cluster — the .toolsera.lan hostnames they were added under are its IngressRoutes.

Four things about that cluster shaped what went on the board:

  • Manifests are not deployments. awx, headlamp, otf, otf-agent, terrakube and weave all have manifests in the repo but are commented out of their kustomizations, so nothing runs behind them. awx.toolsera.lan returns 404, and headlamp.toolsera.fr returns a Cloudflare Access redirect from an Access app that outlived its backend — which is exactly why a 302 on a .fr host proves nothing.
  • Gateway API routes were not duplicated. Every one of these apps also publishes an HTTPRoute on a parallel *-gw hostname reaching the same backend. Adding both would have doubled the section for nothing.
  • Two tiles are namesakes. The cluster’s Traefik and Dozzle would be indistinguishable from the fleet’s if they were filed by purpose, so the cluster keeps its own section. Grafana, Prometheus, Alertmanager and Dex have no such twin and are filed by purpose, in Observability and Identity.
  • Dex has no UI. / and /dex/ both 404; only /dex/auth and the discovery document answer. Its tile links to the discovery document.

The third source is the UGREEN NAS (192.168.1.29), whose stack lives in ../../docker-compose-apps/ugreen-nas/. It is not a Proxmox guest, so the provider plugin below cannot discover its address — and yet its seven services are routed from Proxmox notes like everything else, because loadbalancer.server.url overrides address discovery outright. They live in VM 217 docker-apps-cifs’s description as …server.url=http://192.168.1.29:<port>; the guest whose notes carry a route need not be the guest that answers it. Details in ../../docker-compose-apps/traefik/dynamic/README.md.

One thing to keep in mind: those ports are duplicated state, mirroring the NAS stack’s docker-compose.yml. Republishing an app on a different port there breaks its route with nothing to catch it — which is exactly what happened to Arr Dashboard when it moved to 49153.

Three NAS apps are deliberately not on the board: metube, speedtest-tracker and upsnap. The NAS runs its own copies, but those three hostnames already resolve to the LXC instances (192.168.1.21, .185, .196) through the Proxmox provider. Two tiles differing only by backend IP is worse than one, so the NAS copies stay IP-only.

Media is deliberately two sections, and Downloads is a third. Routing the natively-installed guests added nine media tiles, and a single section ran to fourteen — long past the point where a grid is faster to scan than a list. It is split by what you are actually there to do: Media serves or plays the library, Media Automation fills it. The two share a hue so they still read as one family. Downloads — qBittorrent, qui, qbitwebui, Rustatio, Cleanuparr, Gluetun — was split off for the same reason once the NAS and CIFS apps landed: it is the layer that moves the bytes, and folding it into Media Automation would have put that section back at sixteen. It is also where five of the six IP-linked NAS tiles live, so the one section that behaves differently is the one section you can point at.

Where a service publishes both routes it is linked at .toolsera.lan, which skips the Cloudflare tunnel hop. That is why the cluster’s Traefik is linked at traefik-prod-talos.toolsera.lan/dashboard/ rather than at its .fr twin. Eight tiles are linked at .toolsera.fr: Keycloak, Authelia, and the six cluster ones — Kite, Dozzle (Talos), Goldilocks, whoami, Grafana and Dex.

Readeck, Excalidash, Remmina, Seerr, Tube Archivist, n8n and Traefik Manager moved from that list to .lan once their LAN routers existed.

When adding a .lan twin, give it secure-chain-no-auth@file. Copying the public router’s secure-chain-auth@file puts Authelia in front of a route that never leaves the LAN: it still works in a browser, but the status check goes red.

One deliberate omission, verified:

  • terrakube is listed in ../../CLAUDE.md as an internal domain but has no router. The zone wildcards to Traefik, so it resolves and returns 404. It has manifests in fleet-infra too, also not deployed.

opengist was listed here too while its backend 502’d. It answers 302 as of 2026-08-23, so its tile is green again and the note is gone.

Four guests changed under the board and are worth reading together, because three of the four problems were the guest description, not the service.

  • PegaProx is gone. CT 268 was destroyed on 2026-08-20. Its tile and its file-provider route (dynamic/pegaprox.yml) both outlived it, so pegaprox.toolsera.lan resolved and answered 502 for three days. Both were removed together — see ../../docker-compose-apps/traefik/dynamic/README.md for why that route needed a file in the first place, which is still true of anything serving HTTPS with a certificate Traefik will not verify.
  • prowlarr was already on the board and had silently stopped routing. CT 230 was destroyed and recreated on 2026-08-19, and the rebuild came back with the stock community-script description — so the label block went with it and the tile started 404ing. A recreated guest loses its routing, and nothing reports it. The board tile survives the rebuild; the route does not.
  • Three new LXCs — bookorbit (CT 208, :3000), komga (CT 236, :25600) and autobrr (CT 258, :7474) — had the same stock description and had never been routed. All four got the standard -lan block appended, mirroring radarr’s, and all four answered within the plugin’s 30-second poll: 200, 200, 200, and 302 for Prowlarr’s login redirect.
  • comicarr is the inverse case: routed in docker-apps-cifs.yml on 2026-08-22 with no container ever deployed on VM 217. Its tile is on the board at the user’s request and shows red at 502 until the service comes up. A route is not a deployment.

The 2026-08-23 unrouted sweep added five more. Comparing Traefik’s service backends against every running LXC by IP — names lie, remmina-kasm serves remmina — turned up 17 guests nothing routed to. Most are deliberate (no UI, an API, or joplin-server, which validates the Host header), but five were running a web UI with no label block, so neither this board nor Raindrop knew they existed: radarr-anime (CT 254 :7878), sftpgo (CT 214 :8080), garage-web-ui (CT 257 :3909), cronmaster (CT 260 :40123) and filebrowser (CT 260 :5436). All five are routed and tiled now.

ProxMenux is the first tile for something on the node itself. It runs on pve-dell (:8008), not in a guest, and the provider plugin reads labels out of guest descriptions — the node has none, so no notes-based route is possible. It is defined in ../../docker-compose-apps/traefik/dynamic/proxmenux.yml instead. Anything else installed on the node will need the same treatment.

cronmaster and filebrowser share CT 260 — one guest, two label blocks, two tiles, filed apart by what they are for. That is the pattern for any guest serving more than one thing.

Two other guests were destroyed in the same window — huntarr (CT 216) and ollama (CT 229), both on 2026-08-22. Neither was ever routed, so neither had a tile and nothing here changed.

Edit user-data/conf.yml here, commit, then push it to the guest. Most LXCs on this fleet refuse SSH, so go through the node with pct push:

Terminal window
# from a checkout on the Proxmox node
pct push 270 user-data/conf.yml /opt/dashy/user-data/conf.yml
pct exec 270 -- systemctl restart dashy

Dashy’s own UI can write config back to that file. That copy then diverges from this one with nothing to reconcile it, so treat UI edits as a scratchpad and fold anything worth keeping back into conf.yml here.

Routers on this fleet come from the traefik-proxmox-provider plugin, which reads Traefik labels out of each Proxmox guest’s description field — not from the guest name, and not from container labels. A guest with no labels in its description gets no router, however it is named.

That is exactly what happened here: CT 270 was created with the stock community-script description, and dashy.toolsera.lan returned 404 while Dashy itself was healthy on port 4000. The fix was appending this block to the guest description, mirroring what glance (CT 252) and pulse (CT 244) already carry:

traefik.enable=true
traefik.http.routers.dashy.entrypoints=https
traefik.http.routers.dashy.rule=Host(`dashy.toolsera.fr`)
traefik.http.routers.dashy.tls=true
traefik.http.routers.dashy.middlewares=secure-chain-auth@file
traefik.http.services.dashy.loadbalancer.server.port=4000
traefik.enable=true
traefik.http.routers.dashy-lan.rule=Host(`dashy.toolsera.lan`)
traefik.http.routers.dashy-lan.entrypoints=https
traefik.http.routers.dashy-lan.tls=true
traefik.http.routers.dashy-lan.tls.certresolver=stepca
traefik.http.routers.dashy-lan.middlewares=secure-chain-no-auth@file
traefik.http.services.dashy-lan.loadbalancer.server.port=4000

Applied with pct set, preserving the existing description:

Terminal window
pct set 270 --description "$(cat /root/pct-backups/dashy-desc.txt)"

The plugin polls every 30 seconds, so the route appears within about half a minute. No Traefik restart is needed — this is provider state, not static config.

The same gap hit VM 217 docker-apps-cifs (192.168.1.180). Its description carried label blocks for forgejo and rustfs only, so sportarr and cleanuparr ran healthy on :1867 and :11011 with no hostname pointing at them. Fixed the same way — two more blocks appended to that VM’s description. A service added to ../../docker-compose-apps/docker-apps-cifs/docker-compose.yml needs a matching block there; the compose file alone routes nothing.

arr-dashboard (:49153) and neutarr (:9705) moved onto that VM from the NAS and carry blocks there too. Both use plain loadbalancer.server.port — the guest is the backend now, so the server.url override the NAS services need would be wrong. Moving a service between hosts means editing its block, not just the compose file: a stale server.url keeps pointing at the old host and the route silently 502s.

boxarr (:8888), crosswatch (:8787) and gamevault (:8080) carry blocks there too, appended after neutarr and before the UGREEN NAS paragraph — everything below that line uses server.url against 192.168.1.29, so a port-based block filed under it reads as if it belonged to the NAS.

The description is a single API field, so any edit rewrites it wholesale: keep a copy of the current value first. It applies live, and the plugin picks the routers up within its 30-second poll. Editing it over the API is covered in ../../.claude/host-access.md.

The zone wildcards to Traefik, so a new *.toolsera.lan name needs no DNS work — it resolves to 192.168.1.14 the moment it is invented. That cuts both ways: an unrouted name resolves and 404s rather than failing to resolve, so getent hosts is not evidence a route exists. Check Traefik’s router table instead.

hl- slugs resolve against homarr-labs/dashboard-icons, trying svg/{icon}.svg first and falling back to png/{icon}.png — so a slug that exists only as a PNG (as cleanuparr does) still works. Of the apps added with the NAS and CIFS routes, only sportarr, qui, gluetun, qbittorrent and cleanuparr exist upstream; qbitwebui, anibridge, arr-dashboard and rustatio are in neither directory and use mdi- icons rather than rendering broken. The three added with this batch all resolve: crosswatch has both formats, boxarr and gamevault are PNG-only and rely on that fallback.

Of the 2026-08-23 batch, komga and autobrr have both formats and bookorbit is PNG-only — the same fallback again. comicarr is in neither directory, so it uses mdi-book-search.

statusCheck is on with a 300-second interval. Checks run server-side in Dashy’s Node process, not in the browser, so they succeed or fail on what CT 270 trusts.

Every .toolsera.lan tile initially showed unable to get issuer cert locally. Two things were wrong, and only one of them was obvious:

  • statusCheckAllowInsecure is a per-item option. It had been set under appConfig, where it is silently ignored. It was never going to suppress anything.
  • Node ignores the system trust store. It ships its own bundled CA list, so the Smallstep root installed and active in this guest was simply never consulted.

The fix is systemd/ca-certs.conf — a unit drop-in setting NODE_EXTRA_CA_CERTS to the system bundle, which carries the public roots and the internal CA together. That keeps verification on rather than disabling it across every tile. NODE_EXTRA_CA_CERTS is read once at process start, so it has to live in the unit environment.

Checked afterwards by fetching the original 54 URLs from inside the guest with Node: 53 pass, and the one that does not is Proxmox, reached by IP with a certificate that does not list that IP as a SAN. It reports ERR_TLS_CERT_ALTNAME_INVALID — a name mismatch, not an untrusted issuer — so it carries the per-item statusCheckAllowInsecure flag, the only tile that does.

The nine cluster tiles added later were verified from a workstation rather than from CT 270. That glance happened on 2026-08-23, sweeping all 80 URLs from inside the guest: prometheus and alertmanager verify fine, exactly as expected — they are .toolsera.lan names issued by the same Step CA, so the drop-in above covers them. traefik-prod-talos.toolsera.lan does not. It resolves to 192.168.1.201 — the cluster, not the fleet Traefik — and the cluster issues its own certificates, so the handshake aborts with unknown CA while curl -k on the same URL returns 200. That is an untrusted issuer, not the SAN mismatch the Proxmox tile has, and it now carries the second per-item statusCheckAllowInsecure on the board. The real fix is adding the cluster root to the guest bundle alongside the Smallstep one.

A check never reaches the service on a .toolsera.fr route. Fleet routes are answered by Authelia, cluster routes by Cloudflare Access — both return a redirect to their own login, so a green tile there means the auth layer is up, not the backend.