Skip to content

homepage dashboard

homepage runs on the swarm on port 3000. it's one page with three tabs: apps, infrastructure and plumbing.

compose.yml, 134 lines, 5 notes

each in the code opens a note on that line. download compose.yml

services:
  config-sync:
    image: registry.k8s.io/git-sync/git-sync:v4.7.1
    user: "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=:8080
    entrypoint:
      - /bin/sh
      - -c
      - |
        umask 077
        printf '%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-sync
    volumes:
      - type: bind  # (1)!
        source: /usr/share/zoneinfo
        target: /usr/share/zoneinfo
        read_only: true
      - type: volume
        source: gitrepo_v2
        target: /git
        volume:
          nocopy: true
      - config:/app/config
    configs:
      - source: homepage_known_hosts_v2
        target: /known_hosts
      - source: homepage_ssh_config_v2
        target: /ssh_config
      - source: homepage_sparse_checkout_v1
        target: /sparse-checkout
      - source: homepage_sync_config_v2
        target: /sync-config.sh
        mode: 0555
    secrets:
      - gitsync_ssh_key_v1
    networks:
      discovery:
        aliases:
          - homepage-git-sync
    deploy:
      mode: replicated
      replicas: 1

  homepage:
    image: ghcr.io/gethomepage/homepage:v2.4.0@sha256:643bd0be730d40f69d58028a55d1a896739333e8815786df42bc97f109ecbe61
    restart: unless-stopped
    environment:  # (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_password
    extra_hosts:
      - "proxmox.mydomain.com:192.168.1.45"
    ports:
      - 3000:3000
    volumes:
      - type: bind
        source: /usr/share/zoneinfo
        target: /usr/share/zoneinfo
        read_only: true
      - config:/app/config
    secrets:
      - unifi_apikey
    networks:
      - discovery
      - traefik-api
    deploy:
      mode: replicated
      replicas: 1

configs:
  homepage_known_hosts_v2:
    file: ./known_hosts
  homepage_ssh_config_v2:
    file: ./ssh_config
  homepage_sparse_checkout_v1:
    file: ./sparse-checkout
  homepage_sync_config_v2:  # (3)!
    file: ./sync-config.sh

secrets:
  unifi_apikey:
    external: true

  gitsync_ssh_key_v1:
    external: true

volumes:  # (4)!
  config:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/homepage_config"
      o: bind
  gitrepo_v2:  # (5)!
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/homepage_git_v2"
      o: bind

networks:
  discovery:
    external: true
  traefik-api:
    external: true
  1. 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.
  2. HOMEPAGE_ALLOWED_HOSTS lists every address and name homepage is reached by, because homepage refuses a request whose host it does not recognise.
  3. 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.
  4. 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.
  5. 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.

before you deploy

  1. create both folders on the cephfs mount, owned by uid 1000, which homepage and config-sync run as:

    sudo mkdir -p /mnt/docker-cephFS/homepage_config/secrets /mnt/docker-cephFS/homepage_git_v2
    sudo chown -R 1000:1000 /mnt/docker-cephFS/homepage_config /mnt/docker-cephFS/homepage_git_v2
    
    • a missing folder fails the task, because each volume binds its folder by path
  2. 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
  3. 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

state considerations

both volumes are named binds on cephfs, see stack conventions:

  • 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.

network considerations

  • 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

where the tiles come from

  • 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

    download docker.yaml

    1
    2
    3
    4
    5
    6
    7
    8
    swarm:
      host: dockerproxy
      port: 2375
      swarm: true
    
    truenas:
      host: 192.168.1.86
      port: 2375
    
  • 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

each in the code opens a note on that line. download services.yaml

