flake-update-20260505
1{
2 libx,
3 globals,
4 lib,
5 pkgs,
6 config,
7 ...
8}:
9
10let
11 # Service defaults for media/homelab services
12 serviceDefaults = libx.mkServiceDefaults { };
13
14 # Samba shares configuration (data-driven approach)
15 # Samba shares on /neo
16 neoSambaShares = {
17 backup = { };
18 downloads = { };
19 pictures = { };
20 videos = { };
21 };
22 # Samba shares on /zion
23 zionSambaShares = {
24 audiobooks = {
25 readOnly = true;
26 };
27 ebooks = { };
28 documents = { };
29 music = {
30 readOnly = true;
31 };
32 };
33
34 # Exportarr services configuration (data-driven approach)
35 exportarrServices = {
36 sonarr = {
37 port = 9707;
38 servicePort = 8989;
39 };
40 radarr = {
41 port = 9708;
42 servicePort = 7878;
43 };
44 lidarr = {
45 port = 9709;
46 servicePort = 8686;
47 };
48 prowlarr = {
49 port = 9710;
50 servicePort = 9696;
51 };
52 bazarr = {
53 port = 9712;
54 servicePort = 6767;
55 };
56 };
57
58 # Common rsync configuration for aion backups (reverse sync after migration)
59 aionBackupDefaults = {
60 source = {
61 host = "aion.sbr.pm";
62 user = "vincent";
63 };
64 destination = "/zion";
65 delete = true; # Mirror mode: delete files in destination that don't exist in source
66 user = "vincent";
67 group = "users";
68 rsyncArgs = [
69 "--exclude=.Trash-*"
70 "--exclude=lost+found"
71 ];
72 sshArgs = [
73 "-o StrictHostKeyChecking=accept-new"
74 ];
75 };
76in
77{
78 imports = [
79 ../common/services/samba.nix
80
81 ../common/services/prometheus-exporters-postgres.nix
82 ../../modules/jellyfin-auto-collections
83 ../../modules/jellyfin-favorites-sync
84 ];
85
86 # Age secrets: gandi.env + webdav + jellyfin + ollama + generated exportarr secrets
87 age.secrets = {
88 "gandi.env" = {
89 file = ../../secrets/rhea/gandi.env.age;
90 mode = "400";
91 owner = "traefik";
92 group = "traefik";
93 };
94 "webdav-password" = {
95 file = ../../secrets/rhea/webdav-password.age;
96 mode = "400";
97 };
98 "jellyfin-auto-collections-api-key" = {
99 file = ../../secrets/rhea/jellyfin-auto-collections-api-key.age;
100 mode = "400";
101 owner = "jellyfin-auto-collections";
102 };
103 "jellyfin-auto-collections-jellyseerr-password" = {
104 file = ../../secrets/rhea/jellyfin-auto-collections-jellyseerr-password.age;
105 mode = "400";
106 owner = "jellyfin-auto-collections";
107 };
108 "jellyfin-favorites-sync-api-key" = {
109 file = ../../secrets/rhea/jellyfin-favorites-sync-api-key.age;
110 mode = "400";
111 owner = "jellyfin-favorites-sync";
112 };
113 "jellyfin-favorites-sync-ssh-key" = {
114 file = ../../secrets/rhea/jellyfin-favorites-sync-ssh-key.age;
115 mode = "400";
116 owner = "jellyfin-favorites-sync";
117 };
118 "restic-aix-password" = {
119 file = ../../secrets/rhea/restic-aix-password.age;
120 mode = "400";
121 owner = "vincent";
122 group = "users";
123 };
124 "ntfy-token" = {
125 file = ../../secrets/sakhalin/ntfy-token.age;
126 mode = "400";
127 owner = "vincent";
128 group = "users";
129 };
130 }
131 // lib.mapAttrs' (
132 name: _cfg:
133 lib.nameValuePair "exportarr-${name}-apikey" {
134 file = ../../secrets/rhea/exportarr-${name}-apikey.age;
135 mode = "400";
136 owner = "root";
137 }
138 ) exportarrServices;
139
140 users.users.vincent.linger = true;
141
142 services = {
143 traefik = {
144 enable = true;
145
146 staticConfigOptions = {
147 # API and Dashboard
148 api = {
149 dashboard = true;
150 insecure = false;
151 };
152
153 # Prometheus metrics
154 metrics.prometheus = {
155 addEntryPointsLabels = true;
156 addRoutersLabels = true;
157 addServicesLabels = true;
158 };
159
160 # Entry points
161 entryPoints = {
162 web = {
163 address = ":80";
164 http.redirections.entryPoint = {
165 to = "websecure";
166 scheme = "https";
167 };
168 };
169 websecure = {
170 address = ":443";
171 transport = {
172 respondingTimeouts = {
173 readTimeout = "600s"; # 10 minutes for large uploads
174 writeTimeout = "600s";
175 idleTimeout = "600s";
176 };
177 };
178 };
179 mqtt = {
180 address = ":1883";
181 };
182 mqtts = {
183 address = ":8883";
184 };
185 };
186
187 # Certificate resolver using Gandi DNS
188 certificatesResolvers.letsencrypt = {
189 acme = {
190 email = "vincent@sbr.pm";
191 storage = "/var/lib/traefik/acme.json";
192 dnsChallenge = {
193 provider = "gandiv5";
194 delayBeforeCheck = "0s";
195 resolvers = [
196 "1.1.1.1:53"
197 "8.8.8.8:53"
198 ];
199 };
200 };
201 };
202 };
203
204 # Dynamic configuration using module option
205 dynamicConfigOptions =
206 let
207 # Helper function to create a simple HTTP router
208 mkRouter = name: hosts: {
209 rule = lib.concatStringsSep " || " (map (host: "Host(`${host}`)") hosts);
210 service = name;
211 entryPoints = [ "websecure" ];
212 tls.certResolver = "letsencrypt";
213 };
214
215 # Helper function to create a router with middlewares
216 mkRouterWithMiddlewares = name: hosts: middlewares: {
217 rule = lib.concatStringsSep " || " (map (host: "Host(`${host}`)") hosts);
218 service = name;
219 entryPoints = [ "websecure" ];
220 tls.certResolver = "letsencrypt";
221 inherit middlewares;
222 };
223
224 # Helper function to create a simple HTTP service
225 mkService = url: {
226 loadBalancer.servers = [ { inherit url; } ];
227 };
228
229 # Define local services with their ports and optional alternate hosts
230 localServices = {
231 jellyfin.port = 8096;
232 jellyseerr.port = 5055;
233 # *arr services - ports from exportarrServices
234 sonarr.port = exportarrServices.sonarr.servicePort;
235 radarr.port = exportarrServices.radarr.servicePort;
236 bazarr.port = exportarrServices.bazarr.servicePort;
237 prowlarr.port = exportarrServices.prowlarr.servicePort;
238 transmission = {
239 port = 9091;
240 altHosts = [ "t.sbr.pm" ];
241 };
242 immich.port = 2283;
243 calibre = {
244 port = 8083;
245 altHosts = [ "books.sbr.pm" ];
246 };
247 dav.port = 6065;
248 };
249
250 # Generate routers for local services
251 localRouters = lib.mapAttrs' (
252 name: cfg:
253 let
254 hosts = [ "${name}.sbr.pm" ] ++ (cfg.altHosts or [ ]);
255 in
256 lib.nameValuePair name (mkRouter name hosts)
257 ) localServices;
258
259 # Generate services for local services
260 localHttpServices = lib.mapAttrs' (
261 name: cfg: lib.nameValuePair name (mkService "http://localhost:${toString cfg.port}")
262 ) localServices;
263
264 # Filter machines that have syncthing configured
265 syncthingMachines = lib.filterAttrs (
266 _name: machine: machine ? syncthing && machine.syncthing ? folders
267 ) globals.machines;
268
269 # Generate routers for syncthing hosts
270 syncthingRouters = lib.mapAttrs' (
271 name: _machine:
272 lib.nameValuePair "syncthing-${name}" {
273 rule = "Host(`syncthing.sbr.pm`) && PathPrefix(`/${name}`) || Host(`s.sbr.pm`) && PathPrefix(`/${name}`)";
274 service = "syncthing-${name}";
275 entryPoints = [ "websecure" ];
276 middlewares = [
277 "syncthing-${name}-addslash"
278 "syncthing-${name}-strip"
279 ];
280 tls = {
281 certResolver = "letsencrypt";
282 };
283 }
284 ) syncthingMachines;
285
286 # Generate services for syncthing hosts
287 syncthingServices = lib.mapAttrs' (
288 name: machine:
289 lib.nameValuePair "syncthing-${name}" {
290 loadBalancer = {
291 servers = [
292 { url = "http://${builtins.head machine.net.vpn.ips}:8384"; }
293 ];
294 };
295 }
296 ) syncthingMachines;
297
298 # Generate middleware for path stripping
299 syncthingMiddlewares = lib.mapAttrs' (
300 name: _machine:
301 lib.nameValuePair "syncthing-${name}-strip" {
302 stripPrefix = {
303 prefixes = [ "/${name}" ];
304 };
305 }
306 ) syncthingMachines;
307
308 # Generate middleware for adding trailing slash
309 syncthingAddSlashMiddlewares = lib.mapAttrs' (
310 name: _machine:
311 lib.nameValuePair "syncthing-${name}-addslash" {
312 redirectRegex = {
313 regex = "^(https?://[^/]+/${name})$";
314 replacement = "$$1/";
315 permanent = true;
316 };
317 }
318 ) syncthingMachines;
319 in
320 {
321 http = {
322 routers =
323 syncthingRouters
324 // localRouters
325 // {
326 # Override immich router to add large file upload middleware
327 immich = mkRouterWithMiddlewares "immich" [ "immich.sbr.pm" ] [ "immich-buffering" ];
328 # Override home router to add Home Assistant headers
329 home = mkRouterWithMiddlewares "home" [ "home.sbr.pm" ] [ "home-headers" ];
330 kiwix = mkRouter "kiwix" [ "kiwix.sbr.pm" ];
331 n8n = mkRouter "n8n" [ "n8n.sbr.pm" ];
332 paperless = mkRouter "paperless" [ "paperless.sbr.pm" ];
333 grafana = mkRouter "grafana" [ "grafana.sbr.pm" ];
334 navidrome = mkRouter "navidrome" [
335 "navidrome.sbr.pm"
336 "music.sbr.pm"
337 ];
338 transmission-music = mkRouter "transmission-music" [
339 "transmission-music.sbr.pm"
340 "tm.sbr.pm"
341 ];
342 audiobookshelf = mkRouter "audiobookshelf" [
343 "audiobookshelf.sbr.pm"
344 "podcasts.sbr.pm"
345 ];
346 lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
347 homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
348 # OpenCode web interface on okinawa (VPN-only)
349 opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
350 # llama-server on okinawa (VPN-only, OpenAI-compatible API)
351 llm = mkRouter "llm" [ "llm.sbr.pm" ];
352 reading = mkRouter "reading" [ "reading.sbr.pm" ];
353 # SearXNG metasearch engine on sakhalin
354 search = mkRouter "search" [
355 "search.sbr.pm"
356 "searxng.sbr.pm"
357 ];
358 # Traefik dashboard
359 traefik-dashboard = {
360 rule = "Host(`traefik.sbr.pm`)";
361 service = "api@internal";
362 entryPoints = [ "websecure" ];
363 tls.certResolver = "letsencrypt";
364 };
365 };
366 services =
367 syncthingServices
368 // localHttpServices
369 // {
370 home = mkService "http://${builtins.head globals.machines.hass.net.ips}:8123";
371 kiwix = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:8080";
372 n8n = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:5678";
373 paperless = mkService "http://${builtins.head globals.machines.aion.net.ips}:8000";
374 grafana = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3000";
375 navidrome = mkService "http://${builtins.head globals.machines.aion.net.ips}:4533";
376 transmission-music = mkService "http://${builtins.head globals.machines.aion.net.ips}:9091";
377 homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
378 audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
379 lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
380 opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
381 llm = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8090";
382 reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
383 search = mkService "http://${builtins.head globals.machines.sakhalin.net.vpn.ips}:8090";
384 };
385 middlewares =
386 syncthingMiddlewares
387 // syncthingAddSlashMiddlewares
388 // {
389 # Middleware for handling large file uploads (Immich)
390 immich-buffering = {
391 buffering = {
392 maxRequestBodyBytes = 0; # No limit
393 memRequestBodyBytes = 104857600; # 100MB in memory
394 maxResponseBodyBytes = 0; # No limit
395 memResponseBodyBytes = 104857600; # 100MB in memory
396 retryExpression = "IsNetworkError() && Attempts() < 2";
397 };
398 };
399 # Middleware for Home Assistant reverse proxy headers
400 home-headers = {
401 headers = {
402 customRequestHeaders = {
403 X-Forwarded-Proto = "https";
404 };
405 };
406 };
407 };
408 };
409 tcp = {
410 routers = {
411 mqtt = {
412 rule = "HostSNI(`*`)";
413 service = "mqtt";
414 entryPoints = [ "mqtt" ];
415 };
416 mqtts = {
417 rule = "HostSNI(`mqtt.sbr.pm`)";
418 service = "mqtt";
419 entryPoints = [ "mqtts" ];
420 tls = {
421 certResolver = "letsencrypt";
422 };
423 };
424 };
425 services = {
426 mqtt = {
427 loadBalancer = {
428 servers = [
429 { address = "${builtins.head globals.machines.demeter.net.ips}:1883"; }
430 ];
431 };
432 };
433 };
434 };
435 };
436 };
437
438 # smartd = {
439 # enable = true;
440 # devices = [ { device = "/dev/nvme0n1"; } ];
441 # };
442 samba.settings = {
443 global."server string" = "Rhea";
444 }
445 // builtins.mapAttrs (
446 name: cfg:
447 libx.mkSambaShare (
448 {
449 inherit name;
450 path = "/neo/${name}";
451 }
452 // cfg
453 )
454 ) neoSambaShares
455 // builtins.mapAttrs (
456 name: cfg:
457 libx.mkSambaShare (
458 {
459 inherit name;
460 path = "/zion/${name}";
461 }
462 // cfg
463 )
464 ) zionSambaShares;
465 nfs.server = {
466 enable = true;
467 # Fixed ports for firewall configuration
468 lockdPort = 4001;
469 mountdPort = 4002;
470 statdPort = 4000;
471 exports = ''
472 /neo 192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
473 /neo/backup 192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
474 /neo/downloads 192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
475 /neo/pictures 192.168.1.0/24(rw,fsid=7,no_subtree_check) 10.100.0.0/24(rw,fsid=7,no_subtree_check)
476 /neo/videos 192.168.1.0/24(rw,fsid=8,no_subtree_check) 10.100.0.0/24(rw,fsid=8,no_subtree_check)
477 /zion 192.168.1.0/24(rw,fsid=10,no_subtree_check) 10.100.0.0/24(rw,fsid=10,no_subtree_check)
478 /zion/audiobooks 192.168.1.0/24(ro,fsid=11,no_subtree_check) 10.100.0.0/24(ro,fsid=11,no_subtree_check)
479 /zion/documents 192.168.1.0/24(rw,fsid=12,no_subtree_check) 10.100.0.0/24(rw,fsid=12,no_subtree_check)
480 /zion/ebooks 192.168.1.0/24(rw,fsid=13,no_subtree_check) 10.100.0.0/24(rw,fsid=13,no_subtree_check)
481 /zion/music 192.168.1.0/24(ro,fsid=14,no_subtree_check) 10.100.0.0/24(ro,fsid=14,no_subtree_check)
482 '';
483 };
484 immich = serviceDefaults // {
485 enable = true;
486 host = "0.0.0.0"; # Listen on all interfaces for VPN access
487 mediaLocation = "/neo/pictures/photos";
488 };
489 postgresql = {
490 ensureDatabases = [
491 "immich"
492 ];
493 ensureUsers = [
494 {
495 name = "vincent";
496 }
497 ];
498 };
499 jellyfin = serviceDefaults // {
500 enable = true;
501 };
502 jellyseerr = {
503 enable = true;
504 openFirewall = true;
505 };
506 webdav = {
507 enable = true;
508 user = "vincent";
509 group = "users";
510 environmentFile = config.age.secrets."webdav-password".path;
511 settings = {
512 address = "127.0.0.1";
513 port = 6065;
514 scope = "/zion/documents/boox";
515 modify = true;
516 users = [
517 {
518 username = "vincent";
519 password = "{env}WEBDAV_PASSWORD_HASH";
520 }
521 ];
522 rules = [
523 {
524 regex = "(\\..*|.*\\.tmp)$"; # Block hidden files and .tmp files
525 allow = false;
526 }
527 ];
528 };
529 };
530 jellyfin-auto-collections = {
531 enable = true;
532 jellyfinUrl = "http://localhost:8096";
533 userId = "400fef4e0ab2448cb8a2bc8ca2facc4f";
534 apiKeyFile = config.age.secrets."jellyfin-auto-collections-api-key".path;
535 schedule = "daily"; # Run daily at midnight
536
537 jellyseerr = {
538 enable = false; # Enable when password secret is created
539 serverUrl = "http://localhost:5055";
540 email = "vincent@sbr.pm";
541 # Uncomment when jellyseerr password secret is created
542 # passwordFile = config.age.secrets."jellyfin-auto-collections-jellyseerr-password".path;
543 userType = "local";
544 };
545
546 settings = {
547 plugins = {
548 imdb_chart = {
549 enabled = true;
550 list_ids = [
551 "top"
552 "moviemeter"
553 ];
554 clear_collection = true;
555 };
556 imdb_list = {
557 enabled = true;
558 list_ids = [
559 "ls055592025" # IMDb Top 250
560 ];
561 };
562 jellyfin_api = {
563 enabled = true;
564 list_ids = [
565 # Marvel Cinematic Universe
566 {
567 studios = [
568 "Marvel Studios"
569 "Marvel Entertainment"
570 ];
571 list_name = "Marvel Cinematic Universe";
572 includeItemTypes = [ "Movie" ];
573 }
574 # Pixar Animation
575 {
576 studios = [ "Pixar" ];
577 list_name = "Pixar Collection";
578 includeItemTypes = [ "Movie" ];
579 }
580 # Studio Ghibli
581 {
582 studios = [ "Studio Ghibli" ];
583 list_name = "Studio Ghibli Collection";
584 includeItemTypes = [ "Movie" ];
585 }
586 # Sing Movies (Illumination)
587 {
588 searchTerm = "Sing";
589 studios = [ "Illumination Entertainment" ];
590 list_name = "Sing Movies";
591 includeItemTypes = [ "Movie" ];
592 }
593 # Christopher Nolan Films
594 {
595 person = [ "Christopher Nolan" ];
596 list_name = "Christopher Nolan Collection";
597 includeItemTypes = [ "Movie" ];
598 }
599 # Highly Rated Sci-Fi
600 {
601 genres = [ "Science Fiction" ];
602 minCriticRating = [ "8" ];
603 list_name = "Top Sci-Fi Movies";
604 includeItemTypes = [ "Movie" ];
605 }
606 # Recent Movies (2024-2025)
607 {
608 years = [
609 2024
610 2025
611 ];
612 list_name = "Recent Releases";
613 includeItemTypes = [ "Movie" ];
614 }
615 # Award Winners
616 {
617 tags = [ "Oscar Winner" ];
618 list_name = "Oscar Winners";
619 includeItemTypes = [ "Movie" ];
620 }
621 ];
622 };
623 };
624 };
625 };
626 jellyfin-favorites-sync = {
627 enable = true;
628 schedule = "daily"; # Run daily at midnight
629
630 jellyfinUrl = "http://localhost:8096";
631 apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
632 userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
633
634 # Use "Keep" playlist instead of favorites
635 playlistName = "Keep";
636
637 sourceRoot = "/neo/videos";
638
639 destination = {
640 host = "aix.sbr.pm";
641 user = "vincent";
642 root = "/data/videos";
643 };
644
645 # SSH key for authentication
646 sshKeyFile = config.age.secrets."jellyfin-favorites-sync-ssh-key".path;
647
648 sshArgs = [
649 "-o StrictHostKeyChecking=no"
650 "-o UserKnownHostsFile=/dev/null"
651 ];
652
653 # Dry-run verified, now syncing for real
654 dryRun = false;
655 };
656 transmission = serviceDefaults // {
657 enable = true;
658 package = pkgs.transmission_4;
659 openRPCPort = true; # Open firewall for RPC
660 home = "/neo/torrents";
661 settings = {
662 # Override default settings
663 incomplete-dir-enabled = true;
664 rpc-bind-address = "0.0.0.0"; # Bind to own IP
665 rpc-host-whitelist = "localhost,t.sbr.pm,transmission.sbr.pm,rhea.home,rhea.vpn,rhea.sbr.pm,192.168.1.50,10.100.0.50";
666 rpc-host-whitelist-enabled = true;
667 rpc-whitelist-enabled = true;
668 rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Whitelist your remote machine (10.0.0.1 in this example)
669 rpc-username = "transmission";
670 rpc-password = "transmission";
671 download-queue-enabled = true;
672 download-queue-size = 15;
673 queue-stalled-enabled = true;
674 queue-stalled-minutes = 30;
675 ratio-limit = 0.1;
676 ratio-limit-enabled = true;
677 };
678 };
679 # *arr services - ports configured via exportarrServices
680 sonarr = serviceDefaults // {
681 enable = true;
682 settings.server.port = exportarrServices.sonarr.servicePort;
683 };
684 radarr = serviceDefaults // {
685 enable = true;
686 settings.server.port = exportarrServices.radarr.servicePort;
687 };
688 bazarr = serviceDefaults // {
689 enable = true;
690 listenPort = exportarrServices.bazarr.servicePort;
691 };
692 prowlarr = {
693 enable = true;
694 openFirewall = true;
695 settings.server.port = exportarrServices.prowlarr.servicePort;
696 };
697
698 # Rsync replica jobs to backup FROM aion (disabled until migration)
699 rsync-replica = {
700 enable = true; # Enable after audio services migration to aion
701 jobs = {
702 aion-music-hourly = aionBackupDefaults // {
703 source = aionBackupDefaults.source // {
704 paths = [ "/zion/music" ];
705 };
706 schedule = "hourly";
707 };
708 aion-audiobooks-daily = aionBackupDefaults // {
709 source = aionBackupDefaults.source // {
710 paths = [ "/zion/audiobooks" ];
711 };
712 schedule = "daily";
713 };
714 };
715 };
716
717 # Generate prometheus exporters for all exportarr services
718 prometheus.exporters = lib.mapAttrs' (
719 name: cfg:
720 lib.nameValuePair "exportarr-${name}" {
721 enable = true;
722 inherit (cfg) port;
723 url = "http://localhost:${toString cfg.servicePort}";
724 apiKeyFile = config.age.secrets."exportarr-${name}-apikey".path;
725 }
726 ) exportarrServices;
727
728 # Restic backup to aix (off-site backup)
729 # Note: Media files are rsync'd (rhea → aion → aix)
730 # This backup focuses on arr service databases and configs
731 restic.backups.aix-critical = {
732 user = "vincent";
733 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/rhea";
734
735 # Use password-based encryption
736 passwordFile = config.age.secrets."restic-aix-password".path;
737
738 paths = [
739 "/var/lib/sonarr" # Sonarr database and config (~501MB)
740 "/var/lib/radarr" # Radarr database and config (~729MB)
741 "/var/lib/bazarr" # Bazarr database and config (~25MB)
742 "/var/lib/readarr" # Readarr database and config (~6MB)
743 "/var/lib/prowlarr" # Prowlarr database and config
744 "/var/lib/jellyfin" # Jellyfin database and config
745 # "/var/lib/immich" # Immich app data # Already handled in aion
746 # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
747 ];
748
749 # Backup schedule - weekly for moderate dataset
750 timerConfig = {
751 OnCalendar = "weekly";
752 Persistent = true;
753 RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
754 };
755
756 # Retention policy
757 pruneOpts = [
758 "--keep-daily 7" # Last 7 days
759 "--keep-weekly 4" # Last 4 weeks
760 "--keep-monthly 12" # Last 12 months
761 "--keep-yearly 3" # Last 3 years
762 ];
763
764 # Backup options
765 extraBackupArgs = [
766 "--exclude-caches"
767 "--exclude='*.Trash-*'"
768 "--exclude='lost+found'"
769 "--exclude='logs.db'" # Exclude log databases (large, not critical)
770 "--verbose"
771 ];
772
773 # Check repository integrity after backup
774 checkOpts = [
775 "--read-data-subset=5%" # Verify 5% of data each run
776 ];
777
778 # Backup monitoring with ntfy.sh
779 backupPrepareCommand = ''
780 ${pkgs.curl}/bin/curl \
781 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
782 config.age.secrets."ntfy-token".path
783 })" \
784 -H "Title: Restic Backup Starting (rhea)" \
785 -d "Starting backup to aix (arr services + configs)" \
786 https://ntfy.sbr.pm/backups
787 '';
788
789 backupCleanupCommand = ''
790 ${pkgs.curl}/bin/curl \
791 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
792 config.age.secrets."ntfy-token".path
793 })" \
794 -H "Title: Restic Backup Complete (rhea)" \
795 -H "Tags: white_check_mark" \
796 -d "Backup to aix completed successfully" \
797 https://ntfy.sbr.pm/backups || \
798 ${pkgs.curl}/bin/curl \
799 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
800 config.age.secrets."ntfy-token".path
801 })" \
802 -H "Title: Restic Backup Failed (rhea)" \
803 -H "Tags: x,warning" \
804 -H "Priority: high" \
805 -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
806 https://ntfy.sbr.pm/backups
807 '';
808 };
809 };
810
811 security.acme = {
812 acceptTerms = true;
813 defaults.email = "vincent@sbr.pm";
814 };
815
816 # Grant vincent ownership and superuser privileges for the immich database
817 # Grant healthchecks user permissions for the healthchecks database
818 systemd.services.postgresql.postStart = lib.mkAfter ''
819 PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
820 $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
821 $PSQL -tAc "ALTER ROLE vincent WITH SUPERUSER"
822 $PSQL -tAc "ALTER DATABASE immich OWNER TO vincent"
823 $PSQL immich -tAc "ALTER SCHEMA public OWNER TO vincent"
824 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON SCHEMA public TO vincent"
825 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vincent"
826 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vincent"
827 $PSQL immich -tAc "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vincent"
828 '';
829
830 # Calibre Content Server for ebook library
831 systemd.services.calibre-server = {
832 description = "Calibre Content Server";
833 after = [ "network.target" ];
834 wantedBy = [ "multi-user.target" ];
835
836 serviceConfig = {
837 Type = "simple";
838 ExecStart = "${pkgs.calibre}/bin/calibre-server --port=8083 /zion/ebooks";
839 Restart = "on-failure";
840 User = "vincent";
841 Group = "users";
842 };
843 };
844
845 networking.useDHCP = lib.mkDefault true;
846
847 # Open firewall for Traefik and NFS
848 networking.firewall = {
849 allowedTCPPorts = [
850 80
851 443
852 1883 # MQTT
853 8883 # MQTTS
854 8080 # Traefik metrics
855 9000 # Node exporter
856 9187 # PostgreSQL exporter
857 # Exportarr exporters
858 9707 # Sonarr
859 9708 # Radarr
860 9710 # Prowlarr
861 9712 # Bazarr
862 # NFS ports
863 111 # rpcbind
864 2049 # NFS daemon
865 4000 # statd
866 4001 # lockd
867 4002 # mountd
868 20048 # mountd (NFSv4)
869 ];
870 allowedUDPPorts = [
871 # NFS ports
872 111 # rpcbind
873 2049 # NFS daemon
874 4000 # statd
875 4001 # lockd
876 4002 # mountd
877 20048 # mountd (NFSv4)
878 ];
879 };
880
881 # Add ffsubsync and ffmpeg to bazarr's PATH for subtitle synchronization
882 systemd.services.bazarr.path = with pkgs; [
883 ffsubsync
884 ffmpeg-full
885 ];
886
887 # Environment file for Gandi API key (managed by agenix)
888 systemd.services.traefik.serviceConfig = {
889 EnvironmentFile = config.age.secrets."gandi.env".path;
890 };
891
892 environment.systemPackages = with pkgs; [
893 lm_sensors
894 gnumake
895 ffmpeg-full
896 ];
897
898}