My nix-darwin and NixOS config
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add Umami web analytics service

- Add umami.nix module with Podman container + Caddy reverse proxy
- Uses SQLite for simplicity, cookie-free GDPR-compliant tracking
- Add options.nix service toggle and config (hostname, port, dataDir)
- Add to Cloudflare tunnel ingress for public access at analytics.ewancroft.uk
- Add tracking script placeholders to website and docsite (to be updated after first login)

๐Ÿ‘พ Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+176 -1
+1 -1
.sops.yaml
··· 27 27 creation_rules: 28 28 # โ”€โ”€ Secrets available on all machines โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 29 29 # โ”€โ”€ Server-only secrets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 30 - - path_regex: secrets/(pds\.env|cloudflare\.token|cloudflare-acme\.env|cloudflare-acme-croft-click\.env|cf-tunnel\.json|forgejo\.env|nextcloud-admin-pass|nextcloud-smtp-pass|vaultwarden\.env|sharkey\.env|meilisearch-master-key)$ 30 + - path_regex: secrets/(pds\.env|cloudflare\.token|cloudflare-acme\.env|cloudflare-acme-croft-click\.env|cf-tunnel\.json|forgejo\.env|nextcloud-admin-pass|nextcloud-smtp-pass|vaultwarden\.env|sharkey\.env|meilisearch-master-key|umami\.env)$ 31 31 key_groups: 32 32 - age: 33 33 - *ewan
+2
hosts/server/default.nix
··· 22 22 ../../modules/server/services/utils/vaultwarden.nix 23 23 ../../modules/server/services/utils/timemachine.nix 24 24 ../../modules/server/services/fediverse/sharkey.nix 25 + ../../modules/server/services/analytics/umami.nix 25 26 ../../modules/profiles/server-hardened.nix 26 27 ]; 27 28 ··· 40 41 myConfig.services.vaultwarden.enable = true; # Tailnet-only โ€” password manager, never public 41 42 myConfig.services.timemachine.enable = true; # Tailnet-only โ€” Time Machine AFP target 42 43 myConfig.services.sharkey.enable = false; 44 + myConfig.services.umami.enable = true; 43 45 44 46 # Ignore laptop lid โ€” treat as headless, never suspend. 45 47 services.logind.settings.Login = {
+36
modules/options.nix
··· 445 445 description = "Enable Time Machine backup target via Samba vfs_fruit (SMB, Tailscale only)."; 446 446 }; 447 447 }; 448 + umami = { 449 + enable = mkOption { 450 + type = bool; 451 + default = false; 452 + description = "Enable Umami web analytics."; 453 + }; 454 + public = mkOption { 455 + type = bool; 456 + default = true; 457 + description = "Expose Umami via Cloudflare Tunnel (public). If false, Tailscale only."; 458 + }; 459 + }; 448 460 }; 449 461 450 462 # โ”€โ”€ Nextcloud โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ··· 680 692 type = str; 681 693 default = "/srv/sharkey/media"; 682 694 description = "Directory for Sharkey uploaded media (avatars, attachments, etc.)"; 695 + }; 696 + }; 697 + 698 + # โ”€โ”€ Umami โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 699 + umami = { 700 + hostname = mkOption { 701 + type = str; 702 + default = "analytics.ewancroft.uk"; 703 + description = "Public hostname for Umami analytics dashboard."; 704 + }; 705 + port = mkOption { 706 + type = int; 707 + default = 3010; 708 + description = "Internal Umami HTTP port."; 709 + }; 710 + caddyPort = mkOption { 711 + type = int; 712 + default = 3011; 713 + description = "Caddy virtual host port โ€” used by the Cloudflare tunnel."; 714 + }; 715 + dataDir = mkOption { 716 + type = str; 717 + default = "/srv/umami"; 718 + description = "Directory for Umami database files."; 683 719 }; 684 720 }; 685 721
+3
modules/server/infra/network/cloudflare-tunnel.nix
··· 65 65 } 66 66 // lib.optionalAttrs cfg.services.sharkey.enable { 67 67 ${cfg.sharkey.hostname} = "http://127.0.0.1:${toString cfg.sharkey.caddyPort}"; 68 + } 69 + // lib.optionalAttrs cfg.services.umami.enable { 70 + ${cfg.umami.hostname} = "http://127.0.0.1:${toString cfg.umami.caddyPort}"; 68 71 }; 69 72 in 70 73 lib.mkIf cfg.services.cloudflare.enable {
+125
modules/server/services/analytics/umami.nix
··· 1 + ############################################################################## 2 + # Umami web analytics โ€” NixOS module. 3 + # 4 + # Architecture: 5 + # Umami (127.0.0.1:cfg.umami.port) 6 + # โ†‘ reverse proxy 7 + # Caddy (http://${hostname}:${caddyPort} โ€” Cloudflare tunnel) 8 + # 9 + # GDPR compliance: 10 + # Cookie-free by design. No consent banner required. Uses hashed IP + 11 + # User-Agent for daily unique visitor counting, then discards the hash. 12 + # 13 + # Storage: 14 + # SQLite database at /srv/umami/umami.db (on the /srv volume). 15 + # 16 + # Secrets (sops-encrypted, age backend): 17 + # secrets/umami.env โ€” KEY=value env file, must contain: 18 + # APP_SECRET # Random string for session signing (openssl rand -base64 32) 19 + ############################################################################## 20 + { 21 + config, 22 + lib, 23 + pkgs, 24 + ... 25 + }: 26 + let 27 + cfg = config.myConfig; 28 + umami = cfg.umami; 29 + umamiPort = toString umami.port; 30 + caddyPort = toString umami.caddyPort; 31 + dataDir = umami.dataDir; 32 + isPublic = cfg.services.umami.public or true; 33 + in 34 + lib.mkIf cfg.services.umami.enable { 35 + 36 + # โ”€โ”€ Storage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 37 + systemd.tmpfiles.rules = [ 38 + "d ${dataDir} 0750 umami umami -" 39 + ]; 40 + 41 + # โ”€โ”€ User/Group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 42 + users.users.umami = { 43 + isSystemUser = true; 44 + group = "umami"; 45 + home = dataDir; 46 + }; 47 + users.groups.umami = { }; 48 + 49 + # โ”€โ”€ Secrets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 50 + sops.secrets."umami.env" = { 51 + sopsFile = ../../../../secrets/umami.env; 52 + format = "dotenv"; 53 + owner = "umami"; 54 + group = "umami"; 55 + mode = "0400"; 56 + }; 57 + 58 + # โ”€โ”€ Umami service (native) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 59 + systemd.services.umami = { 60 + description = "Umami Web Analytics"; 61 + wantedBy = [ "multi-user.target" ]; 62 + after = [ 63 + "network.target" 64 + "srv.mount" 65 + ]; 66 + wants = [ "srv.mount" ]; 67 + 68 + environment = { 69 + DATABASE_URL = "file:${dataDir}/umami.db"; 70 + HOSTNAME = "127.0.0.1"; 71 + PORT = umamiPort; 72 + DISABLE_TELEMETRY = "1"; 73 + REMOVE_TRAILING_SLASH = "1"; 74 + }; 75 + 76 + serviceConfig = { 77 + Type = "simple"; 78 + User = "umami"; 79 + Group = "umami"; 80 + WorkingDirectory = dataDir; 81 + EnvironmentFile = config.sops.secrets."umami.env".path; 82 + ExecStart = "${lib.getExe pkgs.umami}"; 83 + Restart = "always"; 84 + RestartSec = cfg.server.servicePolicy.restartSec; 85 + }; 86 + 87 + unitConfig = { 88 + StartLimitIntervalSec = cfg.server.servicePolicy.startLimitIntervalSec; 89 + StartLimitBurst = cfg.server.servicePolicy.startLimitBurst; 90 + }; 91 + }; 92 + 93 + # โ”€โ”€ Caddy reverse proxy (Cloudflare tunnel โ€” public) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 94 + services.caddy.virtualHosts."http://${umami.hostname}:${caddyPort}" = lib.mkIf isPublic { 95 + extraConfig = '' 96 + encode zstd gzip 97 + reverse_proxy http://127.0.0.1:${umamiPort} { 98 + header_up X-Real-IP {remote_host} 99 + header_up X-Forwarded-Proto {scheme} 100 + header_up X-Forwarded-Host {host} 101 + } 102 + ''; 103 + }; 104 + 105 + # โ”€โ”€ Tailscale reverse proxy (private access) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 106 + services.caddy.virtualHosts."http://${umami.hostname}" = lib.mkIf (!isPublic) { 107 + extraConfig = '' 108 + bind ${cfg.server.tailscaleIP} 109 + redir https://${umami.hostname}{uri} permanent 110 + ''; 111 + }; 112 + 113 + services.caddy.virtualHosts."https://${umami.hostname}" = lib.mkIf (!isPublic) { 114 + extraConfig = '' 115 + bind ${cfg.server.tailscaleIP} 116 + tls ${cfg.server.acmeCertDir}/fullchain.pem ${cfg.server.acmeCertDir}/key.pem 117 + encode zstd gzip 118 + reverse_proxy http://127.0.0.1:${umamiPort} { 119 + header_up X-Real-IP {remote_host} 120 + header_up X-Forwarded-Proto {scheme} 121 + header_up X-Forwarded-Host {host} 122 + } 123 + ''; 124 + }; 125 + }
+9
secrets/umami.env
··· 1 + APP_SECRET=ENC[AES256_GCM,data:vOlXiQyFOAEHKqCIKLYt7KwwQIRn0J5y1u4K0ahU2L7uP+Q/A/8iPH+1fr4=,iv:MclosmIFhRfuZjlbraMnXMcKvFU76fisoM7QVdg5fA0=,tag:9EDYxUNFmpV81AUHOxaoHQ==,type:str] 2 + sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2amxacTRjNkhwbTRuRzZC\nRG5CREhYNmhyUWc4Y3YvN3ZmelVHWU9SZldBCnRnUndyOTZzL2w4OHYwUTlFcGZ6\nV0w2R05OdHRPckl4aU9wWG45K1JWWDgKLS0tIGszOHh1VktPQWtKQi9qUjRLQjc1\naGJsSXd2Mm9jallOSlBmRzlzRnlVRGsKC55+KWy5JrOW84lJMHndeKMwfqNBNFxH\nMmzBfRipvBM43zWgR0EEq8YRH+lE6y9f5hnxs6QfCzt0qqrs7gZQVQ==\n-----END AGE ENCRYPTED FILE-----\n 3 + sops_age__list_0__map_recipient=age17ulnk7akn9zfwtc87vsexrr809xj6gkkcp2rkez6xtzyrqclpshqfew5wy 4 + sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWbnh3ZnpIeUNaMUVmZnJD\nSXBnb1MrOTdsSW0xbzU3ajVDU0sxUm4vYzJrCkFQV2dsZlhLQ2pSV0FEWEpSU0pN\ncVRJKzZCSUNmT01nNUZOYzlUWVhiT3MKLS0tIHZJL3E5TXdQcElROEdsTXFJTG92\nb2VjK0RzajBwUEhieWk4RUNQQWhhVUUKNGKkOKlAzPYcb4gKMXrxiEKMS+fqxZeF\nj5ZeFI7sSSuS02+aCzIL/hFo9ah0vBvnL1/lX13s+6sjXQf5RobicQ==\n-----END AGE ENCRYPTED FILE-----\n 5 + sops_age__list_1__map_recipient=age1xvny7h8cahajamj4lz9cew5w0dqlge0yy6tys7szj42grcrl95jqsrutsu 6 + sops_lastmodified=2026-04-11T16:22:07Z 7 + sops_mac=ENC[AES256_GCM,data:ZqQilKGJJIEZiZ7KneNc10zOU4O0A3gNRh6IPG9aEbbzmRB0/PinFVZto5cfku3ixKLvjwwKchK62GSlb4HhYBOUA4/geEMcioh5oSC88D/0x4gBawqq48MBmmosIAZcf9ZQiL/aOket6/y9IZ4QDqHj1bKIQ5Niv/guYJne/gE=,iv:UWbwu1EX2KORAXOdFBFd0Yj2WVPE1QFfVi0RCRulrh8=,tag:ijhhzZhRnH+YGWjkBVyegQ==,type:str] 8 + sops_unencrypted_suffix=_unencrypted 9 + sops_version=3.12.2