lines 1-37: 4 tiles, Portainer (swarm) to Portainer (truenas1)
- Infrastructure:
    - Portainer (swarm):
        href: https://portainer.mydomain.com
        icon: portainer.png
        description: the three managers and every stack on them
        widget:
          type: portainer
          url: http://192.168.1.45:9000
          env: 13
          key: "{{HOMEPAGE_FILE_PORTAINER_KEY}}"
    - Portainer (syn02):
        href: https://portainer.mydomain.com
        icon: portainer.png
        description: Synology NAS
        widget:
          type: portainer
          url: http://192.168.1.45:9000
          env: 34
          key: "{{HOMEPAGE_FILE_PORTAINER_KEY}}"
    - Portainer (pi-zwave01):
        href: https://portainer.mydomain.com
        icon: portainer.png
        description: Raspberry Pi, Z-Wave and Thread
        widget:
          type: portainer
          url: http://192.168.1.45:9000
          env: 37
          key: "{{HOMEPAGE_FILE_PORTAINER_KEY}}"
    - Portainer (truenas1):
        href: https://portainer.mydomain.com
        icon: portainer.png
        description: NAS apps, frigate and arrstack
        widget:
          type: portainer
          url: http://192.168.1.45:9000
          env: 47
          key: "{{HOMEPAGE_FILE_PORTAINER_KEY}}"
    - TrueNAS:
        href: https://truenas1.mydomain.com
        icon: truenas.png
        description: NAS, apps and storage
        widget:
          type: truenas
          url: https://192.168.1.86
          version: 2  # (1)!
          key: "{{HOMEPAGE_FILE_TRUENAS_KEY}}"
          enablePools: true
  1. 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.com
        icon: unifi.png
        description: Network controller, gateway and APs
        widget:
          type: unifi
          url: https://192.168.1.1
          key: "{{HOMEPAGE_FILE_UNIFI_KEY}}"
    - Nginx Proxy Manager:
        href: https://npm.mydomain.com
        server: swarm
        container: npm_app
        icon: nginx-proxy-manager.png
        description: Reverse proxy and its certificates
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/swarm_nginx-proxy-manager/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Traefik:
        href: https://traefik.mydomain.com/
        server: swarm
        container: traefik_traefik
        icon: traefik.png
        description: Reverse proxy on 443, from services.yaml
        widget:
          type: traefik
          url: http://traefik_traefik:8080
    - Proxmox:
        href: https://proxmox.mydomain.com
        icon: proxmox.png
        description: Three-node cluster, VMs and LXC
        widget:
          type: proxmox
          url: https://proxmox.mydomain.com
          username: pve-auditor@pam!homepage
          password: "{{HOMEPAGE_FILE_PROXMOX_KEY}}"
    - pve1:
        href: https://pve1.mydomain.com:8006
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: proxmox
          url: https://proxmox.mydomain.com
          username: pve-auditor@pam!homepage
          password: "{{HOMEPAGE_FILE_PROXMOX_KEY}}"
          node: pve1
    - pve2:
        href: https://pve2.mydomain.com:8006
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: proxmox
          url: https://proxmox.mydomain.com
          username: pve-auditor@pam!homepage
          password: "{{HOMEPAGE_FILE_PROXMOX_KEY}}"
          node: pve2
    - pve3:
        href: https://pve3.mydomain.com:8006
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: proxmox
          url: https://proxmox.mydomain.com
          username: pve-auditor@pam!homepage
          password: "{{HOMEPAGE_FILE_PROXMOX_KEY}}"
          node: pve3

- Glances:
    - truenas1:
        id: glances-truenas1
        href: https://glances-truenas1.mydomain.com
        icon: truenas.png
        description: Physical, NAS, apps and storage
        widget:
          type: glances
          url: http://192.168.1.86:61208
          version: 4
          metric: info
    - syn02:
        id: glances-syn02
        href: https://glances-syn02.mydomain.com
        icon: synology.png
        description: Physical, Synology NAS
        widget:
          type: glances
          url: http://192.168.1.31:61208
          version: 4
          metric: info
    - pi-zwave01:
        id: glances-pi-zwave01
        href: https://glances-pi-zwave01.mydomain.com
        icon: raspberry-pi.png
        description: Physical, the Z-Wave, Zigbee and Thread radios
        widget:
          type: glances
          url: http://192.168.1.96:61208
          version: 4
          metric: info
    - pve1:
        id: glances-pve1
        href: https://glances-pve1.mydomain.com
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: glances
          url: http://192.168.1.81:61208
          version: 4
          metric: info
    - pve2:
        id: glances-pve2
        href: https://glances-pve2.mydomain.com
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: glances
          url: http://192.168.1.82:61208
          version: 4
          metric: info
    - pve3:
        id: glances-pve3
        href: https://glances-pve3.mydomain.com
        icon: proxmox.png
        description: Physical, Proxmox VE node
        widget:
          type: glances
          url: http://192.168.1.83:61208
          version: 4
          metric: info
    - Docker01:
        id: glances-docker01
        href: https://glances-docker01.mydomain.com
        icon: docker.png
        description: VM, swarm manager (HA)
        widget:
          type: glances
          url: http://192.168.1.41:61208
          version: 4
          metric: info
    - Docker02:
        id: glances-docker02
        href: https://glances-docker02.mydomain.com
        icon: docker.png
        description: VM, swarm manager (HA)
        widget:
          type: glances
          url: http://192.168.1.42:61208
          version: 4
          metric: info
    - docker03:
        id: glances-docker03
        href: https://glances-docker03.mydomain.com
        icon: docker.png
        description: VM, swarm manager (HA)
        widget:
          type: glances
          url: http://192.168.1.43:61208
          version: 4
          metric: info

