Homelab Dashboard (Homepage)
The landing page at http://dashboard.lan:3000 — runs
gethomepage/homepage on dashboard-vm. Source of truth:
homelab-services/services/dashboard/.
DNS: dashboard.lan is a CNAME → dashboard-vm.lan (which the router
auto-registers as an A record per current DHCP lease). Either name works,
and the chain self-heals on DHCP renewal — see [[feedback-dns-record-cname-pattern]]
for why.
File layout
services/dashboard/
├── docker-compose.yml # Homepage container + HOMEPAGE_ALLOWED_HOSTS
├── config/
│ ├── settings.yaml # title, theme, layout (column counts)
│ ├── services.yaml # tile groups & individual tiles
│ ├── widgets.yaml # top-of-page widgets (datetime, search)
│ ├── bookmarks.yaml # empty by default — bottom list looks bad
│ ├── docker.yaml # empty stub — no socket discovery
│ └── kubernetes.yaml # empty stub
├── terraform/ # VM + DNS CNAME (uses dns-record module)
└── README.md
Hot reload
Homepage hot-reloads YAML files when the bind-mounted ./config/ dir
changes. After the deploy workflow rsyncs new config to the VM, the page
updates within a few seconds — no container restart needed.
A container restart IS needed only when:
docker-compose.ymlchanges (env vars, image, ports, volumes)- You change the bind mount itself
- You explicitly want to pull a newer
latestimage
The deploy workflow handles both cases idempotently (docker compose pull && up -d is a no-op if nothing's changed).
Adding or editing a tile
The 90% case. Edit services/dashboard/config/services.yaml and add an
entry under the appropriate group:
- Infrastructure:
- My new service:
href: http://my-service.lan:8080
description: One short line
icon: my-service.svg
Then commit + push. The deploy-dashboard workflow auto-triggers on
services/dashboard/config/** changes, rsyncs the file, hot-reload picks
it up within seconds.
Tips:
- Keep
descriptionto ~3 words. Long text wraps awkwardly and looks bad. - Group order matches
layout:insettings.yaml— adjust there if you want a different order. - Adding a new group? You must add BOTH a section in
services.yamlAND a row insettings.yaml'slayout:block (withstyle: rowand acolumns:count) — otherwise the group appears but with default styling.
Icon resolution — known-good name table
Homepage resolves icon names via this chain:
mdi-<name>→ Material Design Icons (always works; full set at pictogrammers.com/library/mdi)si-<name>→ Simple Icons (brand logos; full set at simpleicons.org)<name>.svg(or.png) → homarr-labs/dashboard-icons- Full URL (
https://...) → fetched directly; use for icons missing from the above sets, e.g. selfhst CDN
Verified names for services we deploy (don't guess — these tripped us up before):
| Service | Correct name | Notes |
|---|---|---|
| Proxmox | proxmox.svg |
dashboard-icons |
| Technitium DNS | technitium.svg |
dashboard-icons (404 but Homepage falls back gracefully) |
| Nginx Proxy Manager | nginx-proxy-manager.svg |
dashboard-icons |
| Infisical | infisical.svg |
dashboard-icons |
| Grafana | grafana.svg |
dashboard-icons |
| Prometheus | prometheus.svg |
dashboard-icons |
| Loki | loki.svg |
NOT grafana-loki.svg |
| Tempo | tempo.svg |
NOT grafana-tempo.svg |
| GitHub (Actions) | github.svg |
NOT github-actions.svg |
| OpenWebUI | open-webui.svg |
NOT openwebui.svg (hyphen matters) |
| LiteLLM | https://cdn.jsdelivr.net/gh/selfhst/icons/svg/litellm.svg |
Not in dashboard-icons; selfhst direct URL |
| Generic router | mdi-router-wireless |
Material Design |
| Generic robot/AI placeholder | mdi-robot-happy-outline |
Material Design |
Verification trick: before adding any icon, curl it to confirm it exists (no auth, ~50ms):
# dashboard-icons
curl -s -o /dev/null -w "%{http_code}\n" \
"https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/svg/<name>.svg"
# selfhst
curl -s -o /dev/null -w "%{http_code}\n" \
"https://cdn.jsdelivr.net/gh/selfhst/icons/svg/<name>.svg"
200 means use it, 404 means try a different name or set.
Symptom of a broken icon: tile renders with the text "logo" as a placeholder where the icon should be. Refresh-cache (Ctrl+F5) after fixing — Homepage caches resolved icon URLs aggressively.
HOMEPAGE_ALLOWED_HOSTS — host validation
Homepage rejects requests whose Host header isn't on the list with
"Host validation failed. See logs for more details." in a red banner.
Current value (in docker-compose.yml):
- HOMEPAGE_ALLOWED_HOSTS=dashboard.lan:3000,dashboard.lan,dashboard-vm.lan:3000,dashboard-vm.lan
Rules:
- Comma-separated, no spaces
- Port suffix MAY be required — browsers send
host:3000when the port isn't:80/:443, so include both with-port and without-port for each name localhost:3000and127.0.0.1:3000are always implicitly allowed*disables the check entirely (docs discourage, but acceptable for LAN-only homelab)
Use hostname-only entries, not IP entries. IP entries go stale every time DHCP renews and gives the VM a different address (the cost of not having a router-side reservation). Hostnames are stable because of the CNAME → router-A chain.
If you ever expose this through a reverse proxy on :80/:443, add
dashboard.lan:80 and/or dashboard.lan:443 (or the proxy's hostname)
to the list. Homepage matches the literal Host header.
Layout (settings.yaml)
The layout: block controls group order AND column count per group:
layout:
Infrastructure:
style: row
columns: 5 # max tiles per row; wraps to next row when exceeded
AI:
style: row
columns: 4
style: rowis the default (tiles flow left-to-right).style: columnstacks tiles vertically, useful when a group has only one tile and shouldn't be in a tiny corner of a wide row.columns:is the max per row. If group has fewer tiles, they don't stretch — they sit at the natural tile width.- Groups not listed in
layout:still render but with default settings.
The target: _blank setting in settings.yaml opens tile clicks in a
new tab — useful when the dashboard is your start page.
Widget integrations (not yet wired up)
Homepage supports widget integrations for many of the services we link to: Proxmox CPU graphs, Grafana dashboard counts, Technitium QPS, GitHub Actions run status, etc. Not done in v1 — would need:
- Create API tokens in each source service
- Store under
/dashboard/path in Infisical - Add a step to
deploy-dashboard.ymlthat loads/dashboard/ - Use Homepage's
{{HOMEPAGE_VAR_<NAME>}}placeholder substitution inservices.yamlto inject the tokens - Add the
widget:block to the relevant service entry per gethomepage.dev/widgets
The Infisical machine identity for homelab-services would need read on
/dashboard/ — add when wiring this up.
Troubleshooting decision tree
| Symptom | Likely cause | Fix |
|---|---|---|
| Tile shows "logo" text | Icon name not found | Verify with curl trick, switch to correct name or full URL |
| "Host validation failed" red banner | Host header not on allowlist | Add to HOMEPAGE_ALLOWED_HOSTS in docker-compose.yml — include port suffix |
| Tile click goes to dead address | Backing service moved (DHCP, port change) | Update href: in services.yaml; if the target was a VM, consider switching to its <name>-vm.lan form for self-healing |
| Config change didn't show up | Workflow hasn't run / hot-reload didn't trigger | Check gh run list --workflow=deploy-dashboard.yml --limit 1; if completed-green and still not showing, Ctrl+F5 to bust browser cache |
| Group missing or unstyled | Forgot layout: row |
Add a <group name>: block to settings.yaml with style: and columns: |
| Long description wraps weird | Description too long | Shorten to ≤3 words |
Related
- [[project-homelab-dns]] — DNS architecture (dashboard.lan resolves via CNAME)
- [[feedback-dns-record-cname-pattern]] — why dashboard uses CNAME, not pinned A
homelab-bootstrapskill — broader homelab gotchas (provider env-direct, Infisical OIDC, runner zombie recovery)technitium-apiskill — for verifying DNS records when troubleshooting tile URLs- Upstream docs: gethomepage.dev