Skip to content

13 — Homepage Dashboard

Now behind Caddy (May 2026). Homepage moved behind the Caddy reverse proxy: the container binds 127.0.0.1:3000 only, reached at https://home.z2mini.gabrielgabrie.com (auto-renewing Let's Encrypt cert; HOMEPAGE_ALLOWED_HOSTS now includes that hostname + localhost:3000 + 127.0.0.1:3000). The old https://home.z2mini.gabrielgabrie.com / http://127.0.0.1:3000 URLs no longer work; on the box use http://127.0.0.1:3000. Tile hrefs now point at the https://<svc>.z2mini.gabrielgabrie.com hostnames, and the widget.url fields 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: replace curl … http://127.0.0.1:3000/api/services with http://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:

  1. Version pinned via ${HOMEPAGE_VERSION} from .env (not :latest)
  2. Port published on 127.0.0.1 only ("127.0.0.1:3000:3000") — fronted by Caddy at https://home.z2mini.gabrielgabrie.com (see 17-caddy.md)
  3. HOMEPAGE_ALLOWED_HOSTS set to the Caddy hostname + the on-box forms
  4. Config directory bind-mounted at /data/docker/homepage/config
  5. Docker socket bind-mounted read-only for live container status
  6. env_file: .env so widget API keys can be referenced as {{HOMEPAGE_VAR_*}} from services.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:

ls /data/docker/homepage/config/
# kubernetes.yaml  logs/  settings.yaml

HTTP smoke test:

curl -sI http://127.0.0.1:3000/ | head -5
# HTTP/1.1 200 OK

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, just icon + href + the server/container pair 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: and widget.url: values are the Caddy hostnames (https://<svc>.z2mini.gabrielgabrie.com). The href is what the browser opens; widget.url is what the Homepage container queries — and the container must go through Caddy too, because the apps bind 127.0.0.1:<port> on the host (which the bridged Homepage container can't reach) and 100.67.235.68:<port> no longer exists. Tile-only services (Radicale, Vaultwarden — no native Homepage widget) just have the browser-facing href. The Tailscale widget queries api.tailscale.com directly (external), unaffected.

iOS Immich auto-backup, Navidrome/Tailscale widgets, etc. are already wired — the snippet above is the current services.yaml on 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.

- 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

my-docker:
  socket: /var/run/docker.sock

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.href labels 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, and services.yaml is 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

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

  1. Tailscale admin → Settings → Keys → Generate access token (NOT an auth key — different thing). Validity: max 90 days. Description: homepage-z2mini.
  2. Tailscale admin → Machines → z2mini → expand "Machine Details" → copy the ID field (ends in CNTRL).
  3. Paste both into .env.
  4. 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):

0 9 * * 1 /home/gabriel/scripts/tailscale-token-age-check.sh

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

docker compose logs -f --tail 50 homepage

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.yamlheaderStyle 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, then docker compose up -d; (b) if you're not ready to populate yet, comment out the entire widget: block in services.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.url is the Caddy hostname (https://immich.z2mini.gabrielgabrie.com, etc.), not http://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 at https://<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.yaml has a disk: field pointing to a host path (/data, /mnt/backup, etc.). Homepage's container doesn't mount those paths — only /app/config and the Docker socket — so statvfs() 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_HOSTS is 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: in services.yaml matches the key in docker.yaml (case-sensitive — my-docker not My-Docker).
  • Check container: matches the actual container name (Immich's is immich_server, with an underscore, not immich-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, or docker 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:3000 or https://home.z2mini.gabrielgabrie.com from on-server scripts.

See also