- Plumbing:
    - NPM database:
        icon: mariadb.png
        description: MariaDB behind Nginx Proxy Manager
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_npm-database/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - WordPress database:
        icon: mariadb.png
        description: MariaDB behind wordpress2025
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_wordpress-database/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Mosquitto:
        icon: mosquitto.png
        description: MQTT broker, 1883
        widget:  # (1)!
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_mosquitto/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
  1. 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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
    - OAuth2 Proxy:
        icon: oauth2-proxy.png
        description: Auth in front of the proxied services
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_oauth2-proxy/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Cloudflare DDNS:
        icon: cloudflare.png
        description: Keeps the external A record current
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_cloudflare-ddns/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - ACME (Synology):
        icon: lets-encrypt.png
        description: Renews the syn02 certificate
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-synology/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - ACME (ASRock BMC):
        icon: lets-encrypt.png
        description: Renews the BMC certificate
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-asrock-bmc/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - ACME (Traefik):
        icon: lets-encrypt.png
        description: Traefik's *.mydomain.com certificate
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_acme-traefik/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Docker Autolabel:
        icon: docker.png
        description: Applies node labels from servicelist.txt
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-autolabel/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Docker Proxy (swarm):
        icon: docker.png
        description: Read-only Docker API, overlay only
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-proxy-swarm/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Docker Proxy (truenas1):
        icon: docker.png
        description: Read-only Docker API, LAN exposed
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_docker-proxy-truenas1/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Config sync (gatus):
        icon: git.png
        description: Delivers gatus's checks from git
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_config-sync-gatus/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Config sync (homepage):
        icon: git.png
        description: Delivers the dashboard's config from git
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_config-sync-homepage/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (Docker01):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker01/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (Docker02):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker02/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (docker03):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-docker03/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (truenas1):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-truenas1/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (syn02):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-syn02/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Dozzle agent (pi-zwave01):
        icon: dozzle.png
        description: Feeds the Dozzle hub
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_dozzle-agent-pi-zwave01/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (Docker01):
        icon: portainer.png
        description: Portainer's edge into Docker01
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker01/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (Docker02):
        icon: portainer.png
        description: Portainer's edge into Docker02
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker02/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (docker03):
        icon: portainer.png
        description: Portainer's edge into docker03
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-docker03/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (truenas1):
        icon: portainer.png
        description: Portainer's edge into truenas1
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-truenas1/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (syn02):
        icon: portainer.png
        description: Portainer's edge into syn02
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-syn02/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Portainer agent (pi-zwave01):
        icon: portainer.png
        description: Portainer's edge into pi-zwave01
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_portainer-agent-pi-zwave01/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - FlareSolverr:
        icon: flaresolverr.png
        description: Solves indexer challenges for prowlarr
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_flaresolverr/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Ollama:
        icon: ollama.png
        description: Local model serving, API only
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_ollama/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
    - Open WebUI Redis:
        icon: redis.png
        description: Session store for Open WebUI
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_open-webui-redis/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
config/widgets.yaml: the header strip, 16 lines, 1 note

each in the code opens a note on that line. download widgets.yaml

- glances:
    url: http://192.168.1.86:61208
    version: 4  # (1)!
    label: truenas1
    cpu: true
    mem: true
    cputemp: true
    uptime: true
    expanded: true

- datetime:
    text_size: xl
    format:
      dateStyle: short
      timeStyle: short
      hour12: false
  1. glances on truenas1 is version 4, which serves only /api/4, and the widget asks for /api/3 unless it is told the version.

config in git

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

download sync-config.sh

#!/bin/sh
set -eu

src="./stacks/swarm/homepage/config"
dst="/app/config"

[ -d "$src" ] || { echo "config-sync: $src not found under $(pwd)" >&2; exit 1; }
[ -d "$dst" ] || { echo "config-sync: $dst is not mounted" >&2; exit 1; }

if ! ls "$src"/*.yaml >/dev/null 2>&1; then
    echo "config-sync: no *.yaml under $src, refusing to reconcile" >&2
    exit 1
fi

delivered=0
for f in "$src"/*.yaml; do
    name="${f##*/}"
    tmp="$dst/.${name}.tmp"
    cp "$f" "$tmp"
    chmod 0644 "$tmp"
    mv "$tmp" "$dst/$name"
    delivered=$((delivered + 1))
