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