Homepage dashboard with login

A start page for every service, with live widgets and a login, and no docker socket.

  • Standalone container
  • Rootless: systemctl --user
  • Tested with podman 5.0 and newer

Last updated

In short: Homepage runs rootless on port 3010 without access to any container socket; its widgets ask each app's own API instead. API keys and the login password are podman secrets, passed as environment variables by two drop-ins, and services.yaml only contains {{HOMEPAGE_VAR_*}} placeholders. HOMEPAGE_ALLOWED_HOSTS must list every name and address you open it at, or Homepage answers 400.

The quadlet files

Every quadlet file below passes podman's quadlet generator and systemd-analyze verify on podman 5.0 and the current release. Personal values are placeholders: replace example.com, /srv/... paths and the time zone with your own.

homepage.container

Save as ~/.config/containers/systemd/homepage.container

[Unit]
Description=Homepage start page with live widgets
StartLimitIntervalSec=0
After=network-online.target

[Container]
Image=ghcr.io/gethomepage/homepage:latest
ContainerName=homepage
AutoUpdate=registry
Environment=TZ=Etc/UTC
# Homepage answers 400 to any Host header not listed here, including its health check.
Environment=HOMEPAGE_ALLOWED_HOSTS=home.example.com,10.0.0.10:3010
Environment=LOG_TARGETS=stdout
# API keys and the login come from drop-ins in homepage.container.d/, as podman secrets.
# Host port 3010: 3000 is often taken (AdGuard Home's setup page uses it).
PublishPort=3010:3000
Volume=homepage.volume:/app/config:Z
# Disk usage of a NAS for the resources widget. rslave: shares that mount after
# Homepage started still show up.
Volume=/mnt:/nas:ro,rslave
# No docker or podman socket: status comes from siteMonitor and each app's own API.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:3000/api/healthcheck
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=1m
HealthOnFailure=kill

[Service]
Restart=always
RestartSec=30

[Install]
WantedBy=default.target

homepage.volume

Save as ~/.config/containers/systemd/homepage.volume

[Unit]
Description=Homepage config: settings, services, widgets and bookmarks YAML

[Volume]
VolumeName=homepage

10-secrets.conf

Save as ~/.config/containers/systemd/homepage.container.d/10-secrets.conf

# Every podman secret homepage-<name> becomes the variable HOMEPAGE_VAR_<NAME>,
# which services.yaml reads as {{HOMEPAGE_VAR_<NAME>}}.
[Container]
Secret=homepage-jellyfin-key,type=env,target=HOMEPAGE_VAR_JELLYFIN_KEY
Secret=homepage-radarr-key,type=env,target=HOMEPAGE_VAR_RADARR_KEY
Secret=homepage-sonarr-key,type=env,target=HOMEPAGE_VAR_SONARR_KEY
Secret=homepage-ntfy-token,type=env,target=HOMEPAGE_VAR_NTFY_TOKEN

20-auth.conf

Save as ~/.config/containers/systemd/homepage.container.d/20-auth.conf

# Homepage's built-in login: one password, no user name.
[Container]
Environment=HOMEPAGE_AUTH_ENABLED=true
# The one URL you open Homepage at; the login redirects back to it.
Environment=HOMEPAGE_EXTERNAL_URL=https://home.example.com
Secret=homepage-auth-secret,type=env,target=HOMEPAGE_AUTH_SECRET
Secret=homepage-auth-password,type=env,target=HOMEPAGE_AUTH_PASSWORD

services.yaml

Save as the homepage volume: /app/config/services.yaml

# href: what your browser opens. widget url and siteMonitor: what the container calls.
# Keys only as {{HOMEPAGE_VAR_*}}, never as values.
- Media:
    - Jellyfin:
        icon: jellyfin.svg
        href: https://jellyfin.example.com
        description: Media server
        siteMonitor: http://host.containers.internal:8096/health
        widget:
          type: jellyfin
          url: http://host.containers.internal:8096
          key: "{{HOMEPAGE_VAR_JELLYFIN_KEY}}"
          # Version 2 sends the key in a header. Version 1 puts it in the URL, and
          # Homepage logs that URL when a widget call fails.
          version: 2
          enableBlocks: true
          enableNowPlaying: true
    - Radarr:
        icon: radarr.svg
        href: https://radarr.example.com
        description: Movies
        siteMonitor: http://host.containers.internal:7878/ping
        widget:
          type: radarr
          url: http://host.containers.internal:7878
          key: "{{HOMEPAGE_VAR_RADARR_KEY}}"
          enableQueue: true
    - Sonarr:
        icon: sonarr.svg
        href: https://sonarr.example.com
        description: TV series
        siteMonitor: http://host.containers.internal:8989/ping
        widget:
          type: sonarr
          url: http://host.containers.internal:8989
          key: "{{HOMEPAGE_VAR_SONARR_KEY}}"
          enableQueue: true