done

removed=0
for g in "$dst"/*.yaml; do
    [ -f "$g" ] || continue
    name="${g##*/}"
    [ -f "$src/$name" ] && continue
    rm -f "$g"
    removed=$((removed + 1))
    echo "config-sync: removed $name, no longer in git"
done

if [ -f "$src/custom.css" ]; then
    cp "$src/custom.css" "$dst/.custom.css.tmp"
    chmod 0644 "$dst/.custom.css.tmp"
    mv "$dst/.custom.css.tmp" "$dst/custom.css"
    delivered=$((delivered + 1))
fi

echo "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

download sparse-checkout

1
2
3
4
5
6
7
8
9
/*
!/*/
/stacks/
!/stacks/*/
/stacks/swarm/
!/stacks/swarm/*/
/stacks/swarm/homepage/
!/stacks/swarm/homepage/*/
/stacks/swarm/homepage/config/
ssh_config: ssh's timeouts for config-sync, 4 lines

download ssh_config

1
2
3
4
Host github.com ssh.github.com
  ConnectTimeout 15
  ServerAliveInterval 10
  ServerAliveCountMax 3
known_hosts: github's published host keys, 6 lines

download known_hosts

1
2
3
4
5
6
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
[ssh.github.com]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
[ssh.github.com]:443 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
[ssh.github.com]:443 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=

tabs and layout

config/settings.yaml: title, columns, and the tab of every group, 41 lines

download settings.yaml

title: mydomain homelab
theme: dark
headerStyle: clean

maxGroupColumns: 6

layout:
  Arr:
    tab: Apps
    style: column
  Downloads:
    tab: Apps
    style: column
  Media:
    tab: Apps
    style: column
  AI:
    tab: Apps
    style: column
  Tools:
    tab: Apps
    style: column
  Storage:
    tab: Apps
    style: column
  Infrastructure:
    tab: Infrastructure
    style: row
    columns: 4
  Glances:
    tab: Infrastructure
    style: row
    columns: 3
  Monitoring:
    tab: Infrastructure
    style: row
    columns: 4
  Plumbing:
    tab: Plumbing
    style: row
    columns: 4
config/settings.yaml
layout:
  Arr:
    tab: Apps
    style: column
  Infrastructure:
    tab: Infrastructure
    style: row
    columns: 4
  • 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

widgets and their keys

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:

arrstack/compose.yml
    labels:
      - homepage.widget.type=qbittorrent
      - homepage.widget.url=http://192.168.1.86:8080
      - homepage.widget.username=admin
      - homepage.widget.password={{HOMEPAGE_FILE_QBITTORRENT_PASSWORD}}
compose.yml
    environment:
      - HOMEPAGE_FILE_QBITTORRENT_PASSWORD=/app/config/secrets/qbittorrent_password

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:

    wget -q -O - 'http://127.0.0.1:3000/api/services/proxy?group=Downloads&service=qBittorrent&endpoint=torrents'
    

plumbing tiles show gatus

the plumbing tab holds things with no web UI. each tile shows its gatus check through a customapi widget, and none has a container status badge:

config/services.yaml
    - Mosquitto:
        icon: mosquitto.png
        description: MQTT broker, 1883
        widget:
          type: customapi
          url: http://192.168.1.45:8085/api/v1/endpoints/plumbing_mosquitto/statuses?page=1&pageSize=1
          refreshInterval: 60000
          mappings:
            - field: results.0.success
              label: Gatus
              format: text
              remap:
                - value: true
                  to: UP
                - value: false
                  to: DOWN
                - any: true
                  to: "?"
            - field: results.0.duration
              label: Response
              format: float
              scale: "1/1000000"
              suffix: ms
  • 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

glances tiles open glances

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

download custom.css

li.service[id^="glances-"] .service-card {
  position: relative;
}

li.service[id^="glances-"] a.service-title-text::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 30;
}

@media (min-width: 112rem) {
  .container {
    max-width: 160rem;
  }
}

the stale page after a restart

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:

wget -q -O - http://127.0.0.1:3000/api/revalidate

checking it

the page homepage serves should carry all three tabs:

curl -s http://192.168.1.45:3000/ | grep -o '"tab":"[^"]*"' | sort -u
"tab":"Apps"
"tab":"Infrastructure"
"tab":"Plumbing"

no output means it is serving the placeholder page. to fix it, see the stale page after a restart.