My nix-darwin and NixOS config
3
fork

Configure Feed

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

fix: use native nixpkgs Umami module instead of custom

- Remove custom umami.nix module (nixpkgs has a native one)
- Use services.umami with static user for PostgreSQL peer auth
- Override DynamicUser to use static 'umami' system user
- Update secrets format (APP_SECRET_FILE with systemd LoadCredential)
- Add Caddy reverse proxy for Cloudflare tunnel on port 3011

+67 -194
+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|umami\.env)$ 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-app-secret)$ 31 31 key_groups: 32 32 - age: 33 33 - *ewan
+46 -2
hosts/server/default.nix
··· 1 1 { 2 2 config, 3 + lib, 3 4 ... 4 5 }: 5 6 let ··· 22 23 ../../modules/server/services/utils/vaultwarden.nix 23 24 ../../modules/server/services/utils/timemachine.nix 24 25 ../../modules/server/services/fediverse/sharkey.nix 25 - ../../modules/server/services/analytics/umami.nix 26 26 ../../modules/profiles/server-hardened.nix 27 27 ]; 28 28 ··· 41 41 myConfig.services.vaultwarden.enable = true; # Tailnet-only — password manager, never public 42 42 myConfig.services.timemachine.enable = true; # Tailnet-only — Time Machine AFP target 43 43 myConfig.services.sharkey.enable = false; 44 - myConfig.services.umami.enable = true; 44 + 45 + # ── Umami (native nixpkgs module) ─────────────────────────────────────────── 46 + sops.secrets."umami-app-secret" = { 47 + sopsFile = ../../secrets/umami-app-secret; 48 + owner = "umami"; 49 + group = "umami"; 50 + mode = "0400"; 51 + }; 52 + 53 + # Create system user for PostgreSQL peer auth (native module uses DynamicUser) 54 + users.users.umami = { 55 + isSystemUser = true; 56 + group = "umami"; 57 + }; 58 + users.groups.umami = { }; 59 + 60 + services.umami = { 61 + enable = true; 62 + settings = { 63 + APP_SECRET_FILE = "/run/secrets/umami-app-secret"; 64 + HOSTNAME = "127.0.0.1"; 65 + PORT = 3010; 66 + DISABLE_TELEMETRY = true; 67 + }; 68 + }; 69 + 70 + # Override DynamicUser with static user for PostgreSQL peer auth 71 + systemd.services.umami.serviceConfig = { 72 + DynamicUser = lib.mkForce false; 73 + User = "umami"; 74 + Group = "umami"; 75 + LoadCredential = "appSecret:/run/secrets/umami-app-secret"; 76 + }; 77 + 78 + # Caddy reverse proxy for Cloudflare tunnel 79 + services.caddy.virtualHosts."http://analytics.ewancroft.uk:3011" = { 80 + extraConfig = '' 81 + encode zstd gzip 82 + reverse_proxy http://127.0.0.1:3010 { 83 + header_up X-Real-IP {remote_host} 84 + header_up X-Forwarded-Proto {scheme} 85 + header_up X-Forwarded-Host {host} 86 + } 87 + ''; 88 + }; 45 89 46 90 # Ignore laptop lid — treat as headless, never suspend. 47 91 services.logind.settings.Login = {
-37
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 - }; 460 448 }; 461 449 462 450 # ── Nextcloud ───────────────────────────────────────────────────────────── ··· 694 682 description = "Directory for Sharkey uploaded media (avatars, attachments, etc.)"; 695 683 }; 696 684 }; 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."; 719 - }; 720 - }; 721 - 722 685 # ── Cloudflare ──────────────────────────────────────────────────────────── 723 686 cloudflare = { 724 687 tunnelId = mkOption {
+2 -2
modules/server/infra/network/cloudflare-tunnel.nix
··· 66 66 // lib.optionalAttrs cfg.services.sharkey.enable { 67 67 ${cfg.sharkey.hostname} = "http://127.0.0.1:${toString cfg.sharkey.caddyPort}"; 68 68 } 69 - // lib.optionalAttrs cfg.services.umami.enable { 70 - ${cfg.umami.hostname} = "http://127.0.0.1:${toString cfg.umami.caddyPort}"; 69 + // lib.optionalAttrs config.services.umami.enable { 70 + "analytics.ewancroft.uk" = "http://127.0.0.1:3011"; 71 71 }; 72 72 in 73 73 lib.mkIf cfg.services.cloudflare.enable {
-143
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 - # PostgreSQL database (local, peer auth via unix socket). 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 - # ── PostgreSQL database ─────────────────────────────────────────────────── 59 - services.postgresql = { 60 - enable = lib.mkDefault true; 61 - ensureDatabases = [ "umami" ]; 62 - ensureUsers = [ 63 - { 64 - name = "umami"; 65 - ensureDBOwnership = true; 66 - } 67 - ]; 68 - }; 69 - 70 - # ── Umami service (native) ──────────────────────────────────────────────── 71 - systemd.services.umami = { 72 - description = "Umami Web Analytics"; 73 - wantedBy = [ "multi-user.target" ]; 74 - after = [ 75 - "network.target" 76 - "postgresql.service" 77 - "srv.mount" 78 - ]; 79 - wants = [ 80 - "postgresql.service" 81 - "srv.mount" 82 - ]; 83 - requires = [ "postgresql.service" ]; 84 - 85 - environment = { 86 - # PostgreSQL via unix socket (peer auth, no password needed) 87 - DATABASE_URL = "postgresql:///umami?host=/run/postgresql"; 88 - HOSTNAME = "127.0.0.1"; 89 - PORT = umamiPort; 90 - DISABLE_TELEMETRY = "1"; 91 - REMOVE_TRAILING_SLASH = "1"; 92 - }; 93 - 94 - serviceConfig = { 95 - Type = "simple"; 96 - User = "umami"; 97 - Group = "umami"; 98 - WorkingDirectory = dataDir; 99 - EnvironmentFile = config.sops.secrets."umami.env".path; 100 - ExecStart = "${lib.getExe pkgs.umami}"; 101 - Restart = "always"; 102 - RestartSec = cfg.server.servicePolicy.restartSec; 103 - }; 104 - 105 - unitConfig = { 106 - StartLimitIntervalSec = cfg.server.servicePolicy.startLimitIntervalSec; 107 - StartLimitBurst = cfg.server.servicePolicy.startLimitBurst; 108 - }; 109 - }; 110 - 111 - # ── Caddy reverse proxy (Cloudflare tunnel — public) ─────────────────────── 112 - services.caddy.virtualHosts."http://${umami.hostname}:${caddyPort}" = lib.mkIf isPublic { 113 - extraConfig = '' 114 - encode zstd gzip 115 - reverse_proxy http://127.0.0.1:${umamiPort} { 116 - header_up X-Real-IP {remote_host} 117 - header_up X-Forwarded-Proto {scheme} 118 - header_up X-Forwarded-Host {host} 119 - } 120 - ''; 121 - }; 122 - 123 - # ── Tailscale reverse proxy (private access) ────────────────────────────── 124 - services.caddy.virtualHosts."http://${umami.hostname}" = lib.mkIf (!isPublic) { 125 - extraConfig = '' 126 - bind ${cfg.server.tailscaleIP} 127 - redir https://${umami.hostname}{uri} permanent 128 - ''; 129 - }; 130 - 131 - services.caddy.virtualHosts."https://${umami.hostname}" = lib.mkIf (!isPublic) { 132 - extraConfig = '' 133 - bind ${cfg.server.tailscaleIP} 134 - tls ${cfg.server.acmeCertDir}/fullchain.pem ${cfg.server.acmeCertDir}/key.pem 135 - encode zstd gzip 136 - reverse_proxy http://127.0.0.1:${umamiPort} { 137 - header_up X-Real-IP {remote_host} 138 - header_up X-Forwarded-Proto {scheme} 139 - header_up X-Forwarded-Host {host} 140 - } 141 - ''; 142 - }; 143 - }
+18
secrets/umami-app-secret
··· 1 + { 2 + "data": "ENC[AES256_GCM,data:x+yQRjOaoCQKn1guSamJqe3P35VYDx0YamDt1MbjYK3DX46aZbpva3rTAnUK,iv:laXzkDi2lyKpzcPVdC4U8O3WlMFoh5t0ImdcRMBYego=,tag:F5TWkjcOaCy+0BQl2Ovu4g==,type:str]", 3 + "sops": { 4 + "age": [ 5 + { 6 + "recipient": "age17ulnk7akn9zfwtc87vsexrr809xj6gkkcp2rkez6xtzyrqclpshqfew5wy", 7 + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvZGdzU0MvenVBb2FLUDBu\nQjhhQ3FqN2hjY25adGxnOHQxUzVTOEVzZlVnClhhYWcxTktHeUh6WXpzY0lZalRG\nR1VKc3UzYVUwR3djbTFwYnZyeEF3WUUKLS0tIFIrcmwzYzY5WEZLaXFIb0RMOTla\nNXFHc0hFNExqZVNEalkyeUxZYlJoTlUKoyZQekYyVA7nd4/cYh/wmcCmq2wG0lF4\nktQ93ebNNIghYpXp4rUPKz6c1kuiXhCqxJIiU8yj8Ia8Jb/pOCzRlQ==\n-----END AGE ENCRYPTED FILE-----\n" 8 + }, 9 + { 10 + "recipient": "age1xvny7h8cahajamj4lz9cew5w0dqlge0yy6tys7szj42grcrl95jqsrutsu", 11 + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3c2pzdE50RHl6U09yY2ZH\nQjlLbTRUbjNMdXJMUWt2TXFyNmxtbVM3a3pvClplRk9tenltMVQ0ZTdCWHNKRzFl\ndjFrOUNrTjloQllzYlVRazRGa3pNazgKLS0tIDh0T2FQL0FzSFVtZU1XaVkrV2li\nT3FrLzgzOFI5MDRyaCtpbGwxb21QWVkKVZ0s3Ed15vvpPV1IGoBR2OttIISZ0uXU\nQVJLciHJVTkii33WgXc+3pPURNCFHBxOC8bVl+2tmDxe/WhsBDO+XQ==\n-----END AGE ENCRYPTED FILE-----\n" 12 + } 13 + ], 14 + "lastmodified": "2026-04-11T16:52:18Z", 15 + "mac": "ENC[AES256_GCM,data:GYGdnjaWv+FdcmaXwvxF2nhEHngFTY26YVpKZVvOa0Kn9rHWGCqZtNGGWDsfBmawsz7m8SLq/s8HD3ubinCk+0CTnl13qBpSJQ+kYG0B0zzU98DHE8N64lTo9O01/uwqVn+UPrQ/8g7cbzhACksSjrXMeHYO49u3GueRrSoM4KY=,iv:o2AKF1s0X1o85LbpeSUmzuU1bP45zq/McytlIJogCyE=,tag:AQRYI8MX+x9sbWLglkNm4w==,type:str]", 16 + "version": "3.12.2" 17 + } 18 + }
-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