# Quadlet unit: grafana (rootless, boot-persistent). # # Install path on dankclaw: ~/.config/containers/systemd/grafana.container # Unit name: grafana.service (container name: grafana) # # Dashboards. This replaces the compose service of the same name in # dankclaw/compose.yaml ("Observability" block). # # PUBLISHING. Bound to 127.0.0.1 for the same reason as prometheus: the browser # path is https://grafana.franksdank.cloud (Caddy → Anubis → Authentik outpost → # grafana:3000 over dankclaw_default) and the host-side homepage status timer # probes 127.0.0.1:3002. Publishing on 0.0.0.0 as the compose service did left # an off-host path into Grafana that skips the guard chain entirely. # # AUTH. Grafana is an OAuth2 relying party against Authentik (OAuth2Provider # "grafana", confidential client). The guard chain fronts unauthenticated # traffic; the login itself happens through the Authentik button inside # Grafana. The client secret is NOT written here: it is expanded by systemd # from the root .env (EnvironmentFile in [Service], never in [Container], so # the container receives exactly this one secret and not the whole file). # Without that .env entry the unit will not start — the same dependency every # other rendered-secret deploy has. # # STATE. /opt/grafana/data holds grafana.db. /opt/grafana/provisioning is # mounted read-only and was EMPTY as of 2026-09-11, and grafana.db reported # zero datasources and zero dashboards: this Grafana is an empty install whose # only configured account is the local admin. Nothing here queries Prometheus # or Loki yet. [Unit] Description=Grafana dashboards (Dankclaw) After=network-online.target prometheus.service Wants=network-online.target prometheus.service [Container] ContainerName=grafana Image=docker.io/grafana/grafana:latest Network=dankclaw_default User=0:0 PublishPort=127.0.0.1:3002:3000 Environment=GF_SERVER_ROOT_URL=https://grafana.franksdank.cloud Environment=GF_AUTH_GENERIC_OAUTH_ENABLED=true Environment=GF_AUTH_GENERIC_OAUTH_NAME=Authentik Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID=grafana-21ca190cde75d60f Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET} # Global authorize path: this Authentik build resolves the provider by the # client_id query parameter — /application/o//authorize/ does not exist. Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/ Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/ # Standard OIDC userinfo. Grafana v13 fetches /emails for the address # ONLY when the token carries no `email` claim (authentik has no /emails path and # 404s it, failing the login) — so the claim must actually be there: the fix is # the grafana-email-scope mapping (see scripts/authentik-grafana-sso-fix.sh), # not a Grafana flag. Do NOT append ?api=1 to this URL: Grafana then GETs # <...>/userinfo/?api=1/emails, a 403. Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/ # authentik's scopes are openid/email/profile. `user:email` (a GitLab-ism) is # not a known scope here; requesting it makes the authorize view crash. # Quoted: quadlet word-splits Environment=, so an unquoted spaced list # truncates SCOPES to its first token. Environment="GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile" Environment=GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH=email Environment=GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=sub Environment=GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=preferred_username # First SSO login auto-provisions the user. Safe: unauthenticated traffic never # reaches this callback — the Anubis -> Authentik guard fronts every request. Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1 Volume=/opt/grafana/data:/var/lib/grafana Volume=/opt/grafana/provisioning:/etc/grafana/provisioning:ro HealthCmd=wget --spider -q http://localhost:3000/api/health HealthInterval=30s HealthTimeout=10s HealthRetries=3 HealthStartPeriod=60s [Service] # systemd-side only: supplies ${GRAFANA_OAUTH_CLIENT_SECRET} for the expansion # above. The container never receives this file. # # It is the per-service env file the deployer seeds from dankclaw/.env — the # fleet convention (caddy, pihole, copyparty, romm and seerr all read one). # The repo-root .env that the compose service happened to pick up is a gluetun # env file: it carries HOME=/root and the WireGuard keys, and importing it into # this service makes podman look for its configuration under /root, which it # cannot read, so the unit fails to start. Seed the value into dankclaw/.env. EnvironmentFile=/home/dank/Containers/grafana/env Restart=always RestartSec=10 [Install] WantedBy=default.target