- Monitoring:
    - Gatus:
        icon: gatus.svg
        href: https://status.example.com
        description: Uptime checks
        widget:
          type: gatus
          url: http://host.containers.internal:8084
    - ntfy:
        icon: ntfy.svg
        href: https://ntfy.example.com
        description: Latest alert
        siteMonitor: http://host.containers.internal:2586/v1/health
        widget:
          type: ntfy
          url: http://host.containers.internal:2586
          topic: homelab-alerts
          key: "{{HOMEPAGE_VAR_NTFY_TOKEN}}"

Caddyfile

Save as /etc/caddy/Caddyfile

home.example.com {
	reverse_proxy 10.0.0.10:3010
}

Why is it built this way?

What it is. Homepage is a start page for your server: one tile per service, with live numbers such as the download queue, what is playing in Jellyfin and the latest ntfy alert. It is configured entirely in YAML.

Secrets as placeholders. Homepage replaces {{HOMEPAGE_VAR_RADARR_KEY}} in services.yaml with the environment variable of that name. The drop-in 10-secrets.conf fills those variables from podman secrets, so the YAML holds no keys and can be shared or committed. Quadlet merges every file in homepage.container.d/ into the container, so the secrets stay out of the main file.

The built-in login. 20-auth.conf turns on Homepage's own password login. The signing secret is random; the password is yours. Put the page behind Caddy at one fixed URL and set HOMEPAGE_EXTERNAL_URL to exactly that URL.

Read-only accounts. Give widgets the least access that works: a read-only ntfy user that may only read the alert topic (see the ntfy example), and a read-only local account for network controllers. Never use an admin login for a dashboard widget.

Caddy in front of Homepage

The name in the site block must also be in HOMEPAGE_ALLOWED_HOSTS; that is the most common reason for a 400 error behind a proxy. The Caddyfile generator writes the block.

Check that it works

systemctl --user status homepage.service
# 200 with an allowed Host header, 400 without
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: 10.0.0.10:3010' http://127.0.0.1:3010/api/healthcheck
# which secrets the container gets (names only, never values)
podman inspect homepage --format '{{range .Config.CreateCommand}}{{.}} {{end}}' | grep -o -- '--secret [a-z-]*'
journalctl --user -u homepage.service -n 30

Gatus adds history and alerts to the up or down dots. For rootless services in general, see rootless podman.

How do I start it?

Run these as your normal user, not as root. Quadlet reads ~/.config/containers/systemd/ for user services.

mkdir -p ~/.config/containers/systemd/homepage.container.d
# one secret per API key: copy each key from the app (Settings, General) and paste it
for s in jellyfin-key radarr-key sonarr-key ntfy-token; do
  read -rs -p "$s: " v && echo && printf '%s' "$v" | podman secret create "homepage-$s" -
done; unset v
# the login: a random signing secret and your password
openssl rand -hex 32 | tr -d '\n' | podman secret create homepage-auth-secret -
read -rs -p 'Homepage password: ' v && echo && printf '%s' "$v" | podman secret create homepage-auth-password - && unset v
systemctl --user daemon-reload
systemctl --user start homepage.service
cp services.yaml "$(podman volume inspect homepage --format '{{.Mountpoint}}')/"
# open https://home.example.com and log in

Check the result with systemctl --user status and the logs with journalctl --user -u followed by the service name. To turn a docker-compose file of your own into files like these, use the compose to quadlet converter.

Common mistakes

Homepage shows a blank page or answers 400 Bad Request

The Host header is not in HOMEPAGE_ALLOWED_HOSTS. List every name and address:port you open it at, comma-separated, such as home.example.com,10.0.0.10:3010. This check also applies to /api/healthcheck from outside the container.

The login page loops back to itself or redirects to the wrong address

HOMEPAGE_EXTERNAL_URL must be the exact URL in your browser, including https://. The login supports one URL at a time; if you also open Homepage by IP, you have to log in at the external URL.

Widgets show an API error while the app works fine

The widget url is called from inside the container: use http://host.containers.internal:<port>, not localhost and not the public HTTPS name. Check that the secret exists with podman secret ls, then restart homepage.service, because secrets are read at container start.

An API key shows up in the journal

Homepage logs the full upstream URL when a widget call fails. Widgets that put the key in the URL (Jellyfin version 1) leak it that way. Use version: 2 for Jellyfin 10.12 and newer, and rotate any key that appeared in a log.

Frequently asked questions

Does Homepage have a built-in login?

Yes. With HOMEPAGE_AUTH_ENABLED=true, a signing secret in HOMEPAGE_AUTH_SECRET and a password in HOMEPAGE_AUTH_PASSWORD, Homepage asks for the password before it shows anything. There is no user name. /api/healthcheck stays open so health checks keep working.

Why no docker or podman socket for container status?

Whoever can talk to the container socket controls every container, which is root-equivalent for rootful podman. siteMonitor checks each app's health URL instead, which is what you care about anyway: whether the app answers.

How do I edit services.yaml later?

Edit the file in the volume (podman volume inspect homepage shows the path) or keep it in git and copy it in. Homepage picks up YAML changes without a restart; new secrets need systemctl --user restart homepage.service.