services:config-sync:image:registry.k8s.io/git-sync/git-sync:v4.7.1user:"1000:1000"environment:-TZ=America/Los_Angeles-GITSYNC_REPO=ssh://git@ssh.github.com:443/<you>/<repo>.git-GITSYNC_REF=deploy/swarm/homepage-GITSYNC_PERIOD=60s-GITSYNC_ROOT=/git-GITSYNC_LINK=repo-GITSYNC_SSH_KEY_FILE=/dev/shm/key-GITSYNC_SSH_KNOWN_HOSTS_FILE=/known_hosts-GITSYNC_EXECHOOK_COMMAND=/sync-config.sh-GITSYNC_ADD_USER=true-GITSYNC_FILTER=blob:none-GITSYNC_SPARSE_CHECKOUT_FILE=/sparse-checkout-GITSYNC_HTTP_BIND=:8080entrypoint:-/bin/sh--c-|umask 077printf '%s\n' "$$(cat /run/secrets/gitsync_ssh_key_v1)" > /dev/shm/key[ -s /dev/shm/key ] || { echo "config-sync: could not write the SSH key to /dev/shm" >&2; exit 1; }mkdir -p /tmp/.ssh && cp /ssh_config /tmp/.ssh/config || { echo "config-sync: could not install the ssh config" >&2; exit 1; }exec /git-syncvolumes:-type:bind# (1)!source:/usr/share/zoneinfotarget:/usr/share/zoneinforead_only:true-type:volumesource:gitrepo_v2target:/gitvolume:nocopy:true-config:/app/configconfigs:-source:homepage_known_hosts_v2target:/known_hosts-source:homepage_ssh_config_v2target:/ssh_config-source:homepage_sparse_checkout_v1target:/sparse-checkout-source:homepage_sync_config_v2target:/sync-config.shmode:0555secrets:-gitsync_ssh_key_v1networks:discovery:aliases:-homepage-git-syncdeploy:mode:replicatedreplicas:1homepage:image:ghcr.io/gethomepage/homepage:v2.4.0@sha256:643bd0be730d40f69d58028a55d1a896739333e8815786df42bc97f109ecbe61restart:unless-stoppedenvironment:# (2)!-TZ=America/Los_Angeles-HOMEPAGE_ALLOWED_HOSTS=192.168.1.45:3000,homepage.mydomain.com,localhost:3000-PUID=1000-PGID=1000-HOMEPAGE_FILE_PROWLARR_KEY=/app/config/secrets/prowlarr_key-HOMEPAGE_FILE_RADARR_KEY=/app/config/secrets/radarr_key-HOMEPAGE_FILE_SONARR_KEY=/app/config/secrets/sonarr_key-HOMEPAGE_FILE_BAZARR_KEY=/app/config/secrets/bazarr_key-HOMEPAGE_FILE_SEERR_KEY=/app/config/secrets/seerr_key-HOMEPAGE_FILE_SABNZBD_KEY=/app/config/secrets/sabnzbd_key-HOMEPAGE_FILE_UNIFI_KEY=/run/secrets/unifi_apikey-HOMEPAGE_FILE_PORTAINER_KEY=/app/config/secrets/portainer_key-HOMEPAGE_FILE_PROXMOX_KEY=/app/config/secrets/proxmox_key-HOMEPAGE_FILE_TRUENAS_KEY=/app/config/secrets/truenas_key-HOMEPAGE_FILE_ADGUARD1_PASSWORD=/app/config/secrets/adguard1_password-HOMEPAGE_FILE_ADGUARD2_PASSWORD=/app/config/secrets/adguard2_password-HOMEPAGE_FILE_QBITTORRENT_PASSWORD=/app/config/secrets/qbittorrent_passwordextra_hosts:-"proxmox.mydomain.com:192.168.1.45"ports:-3000:3000volumes:-type:bindsource:/usr/share/zoneinfotarget:/usr/share/zoneinforead_only:true-config:/app/configsecrets:-unifi_apikeynetworks:-discovery-traefik-apideploy:mode:replicatedreplicas:1configs:homepage_known_hosts_v2:file:./known_hostshomepage_ssh_config_v2:file:./ssh_confighomepage_sparse_checkout_v1:file:./sparse-checkouthomepage_sync_config_v2:# (3)!file:./sync-config.shsecrets:unifi_apikey:external:truegitsync_ssh_key_v1:external:truevolumes:# (4)!config:driver:localdriver_opts:type:nonedevice:"/mnt/docker-cephFS/homepage_config"o:bindgitrepo_v2:# (5)!driver:localdriver_opts:type:nonedevice:"/mnt/docker-cephFS/homepage_git_v2"o:bindnetworks:discovery:external:truetraefik-api:external:true
neither the git-sync image nor the homepage image has time zone data, so TZ alone changes
nothing. both services mount the host's /usr/share/zoneinfo read-only.
HOMEPAGE_ALLOWED_HOSTS lists every address and name homepage is reached by, because homepage
refuses a request whose host it does not recognise.
swarm refuses a deploy that changes a config's content under the same name, so each change to
sync-config.sh bumps the suffix. that restarts config-sync only, and homepage keeps running.
homepage_config and homepage_git_v2 must exist on the cephfs mount before the first deploy,
owned by uid 1000, which homepage and config-sync run as. homepage_config also needs the key
file for each /app/config/secrets/ path above, or that tile's widget shows an api error.
the name carries _v2 because docker never updates an existing volume's options, so a node that
already has the volume keeps mounting the old folder. a new name makes every node create it again.
a missing folder fails the task, because each volume binds its folder by path
put each widget key in its own file in homepage_config/secrets/, readable by uid 1000. there's one file for each HOMEPAGE_FILE_* path under /app/config/secrets/
a missing key file leaves that tile's widget showing an api error
create the docker secret unifi_apikey. homepage shares gitsync_ssh_key_v1 and the discovery overlay with gatus, so create those as in gatus's steps if gatus is not deployed yet
homepage also joins traefik-api, where the traefik widget reads traefik's API. the dashboard's name is behind oauth, which a widget can't pass. create the overlay as in traefik's steps
config, from /mnt/docker-cephFS/homepage_config, is homepage's /app/config. it holds the yaml files and custom.css that config-sync copies from git, the widget key files in secrets/, and homepage's logs/
gitrepo_v2, from /mnt/docker-cephFS/homepage_git_v2, holds config-sync's checkout
on cephfs, config-sync doesn't have to share a node with homepage.
homepage publishes 3000 through the ingress mesh. it answers only the hosts in HOMEPAGE_ALLOWED_HOSTS: the keepalived VIP at 192.168.1.45:3000, its name homepage.mydomain.com, and localhost:3000
it joins discovery to read the swarm's labels from dockerproxy:2375. config-sync joins it too, as homepage-git-sync, so gatus's check reaches the sidecar's health endpoint by name
it joins traefik-api so the traefik widget can read traefik's API at http://traefik_traefik:8080, since traefik.mydomain.com is behind oauth
extra_hosts resolves proxmox.mydomain.com to the VIP, 192.168.1.45, without DNS. the proxmox widget can only reach proxmox by that name, because traefik routes on it
labels on the stacks make the tiles for anything with a web UI. homepage reads them from two docker endpoints: dockerproxy, the swarm's read-only socket proxy on an overlay network, and a second read-only socket proxy on truenas1 at 192.168.1.86:2375.
config/docker.yaml: the two docker endpoints, 8 lines
on the swarm the labels go under deploy.labels. homepage reads the swarm's service specs, and container labels aren't in them, so a tile labelled the usual way never appears
services.yaml holds everything else: the portainer environments, truenas, unifi, proxmox and its nodes, glances on each host, and the plumbing tab
config/services.yaml: every tile that no label provides, 867 lines, 2 notes
-Infrastructure:-Portainer (swarm):href:https://portainer.mydomain.comicon:portainer.pngdescription:the three managers and every stack on themwidget:type:portainerurl:http://192.168.1.45:9000env:13key:"{{HOMEPAGE_FILE_PORTAINER_KEY}}"-Portainer (syn02):href:https://portainer.mydomain.comicon:portainer.pngdescription:Synology NASwidget:type:portainerurl:http://192.168.1.45:9000env:34key:"{{HOMEPAGE_FILE_PORTAINER_KEY}}"-Portainer (pi-zwave01):href:https://portainer.mydomain.comicon:portainer.pngdescription:Raspberry Pi, Z-Wave and Threadwidget:type:portainerurl:http://192.168.1.45:9000env:37key:"{{HOMEPAGE_FILE_PORTAINER_KEY}}"-Portainer (truenas1):href:https://portainer.mydomain.comicon:portainer.pngdescription:NAS apps, frigate and arrstackwidget:type:portainerurl:http://192.168.1.45:9000env:47key:"{{HOMEPAGE_FILE_PORTAINER_KEY}}"
-TrueNAS:href:https://truenas1.mydomain.comicon:truenas.pngdescription:NAS, apps and storagewidget:type:truenasurl:https://192.168.1.86version:2# (1)!key:"{{HOMEPAGE_FILE_TRUENAS_KEY}}"enablePools:true
truenas 26 removed the REST api that the widget's default, version 1, uses. with version: 2 the
widget talks to truenas over its websocket api, and without it the tile shows an error.
lines 48-269: 18 more tiles, UniFi to WordPress database
-UniFi:href:https://unifi.mydomain.comicon:unifi.pngdescription:Network controller, gateway and APswidget:type:unifiurl:https://192.168.1.1key:"{{HOMEPAGE_FILE_UNIFI_KEY}}"-Nginx Proxy Manager:href:https://npm.mydomain.comserver:swarmcontainer:npm_appicon:nginx-proxy-manager.pngdescription:Reverse proxy and its certificateswidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/swarm_nginx-proxy-manager/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Traefik:href:https://traefik.mydomain.com/server:swarmcontainer:traefik_traefikicon:traefik.pngdescription:Reverse proxy on 443, from services.yamlwidget:type:traefikurl:http://traefik_traefik:8080-Proxmox:href:https://proxmox.mydomain.comicon:proxmox.pngdescription:Three-node cluster, VMs and LXCwidget:type:proxmoxurl:https://proxmox.mydomain.comusername:pve-auditor@pam!homepagepassword:"{{HOMEPAGE_FILE_PROXMOX_KEY}}"-pve1:href:https://pve1.mydomain.com:8006icon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:proxmoxurl:https://proxmox.mydomain.comusername:pve-auditor@pam!homepagepassword:"{{HOMEPAGE_FILE_PROXMOX_KEY}}"node:pve1-pve2:href:https://pve2.mydomain.com:8006icon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:proxmoxurl:https://proxmox.mydomain.comusername:pve-auditor@pam!homepagepassword:"{{HOMEPAGE_FILE_PROXMOX_KEY}}"node:pve2-pve3:href:https://pve3.mydomain.com:8006icon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:proxmoxurl:https://proxmox.mydomain.comusername:pve-auditor@pam!homepagepassword:"{{HOMEPAGE_FILE_PROXMOX_KEY}}"node:pve3-Glances:-truenas1:id:glances-truenas1href:https://glances-truenas1.mydomain.comicon:truenas.pngdescription:Physical, NAS, apps and storagewidget:type:glancesurl:http://192.168.1.86:61208version:4metric:info-syn02:id:glances-syn02href:https://glances-syn02.mydomain.comicon:synology.pngdescription:Physical, Synology NASwidget:type:glancesurl:http://192.168.1.31:61208version:4metric:info-pi-zwave01:id:glances-pi-zwave01href:https://glances-pi-zwave01.mydomain.comicon:raspberry-pi.pngdescription:Physical, the Z-Wave, Zigbee and Thread radioswidget:type:glancesurl:http://192.168.1.96:61208version:4metric:info-pve1:id:glances-pve1href:https://glances-pve1.mydomain.comicon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:glancesurl:http://192.168.1.81:61208version:4metric:info-pve2:id:glances-pve2href:https://glances-pve2.mydomain.comicon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:glancesurl:http://192.168.1.82:61208version:4metric:info-pve3:id:glances-pve3href:https://glances-pve3.mydomain.comicon:proxmox.pngdescription:Physical, Proxmox VE nodewidget:type:glancesurl:http://192.168.1.83:61208version:4metric:info-Docker01:id:glances-docker01href:https://glances-docker01.mydomain.comicon:docker.pngdescription:VM, swarm manager (HA)widget:type:glancesurl:http://192.168.1.41:61208version:4metric:info-Docker02:id:glances-docker02href:https://glances-docker02.mydomain.comicon:docker.pngdescription:VM, swarm manager (HA)widget:type:glancesurl:http://192.168.1.42:61208version:4metric:info-docker03:id:glances-docker03href:https://glances-docker03.mydomain.comicon:docker.pngdescription:VM, swarm manager (HA)widget:type:glancesurl:http://192.168.1.43:61208version:4metric:info-Plumbing:-NPM database:icon:mariadb.pngdescription:MariaDB behind Nginx Proxy Managerwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_npm-database/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-WordPress database:icon:mariadb.pngdescription:MariaDB behind wordpress2025widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_wordpress-database/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms
with pageSize=1 gatus returns only the newest result, so results.0 is the latest check. every
gatus tile's url ends this way, because on a longer page gatus lists results oldest first.
lines 293-867: 25 more tiles, OAuth2 Proxy to Open WebUI Redis
-OAuth2 Proxy:icon:oauth2-proxy.pngdescription:Auth in front of the proxied serviceswidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_oauth2-proxy/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Cloudflare DDNS:icon:cloudflare.pngdescription:Keeps the external A record currentwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_cloudflare-ddns/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-ACME (Synology):icon:lets-encrypt.pngdescription:Renews the syn02 certificatewidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-synology/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-ACME (ASRock BMC):icon:lets-encrypt.pngdescription:Renews the BMC certificatewidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-asrock-bmc/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-ACME (Traefik):icon:lets-encrypt.pngdescription:Traefik's *.mydomain.com certificatewidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-traefik/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Docker Autolabel:icon:docker.pngdescription:Applies node labels from servicelist.txtwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-autolabel/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Docker Proxy (swarm):icon:docker.pngdescription:Read-only Docker API, overlay onlywidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-proxy-swarm/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Docker Proxy (truenas1):icon:docker.pngdescription:Read-only Docker API, LAN exposedwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-proxy-truenas1/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Config sync (gatus):icon:git.pngdescription:Delivers gatus's checks from gitwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_config-sync-gatus/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Config sync (homepage):icon:git.pngdescription:Delivers the dashboard's config from gitwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_config-sync-homepage/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (Docker01):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker01/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (Docker02):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker02/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (docker03):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker03/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (truenas1):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-truenas1/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (syn02):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-syn02/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Dozzle agent (pi-zwave01):icon:dozzle.pngdescription:Feeds the Dozzle hubwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-pi-zwave01/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (Docker01):icon:portainer.pngdescription:Portainer's edge into Docker01widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker01/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (Docker02):icon:portainer.pngdescription:Portainer's edge into Docker02widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker02/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (docker03):icon:portainer.pngdescription:Portainer's edge into docker03widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker03/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (truenas1):icon:portainer.pngdescription:Portainer's edge into truenas1widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-truenas1/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (syn02):icon:portainer.pngdescription:Portainer's edge into syn02widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-syn02/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Portainer agent (pi-zwave01):icon:portainer.pngdescription:Portainer's edge into pi-zwave01widget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-pi-zwave01/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-FlareSolverr:icon:flaresolverr.pngdescription:Solves indexer challenges for prowlarrwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_flaresolverr/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Ollama:icon:ollama.pngdescription:Local model serving, API onlywidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_ollama/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms-Open WebUI Redis:icon:redis.pngdescription:Session store for Open WebUIwidget:type:customapiurl:http://192.168.1.45:8085/api/v1/endpoints/plumbing_open-webui-redis/statuses?page=1&pageSize=1refreshInterval:60000mappings:-field:results.0.successlabel:Gatusformat:textremap:-value:trueto:UP-value:falseto:DOWN-any:trueto:"?"-field:results.0.durationlabel:Responseformat:floatscale:"1/1000000"suffix:ms
config/widgets.yaml: the header strip, 16 lines, 1 note
homepage has no setting for where its config lives, so it can't read a git checkout the way gatus does. a git-sync sidecar in the same stack polls deploy/swarm/homepage and runs a small script after each sync that copies config/*.yaml and custom.css into homepage's config volume. homepage notices and reloads.
the script reconciles: a file removed from git disappears from the volume too
it refuses to run against an empty checkout, so a bad ref can't wipe the dashboard
it writes each file to a temporary name and renames it, so homepage never reads half a file
it never touches the key files, which live in the same volume
sync-config.sh: run by config-sync after each sync, 42 lines
#!/bin/shset-eu
src="./stacks/swarm/homepage/config"dst="/app/config"[-d"$src"]||{echo"config-sync: $src not found under $(pwd)">&2;exit1;}[-d"$dst"]||{echo"config-sync: $dst is not mounted">&2;exit1;}if!ls"$src"/*.yaml>/dev/null2>&1;thenecho"config-sync: no *.yaml under $src, refusing to reconcile">&2exit1fidelivered=0forfin"$src"/*.yaml;doname="${f##*/}"tmp="$dst/.${name}.tmp"cp"$f""$tmp"chmod0644"$tmp"mv"$tmp""$dst/$name"delivered=$((delivered+1))doneremoved=0forgin"$dst"/*.yaml;do[-f"$g"]||continuename="${g##*/}"[-f"$src/$name"]&&continuerm-f"$g"removed=$((removed+1))echo"config-sync: removed $name, no longer in git"doneif[-f"$src/custom.css"];thencp"$src/custom.css""$dst/.custom.css.tmp"chmod0644"$dst/.custom.css.tmp"mv"$dst/.custom.css.tmp""$dst/custom.css"delivered=$((delivered+1))fiecho"config-sync: delivered $delivered, removed $removed, at ${GITSYNC_HASH:-unknown}"
the sidecar is the one on config from git, with two additions. GITSYNC_EXECHOOK_COMMAND runs sync-config.sh, mounted from a swarm config, after each sync. the sidecar also mounts homepage's config volume, which the script writes to.
sparse-checkout: the paths config-sync checks out, 9 lines
every group names its tab. a group without one shows on every tab, and so does a group discovered from a label but missing from layout
apps groups are columns, side by side. infrastructure and plumbing groups are rows of four, glances rows of three
maxGroupColumns: 6 lets up to six groups sit side by side on a wide screen. homepage's page stops growing at 1792px, and six columns in that width would only mean narrower ones, so custom.css lifts that cap to 2560px on wider screens
labels are readable by anything that can read the service, so no key or password goes in one. the label names a placeholder, and homepage fills it from a file:
the file can also be a docker secret: HOMEPAGE_FILE_UNIFI_KEY=/run/secrets/unifi_apikey. the other keys are read-only where the app allows it: a proxmox auditor token, a read-only truenas api key. the portainer key belongs to a helpdesk-role user and has read access.
check the upstream's lockout rules before testing a password. qbittorrent bans an address for an hour after five failed logins, and homepage's address is its node's
the adguards are macvlan containers, which a container on the same host can't reach. a shim on every swarm node covers their two IPv4 addresses only, so the adguard widgets use those addresses and work wherever homepage runs. the shim is in troubleshooting
to test a widget, ask homepage's own proxy from inside its container. use the widget's endpoint name, not the upstream's api path:
there are no badges because homepage can only take one from docker, kubernetes, proxmox, a ping or its own http check, not from gatus. and a mix of running 1/1, running and healthy side by side said different things
homepage only links a tile's icon and name, and the glances widget covers the rest of the tile. custom.css stretches the name's link over the whole tile on tiles with id: glances-<host>:
config/custom.css: the glances tile link and the wide-screen width, 16 lines
homepage serves a prebuilt page, and after a restart that page has the image's placeholder settings: no tabs, no layout. a browser only asks homepage to rebuild it when the config has changed since it last looked, so after a restart it can keep showing the placeholder. to rebuild it, run this from inside the container, then reload the page: