13 — Homepage Dashboard¶
Now behind Caddy (May 2026). Homepage moved behind the Caddy reverse proxy: the container binds
127.0.0.1:3000only, reached athttps://home.z2mini.gabrielgabrie.com(auto-renewing Let's Encrypt cert;HOMEPAGE_ALLOWED_HOSTSnow includes that hostname +localhost:3000+127.0.0.1:3000). The oldhttps://home.z2mini.gabrielgabrie.com/http://127.0.0.1:3000URLs no longer work; on the box usehttp://127.0.0.1:3000. Tilehrefs now point at thehttps://<svc>.z2mini.gabrielgabrie.comhostnames, and thewidget.urlfields point at the Caddy hostnames too (the Homepage container can't reach the apps'127.0.0.1:<port>on the host — it goes through Caddy like everything else). The Tailscale-token rotation steps below: replacecurl … http://127.0.0.1:3000/api/serviceswithhttp://127.0.0.1:3000/api/services. 17-caddy.md is authoritative for exposure.
A single-page launcher for everything running on the Z2: tiles for Immich, Navidrome, Radicale, Vaultwarden, and Tailscale; live widgets that pull real numbers (photo count, track count, devices online) from the services that expose an API; static bookmarks for GitHub, the published docs site, and external tools. Tailscale-only, single Docker container, config-as-YAML.
Companion: 14-beszel.md (system metrics). Homepage answers "where do I click?"; Beszel answers "what is the server doing right now?". The two complement each other rather than overlap.
Overview¶
Single-container Docker stack:
| Container | Image | Purpose |
|---|---|---|
homepage |
ghcr.io/gethomepage/homepage:v1.12.3 |
Static-rendered launcher page + widget-driven service queries |
Reachable at https://home.z2mini.gabrielgabrie.com from any tailnet device. Not exposed to the public internet.
Config lives at /data/docker/homepage/config/ — six YAML files (services.yaml, bookmarks.yaml, widgets.yaml, settings.yaml, docker.yaml, kubernetes.yaml). On first boot Homepage auto-creates only settings.yaml and kubernetes.yaml; the rest must be hand-written to populate the page. Once they exist, edits are picked up live without a container restart.
Design decisions¶
| Decision | Reasoning |
|---|---|
| Homepage over Glance / Dashy / Heimdall | Native widgets for Immich, Navidrome, Tailscale (no scraping needed); active development; YAML-as-config (versionable, copy-pasteable in this doc); MIT-equivalent license; ~80 MB RAM at idle |
Bound to 127.0.0.1:3000, fronted by Caddy |
Behind the single Caddy ingress at https://home.z2mini.gabrielgabrie.com; never reachable on the tailnet directly or publicly. (Was 100.67.235.68:3000 before the Caddy migration.) See 17-caddy.md. |
Config directory at /data/docker/homepage/config/ |
Same /data/docker/<service>/ pattern as Immich and Navidrome |
| Docker socket mounted read-only | Lets Homepage display live container status (running / stopped / unhealthy) for Immich, Navidrome, etc. without needing Portainer. :ro is the safety property. |
Image pinning via ${HOMEPAGE_VERSION} in .env |
Updates are deliberate (docker compose pull + restart), never automatic. Same posture as Immich and Navidrome. |
API keys / tokens in .env (mode 600), referenced from widget config via {{HOMEPAGE_VAR_*}} |
Keeps secrets out of services.yaml, which would otherwise be safe to commit elsewhere. Homepage substitutes env vars at render time. |
HOMEPAGE_ALLOWED_HOSTS set explicitly |
Required since Homepage 0.9 — refuses requests with unknown Host: headers as a CSRF mitigation. Must include home.z2mini.gabrielgabrie.com (the Caddy-fronted hostname — Caddy passes the original Host: through) plus localhost:3000 / 127.0.0.1:3000 for on-box access. |
restart: unless-stopped (not always) |
Survives reboots, but docker compose stop actually stops |
Evaluated and not chosen: Glance (newer, lighter, but no native Immich/Navidrome widgets — would need custom RSS-style scraping); Dashy (heavy Vue SPA, rebuild-on-change config flow); Heimdall / Homer / Flame (older, mostly static-tile launchers, no live data widgets).
Install¶
Directory layout¶
/data/docker/homepage/
├── docker-compose.yml ← stack definition, version-pinned
├── .env ← version pin + API tokens for widgets
└── config/ ← Homepage config dir (auto-created on first boot)
├── services.yaml ← service tiles (Immich, Navidrome, ...)
├── bookmarks.yaml ← static link tiles (GitHub, Tailscale admin, ...)
├── widgets.yaml ← top-of-page info widgets (resources, search, ...)
├── settings.yaml ← layout, theme, columns
├── docker.yaml ← Docker socket integration (live container status)
└── kubernetes.yaml ← unused (file is auto-created and left empty)
docker-compose.yml¶
Mirrors the upstream Homepage template verbatim except for these intentional customizations:
- Version pinned via
${HOMEPAGE_VERSION}from.env(not:latest) - Port published on
127.0.0.1only ("127.0.0.1:3000:3000") — fronted by Caddy athttps://home.z2mini.gabrielgabrie.com(see 17-caddy.md) HOMEPAGE_ALLOWED_HOSTSset to the Caddy hostname + the on-box forms- Config directory bind-mounted at
/data/docker/homepage/config - Docker socket bind-mounted read-only for live container status
env_file: .envso widget API keys can be referenced as{{HOMEPAGE_VAR_*}}fromservices.yaml
name: homepage
services:
homepage:
container_name: homepage
image: ghcr.io/gethomepage/homepage:${HOMEPAGE_VERSION}
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
env_file: .env
environment:
HOMEPAGE_ALLOWED_HOSTS: "home.z2mini.gabrielgabrie.com,localhost:3000,127.0.0.1:3000"
TZ: America/Toronto
volumes:
- "/data/docker/homepage/config:/app/config"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
.env¶
# Version pin — verify current stable:
# curl -s https://api.github.com/repos/gethomepage/homepage/releases/latest | grep tag_name
HOMEPAGE_VERSION=v1.12.3
# Widget API tokens — referenced from services.yaml as {{HOMEPAGE_VAR_*}}
# Mode 600. Never commit this file.
# Immich — Account Settings → API Keys, scope: server.statistics
HOMEPAGE_VAR_IMMICH_KEY=
# Navidrome — Subsonic-style auth: token = md5(password + salt)
# See "Navidrome widget auth" section below for the one-liner to compute this.
HOMEPAGE_VAR_NAVIDROME_USER=admin
HOMEPAGE_VAR_NAVIDROME_TOKEN=
HOMEPAGE_VAR_NAVIDROME_SALT=
# Tailscale — login.tailscale.com/admin/settings/keys (access token, NOT auth key)
HOMEPAGE_VAR_TAILSCALE_KEY=
HOMEPAGE_VAR_TAILSCALE_DEVICEID=
First boot¶
mkdir -p /data/docker/homepage/config
cd /data/docker/homepage
# Write docker-compose.yml + .env (see above)
chmod 600 .env
docker compose config --quiet # validate YAML + variable resolution
docker compose pull # ~80 MB image
docker compose up -d
docker compose ps # confirm "Up X seconds"
Homepage auto-generates only settings.yaml and kubernetes.yaml on first boot — services.yaml, bookmarks.yaml, widgets.yaml, and docker.yaml must be hand-written to populate the page (see the "Config" section below for ready-to-paste snippets). Verify the auto-created subset exists:
HTTP smoke test:
Open https://home.z2mini.gabrielgabrie.com in your browser. You'll see a default placeholder page. Now configure it.
Config — populate the YAML files¶
Edits to these files apply live (no container restart). If a YAML syntax error sneaks in, the page renders the error inline at the top — fix the file and reload.
services.yaml — service tiles with live widgets¶
Important first-boot tip: if a
widget:block references an env var that resolves to an empty string, Homepage renders the tile with a red API Error Information banner instead of a quiet "—". The cleaner pattern when you don't yet have the credentials is to omit the widget block entirely — the tile then renders as a clean clickable launcher, and you add the widget back when ready. Same applies to services with no native Homepage widget at all (Radicale, Vaultwarden) — they're tile-only by design, justicon+href+ theserver/containerpair for the status dot.
- Self-hosted:
- Immich:
icon: immich.png
href: https://immich.z2mini.gabrielgabrie.com
description: Photo library
server: my-docker
container: immich_server
widget:
type: immich
url: https://immich.z2mini.gabrielgabrie.com
key: "{{HOMEPAGE_VAR_IMMICH_KEY}}"
version: 2
fields: ["photos", "videos", "storage"]
- Navidrome:
icon: navidrome.png
href: https://navidrome.z2mini.gabrielgabrie.com
description: Music streaming
server: my-docker
container: navidrome
widget:
type: navidrome
url: https://navidrome.z2mini.gabrielgabrie.com
user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}"
token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}"
salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}"
- Radicale:
icon: radicale.png
href: https://radicale.z2mini.gabrielgabrie.com
description: Calendar (CalDAV)
server: my-docker
container: radicale
# No native Homepage widget for Radicale — tile-only launcher.
- Vaultwarden:
icon: vaultwarden.png
href: https://vault.z2mini.gabrielgabrie.com
description: Password manager
server: my-docker
container: vaultwarden
# No native Homepage widget for Vaultwarden — tile-only launcher.
- OpenProject:
icon: openproject.png
href: https://openproject.z2mini.gabrielgabrie.com
description: Project management (PMP toolkit)
server: my-docker
container: openproject-web-1
# No native Homepage widget for OpenProject — tile-only launcher.
- Network:
- Tailscale:
icon: tailscale.png
href: https://login.tailscale.com/admin/machines
description: Tailnet admin
widget:
type: tailscale
deviceid: "{{HOMEPAGE_VAR_TAILSCALE_DEVICEID}}"
key: "{{HOMEPAGE_VAR_TAILSCALE_KEY}}"
fields: ["address", "last_seen", "expires"]
All
href:andwidget.url:values are the Caddy hostnames (https://<svc>.z2mini.gabrielgabrie.com). Thehrefis what the browser opens;widget.urlis what the Homepage container queries — and the container must go through Caddy too, because the apps bind127.0.0.1:<port>on the host (which the bridged Homepage container can't reach) and100.67.235.68:<port>no longer exists. Tile-only services (Radicale, Vaultwarden — no native Homepage widget) just have the browser-facinghref. The Tailscale widget queriesapi.tailscale.comdirectly (external), unaffected.iOS Immich auto-backup, Navidrome/Tailscale widgets, etc. are already wired — the snippet above is the current
services.yamlon the Z2 (Navidrome + Tailscale widgets are live, not commented out — those tokens are populated in.env). New self-hosted stacks (a future Jellyfin, etc.) get appended here as tile-only first, with a widget added later if one exists.
bookmarks.yaml — static link tiles, no widget¶
- Developer:
- GitHub:
- abbr: GH
href: https://github.com/Gabriel-Gabrie
- Docs:
- abbr: DOC
href: https://docs.gabrielgabrie.com
- Admin:
- Tailscale:
- abbr: TS
href: https://login.tailscale.com/admin/machines
- Hostinger:
- abbr: HOS
href: https://hpanel.hostinger.com
widgets.yaml — top-of-page info row¶
- resources:
label: System
cpu: true
memory: true
expanded: true
- search:
provider: duckduckgo
target: _blank
The resources widget reads CPU/memory from inside the container's cgroups. We deliberately do NOT include a disk: field — Homepage's container only mounts /app/config and /var/run/docker.sock, so any host disk path passed to disk: will fail statvfs() inside the container and the widget renders API Error. Beszel (14-beszel.md) is wired into all three filesystems (/, /data, /mnt/backup) for proper disk monitoring — Homepage's resources widget is "good enough for at-a-glance CPU/RAM," not a disk monitor.
settings.yaml — layout¶
title: Z2 Mini
favicon: https://docs.gabrielgabrie.com/favicon.ico
theme: dark
color: slate
layout:
Self-hosted:
style: row
columns: 5
Network:
style: row
columns: 3
Developer:
style: row
columns: 3
Admin:
style: row
columns: 3
quicklaunch:
searchDescriptions: true
hideInternetSearch: false
showSearchSuggestions: true
statusStyle: dot
docker.yaml — live container status¶
Then on each service tile in services.yaml, the server: my-docker + container: <name> lines (already shown above) tell Homepage to look up that container's status via the socket. Container running → green dot. Stopped → red dot. Unhealthy → yellow.
Adding a new service tile¶
Every time a new self-hosted service goes up on the Z2, it gets a tile here. Homepage does not auto-discover services — there's no Docker-label magic in this setup (a deliberate choice; see the design decisions), so a new container will not appear on the dashboard until you edit services.yaml. This step is part of the standard "bring up a new service" checklist in z2mini-context-for-ai.md — don't skip it. (Radicale and Vaultwarden both went live without a tile and sat dashboard-invisible for days. That's the failure mode this section exists to prevent.)
The minimal tile:
- Self-hosted: # or the appropriate group
- <ServiceName>:
icon: <service>.png # resolves from the dashboard-icons set; most common services are in it
href: http://z2mini:<port> # what the browser opens — use the short hostname, or the
# tailscale-serve HTTPS URL for services that only listen on 127.0.0.1
description: <one line>
server: my-docker
container: <container_name> # exact name from `docker ps` — Immich's is `immich_server`, with an underscore
Then, if Homepage ships a widget for that service (check https://gethomepage.dev/widgets/services/), add a widget: block — store any API token in .env as HOMEPAGE_VAR_<NAME> and reference it as {{HOMEPAGE_VAR_<NAME>}}, then docker compose up -d to reload .env. If there's no widget (Radicale, Vaultwarden), the tile-only form above is the final answer.
Edits to services.yaml and settings.yaml apply live — no restart. Only .env changes need docker compose up -d. If you add a 5th-or-more tile to a group, bump that group's columns: in settings.yaml so the row doesn't wrap awkwardly.
Beszel needs nothing. Its agent auto-discovers every container via the read-only docker.sock mount, so a new service shows up in Beszel's per-container metrics on its own — the manual-tile gap is Homepage-only.
If the manual step keeps getting missed: the structural fix is to switch Homepage to Docker-label discovery — put
homepage.group,homepage.name,homepage.icon,homepage.hreflabels on each container in its compose file, and Homepage builds the tile from the running container automatically. It was rejected at install time (keeps the documented-verbatim compose files free of Homepage cruft, andservices.yamlis more readable as a single file). Reconsider if "forgot to add the tile" happens a third time.
Widget auth — getting the API keys¶
Immich¶
The bulk-import API key at ~/.immich-api-key is over-privileged for a stats-only widget — mint a least-privilege one instead. Either:
Via the web UI: open https://immich.z2mini.gabrielgabrie.com → Account Settings → API Keys → "New API Key." Name homepage-widget, scope server.statistics only.
Via the API (using the existing bulk-import key as auth):
EXISTING_KEY=$(cat ~/.immich-api-key)
curl -sS -X POST http://127.0.0.1:2283/api/api-keys \
-H "x-api-key: $EXISTING_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"homepage-widget","permissions":["server.statistics"]}'
# response: {"secret":"<NEW_KEY>","apiKey":{...,"permissions":["server.statistics"]}}
Either way, write the new key into .env as HOMEPAGE_VAR_IMMICH_KEY=<key> and docker compose up -d to pick it up.
Smoke-test the new key against the stats endpoint before relying on it:
curl -sS -H "x-api-key: <NEW_KEY>" http://127.0.0.1:2283/api/server/statistics
# returns photos / videos / usage / usagePhotos / usageVideos / usageByUser
Navidrome¶
Subsonic-API auth uses token = md5(password + salt), never the raw password. Generate from the laptop or a tailnet shell:
SALT=$(head -c 12 /dev/urandom | base64 | tr -d '+/=' | head -c 12)
PASS='your-navidrome-admin-password'
TOKEN=$(printf '%s%s' "$PASS" "$SALT" | md5sum | awk '{print $1}')
echo "SALT=$SALT"
echo "TOKEN=$TOKEN"
Paste both into .env. The salt is per-config and can stay constant across restarts (it's not a password — it's a hash component).
Tailscale¶
- Tailscale admin → Settings → Keys → Generate access token (NOT an auth key — different thing). Validity: max 90 days. Description:
homepage-z2mini. - Tailscale admin → Machines → z2mini → expand "Machine Details" → copy the
IDfield (ends inCNTRL). - Paste both into
.env. - Record the rotation date so the age-check reminder can warn before expiry:
date -u +%FT%TZ > /home/gabriel/.tailscale-token-rotated
chmod 600 /home/gabriel/.tailscale-token-rotated
Why no auto-renewal¶
Tailscale enforces a 90-day max on access tokens — no plan has permanent tokens. OAuth client credentials (the only programmatic-renewal path) issue 1-hour tokens, which would force a Homepage container restart every ~30 minutes (env_file is loaded at container start, not live). For a single-device personal tailnet, the widget data (IP / last-seen / key-expiry) is low-value enough that ~48 daily restarts are not worth it. Manual rotation every ~80 days is the chosen pattern, paired with a weekly automated email reminder so it never silently expires.
Rotation reminder (weekly cron, emails on day 75)¶
/home/gabriel/scripts/tailscale-token-age-check.sh runs every Monday at 09:00 via cron. It reads /home/gabriel/.tailscale-token-rotated, computes age, and emails the rotation steps (via the existing msmtp pipeline) when the token is older than 75 days. Silent otherwise.
Cron entry (already in crontab -l):
When the email arrives, follow the rotation steps verbatim:
# 1. Generate new token at https://login.tailscale.com/admin/settings/keys
# 2. ssh gabriel@z2mini
# 3. Replace token in .env (paste between the quotes):
sed -i 's|^HOMEPAGE_VAR_TAILSCALE_KEY=.*|HOMEPAGE_VAR_TAILSCALE_KEY=<NEW_TOKEN>|' /data/docker/homepage/.env
# 4. Reset the rotation clock:
date -u +%FT%TZ > /home/gabriel/.tailscale-token-rotated
# 5. Reload Homepage to pick up the new env var:
cd /data/docker/homepage && docker compose up -d
# 6. Verify the widget renders:
curl -sS http://127.0.0.1:3000/api/services | grep -o '"type":"tailscale"'
# 7. Revoke the OLD token in the Tailscale admin keys page
If you ever delete .tailscale-token-rotated, the age-check exits silent (no false alarm) until the file is re-recreated on the next rotation.
Operations¶
Start / stop / pull updates¶
cd /data/docker/homepage
docker compose up -d # start (or recreate after pull)
docker compose stop # stop without removing
docker compose down # stop and remove (config preserved)
# Updates: bump HOMEPAGE_VERSION in .env, then:
docker compose pull
docker compose up -d
Logs¶
Live YAML reload¶
Homepage watches the config directory and re-renders on save — no container restart needed. Browser auto-refresh is bound to the page (turn it off in settings.yaml → headerStyle if it's distracting).
If a YAML edit produces a parse error, Homepage shows the error banner inline at the top of the rendered page. Fix the file, save, the banner disappears.
Connecting from on the server itself¶
Homepage binds 127.0.0.1:3000, so scripts on z2mini use http://127.0.0.1:3000 (or http://localhost:3000) — e.g. the Tailscale-token-age-check script's curl http://127.0.0.1:3000/api/services. https://home.z2mini.gabrielgabrie.com also works from the box (via Caddy). http://z2mini:3000 and http://100.67.235.68:3000 no longer work — those bindings were removed when Homepage moved behind Caddy. From other tailnet devices, the only way in is https://home.z2mini.gabrielgabrie.com.
Backup considerations¶
Homepage is in the nightly backup (since May 2026 — see 05-backups.md). ~/scripts/backup-files.sh rsyncs .env + config/ (the six YAML files: services.yaml, docker.yaml, settings.yaml, bookmarks.yaml, widgets.yaml, kubernetes.yaml) into /mnt/backup/current/service-config/homepage/. Both are small and rsync-safe — no live database, config files are read on each request, not held open. The whole service-config/ tree also goes to the off-site T5 via backup-offsite.sh.
.env contains the widget API tokens; permission must remain 600. The backup script preserves permissions via rsync -a.
The Homepage config is also fully committed to documentation (this page contains the operational services.yaml / bookmarks.yaml / widgets.yaml shapes verbatim) — so even a total backup loss is recoverable by re-pasting the snippets above and regenerating the API keys (~10 minutes of work).
Restore: rsync service-config/homepage/.env and service-config/homepage/config/ back into /data/docker/homepage/, docker compose up -d. The widget tokens in .env may need re-minting if they've expired since the backup (the Tailscale one rotates on an ~80-day cycle). See 08-recovery.md → Step 6b.
Troubleshooting¶
Page loads but tiles show "API Error Information" red banner:
- The widget block is configured but the
{{HOMEPAGE_VAR_*}}values resolved to empty strings (or wrong values). Homepage doesn't render "—" for empty tokens — it renders an error. - Two valid fixes: (a) populate the env vars in
.env, thendocker compose up -d; (b) if you're not ready to populate yet, comment out the entirewidget:block inservices.yaml— the tile will render as a quiet clickable launcher with no error banner. - Don't try to half-disable a widget by leaving the block but blanking specific fields — that produces the same error.
Page loads but tiles show "Unable to fetch data" or empty stats:
- Check the
widget.urlis the Caddy hostname (https://immich.z2mini.gabrielgabrie.com, etc.), nothttp://127.0.0.1:<port>or the short hostname — the Homepage container is bridge-networked, so it can't reach the apps'127.0.0.1:<port>on the host; it has to go through Caddy athttps://<svc>.z2mini.gabrielgabrie.com. - Check the API token is correct:
docker compose exec homepage env | grep HOMEPAGE_VAR_. - Check the service is reachable from the container:
docker compose exec homepage wget -qO- https://immich.z2mini.gabrielgabrie.com/api/server/ping.
Top "System" widget shows "API Error":
widgets.yamlhas adisk:field pointing to a host path (/data,/mnt/backup, etc.). Homepage's container doesn't mount those paths — only/app/configand the Docker socket — sostatvfs()fails.- Fix: remove the
disk:line. Use Beszel for disk monitoring (it has explicit bind mounts for each filesystem); Homepage's resources widget is best left to CPU + memory.
"Hostname not allowed" error in browser:
HOMEPAGE_ALLOWED_HOSTSis too narrow. Add the hostname you're browsing from to the env var, restart:docker compose up -d.- Common omissions: the tailnet IP itself, the FQDN form, the short hostname when MagicDNS is on.
Tailscale widget always shows the same numbers:
- Tailscale's API rate-limits aggressively (free tier). Homepage caches; it's not stale data, it's the API throttling the refresh.
- Check token validity at
login.tailscale.com/admin/settings/keys. Access tokens silently stop returning data when expired (no error, just stale numbers).
Container shows red dot in Homepage even though docker compose ps says it's up:
- Check
server:inservices.yamlmatches the key indocker.yaml(case-sensitive —my-dockernotMy-Docker). - Check
container:matches the actual container name (Immich's isimmich_server, with an underscore, notimmich-server). - Check the Docker socket is mounted:
docker compose exec homepage ls -la /var/run/docker.sock(should show socket file).
Edits to services.yaml don't show up:
- Live reload watches inotify events. If editing over Samba
[files]the inotify event may not fire — edit via SSH instead, ordocker compose restart homepage.
http://localhost:3000 doesn't work from the server:
- Same gotcha as Immich and Navidrome — bound to Tailscale interface IP only.
- Fix: use
http://127.0.0.1:3000orhttps://home.z2mini.gabrielgabrie.comfrom on-server scripts.
See also¶
- 11-immich.md — Immich install; Homepage's
immichwidget queries the same API key - 12-navidrome.md — Navidrome install; Homepage's
navidromewidget uses Subsonic-style token auth - 10-system-reference.md — quick lookup for paths, ports, services
- 03-tailscale.md — Tailscale baseline; Homepage's
tailscalewidget needs an access token from the admin panel - z2mini-context-for-ai.md — AI-context document, kept in sync with this page