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 paperless = mkRouter "paperless" [ "paperless.sbr.pm" ];
335 grafana = mkRouter "grafana" [ "grafana.sbr.pm" ];
336 navidrome = mkRouter "navidrome" [
337 "navidrome.sbr.pm"
338 "music.sbr.pm"
339 ];
340 transmission-music = mkRouter "transmission-music" [
341 "transmission-music.sbr.pm"
342 "tm.sbr.pm"
343 ];
344 audiobookshelf = mkRouter "audiobookshelf" [
345 "audiobookshelf.sbr.pm"
346 "podcasts.sbr.pm"
347 ];
348 lidarr = mkRouter "lidarr" [ "lidarr.sbr.pm" ];
349 wallabag = mkRouter "wallabag" [
350 "wallabag.sbr.pm"
351 "read.sbr.pm"
352 ];
353 miniflux = mkRouter "miniflux" [
354 "miniflux.sbr.pm"
355 "rss.sbr.pm"
356 ];
357 homepage = mkRouter "homepage" [ "homepage.sbr.pm" ];
358 # OpenCode web interface on okinawa (VPN-only)
359 opencode = mkRouter "opencode" [ "opencode.sbr.pm" ];
360 reading = mkRouter "reading" [ "reading.sbr.pm" ];
361 # Traefik dashboard
362 traefik-dashboard = {
363 rule = "Host(`traefik.sbr.pm`)";
364 service = "api@internal";
365 entryPoints = [ "websecure" ];
366 tls.certResolver = "letsencrypt";
367 };
368 };
369 services =
370 syncthingServices
371 // localHttpServices
372 // {
373 home = mkService "http://${builtins.head globals.machines.hass.net.ips}:8123";
374 paperless = mkService "http://${builtins.head globals.machines.aion.net.ips}:8000";
375 grafana = mkService "http://${builtins.head globals.machines.sakhalin.net.ips}:3000";
376 navidrome = mkService "http://${builtins.head globals.machines.aion.net.ips}:4533";
377 transmission-music = mkService "http://${builtins.head globals.machines.aion.net.ips}:9091";
378 homepage = mkService "http://${builtins.head globals.machines.aion.net.ips}:3001";
379 audiobookshelf = mkService "http://${builtins.head globals.machines.aion.net.ips}:13378";
380 lidarr = mkService "http://${builtins.head globals.machines.aion.net.ips}:8686";
381 wallabag = mkService "http://${builtins.head globals.machines.aion.net.ips}:8084";
382 miniflux = mkService "http://${builtins.head globals.machines.aion.net.ips}:8085";
383 opencode = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:5555";
384 reading = mkService "http://${builtins.head globals.machines.okinawa.net.vpn.ips}:8880";
385 };
386 middlewares =
387 syncthingMiddlewares
388 // syncthingAddSlashMiddlewares
389 // {
390 # Middleware for handling large file uploads (Immich)
391 immich-buffering = {
392 buffering = {
393 maxRequestBodyBytes = 0; # No limit
394 memRequestBodyBytes = 104857600; # 100MB in memory
395 maxResponseBodyBytes = 0; # No limit
396 memResponseBodyBytes = 104857600; # 100MB in memory
397 retryExpression = "IsNetworkError() && Attempts() < 2";
398 };
399 };
400 # Middleware for Home Assistant reverse proxy headers
401 home-headers = {
402 headers = {
403 customRequestHeaders = {
404 X-Forwarded-Proto = "https";
405 };
406 };
407 };
408 };
409 };
410 tcp = {
411 routers = {
412 mqtt = {
413 rule = "HostSNI(`*`)";
414 service = "mqtt";
415 entryPoints = [ "mqtt" ];
416 };
417 mqtts = {
418 rule = "HostSNI(`mqtt.sbr.pm`)";
419 service = "mqtt";
420 entryPoints = [ "mqtts" ];
421 tls = {
422 certResolver = "letsencrypt";
423 };
424 };
425 };
426 services = {
427 mqtt = {
428 loadBalancer = {
429 servers = [
430 { address = "${builtins.head globals.machines.demeter.net.ips}:1883"; }
431 ];
432 };
433 };
434 };
435 };
436 };
437 };
438
439 # smartd = {
440 # enable = true;
441 # devices = [ { device = "/dev/nvme0n1"; } ];
442 # };
443 samba.settings = {
444 global."server string" = "Rhea";
445 }
446 // builtins.mapAttrs (
447 name: cfg:
448 libx.mkSambaShare (
449 {
450 inherit name;
451 path = "/neo/${name}";
452 }
453 // cfg
454 )
455 ) neoSambaShares
456 // builtins.mapAttrs (
457 name: cfg:
458 libx.mkSambaShare (
459 {
460 inherit name;
461 path = "/zion/${name}";
462 }
463 // cfg
464 )
465 ) zionSambaShares;
466 nfs.server = {
467 enable = true;
468 # Fixed ports for firewall configuration
469 lockdPort = 4001;
470 mountdPort = 4002;
471 statdPort = 4000;
472 exports = ''
473 /neo 192.168.1.0/24(rw,fsid=0,no_subtree_check) 10.100.0.0/24(rw,fsid=0,no_subtree_check)
474 /neo/backup 192.168.1.0/24(rw,fsid=2,no_subtree_check) 10.100.0.0/24(rw,fsid=2,no_subtree_check)
475 /neo/downloads 192.168.1.0/24(rw,fsid=4,no_subtree_check) 10.100.0.0/24(rw,fsid=4,no_subtree_check)
476 /neo/pictures 192.168.1.0/24(rw,fsid=7,no_subtree_check) 10.100.0.0/24(rw,fsid=7,no_subtree_check)
477 /neo/videos 192.168.1.0/24(rw,fsid=8,no_subtree_check) 10.100.0.0/24(rw,fsid=8,no_subtree_check)
478 /zion 192.168.1.0/24(rw,fsid=10,no_subtree_check) 10.100.0.0/24(rw,fsid=10,no_subtree_check)
479 /zion/audiobooks 192.168.1.0/24(ro,fsid=11,no_subtree_check) 10.100.0.0/24(ro,fsid=11,no_subtree_check)
480 /zion/documents 192.168.1.0/24(rw,fsid=12,no_subtree_check) 10.100.0.0/24(rw,fsid=12,no_subtree_check)
481 /zion/ebooks 192.168.1.0/24(rw,fsid=13,no_subtree_check) 10.100.0.0/24(rw,fsid=13,no_subtree_check)
482 /zion/music 192.168.1.0/24(ro,fsid=14,no_subtree_check) 10.100.0.0/24(ro,fsid=14,no_subtree_check)
483 '';
484 };
485 immich = serviceDefaults // {
486 enable = true;
487 host = "0.0.0.0"; # Listen on all interfaces for VPN access
488 mediaLocation = "/neo/pictures/photos";
489 };
490 postgresql = {
491 package = pkgs.postgresql_16;
492 ensureDatabases = [
493 "immich"
494 ];
495 ensureUsers = [
496 {
497 name = "vincent";
498 }
499 ];
500 };
501 jellyfin = serviceDefaults // {
502 enable = true;
503 };
504 jellyseerr = {
505 enable = true;
506 openFirewall = true;
507 };
508 webdav = {
509 enable = true;
510 user = "vincent";
511 group = "users";
512 environmentFile = config.age.secrets."webdav-password".path;
513 settings = {
514 address = "127.0.0.1";
515 port = 6065;
516 scope = "/zion/documents/boox";
517 modify = true;
518 users = [
519 {
520 username = "vincent";
521 password = "{env}WEBDAV_PASSWORD_HASH";
522 }
523 ];
524 rules = [
525 {
526 regex = "(\\..*|.*\\.tmp)$"; # Block hidden files and .tmp files
527 allow = false;
528 }
529 ];
530 };
531 };
532 jellyfin-auto-collections = {
533 enable = true;
534 jellyfinUrl = "http://localhost:8096";
535 userId = "400fef4e0ab2448cb8a2bc8ca2facc4f";
536 apiKeyFile = config.age.secrets."jellyfin-auto-collections-api-key".path;
537 schedule = "daily"; # Run daily at midnight
538
539 jellyseerr = {
540 enable = false; # Enable when password secret is created
541 serverUrl = "http://localhost:5055";
542 email = "vincent@sbr.pm";
543 # Uncomment when jellyseerr password secret is created
544 # passwordFile = config.age.secrets."jellyfin-auto-collections-jellyseerr-password".path;
545 userType = "local";
546 };
547
548 settings = {
549 plugins = {
550 imdb_chart = {
551 enabled = true;
552 list_ids = [
553 "top"
554 "moviemeter"
555 ];
556 clear_collection = true;
557 };
558 imdb_list = {
559 enabled = true;
560 list_ids = [
561 "ls055592025" # IMDb Top 250
562 ];
563 };
564 jellyfin_api = {
565 enabled = true;
566 list_ids = [
567 # Marvel Cinematic Universe
568 {
569 studios = [
570 "Marvel Studios"
571 "Marvel Entertainment"
572 ];
573 list_name = "Marvel Cinematic Universe";
574 includeItemTypes = [ "Movie" ];
575 }
576 # Pixar Animation
577 {
578 studios = [ "Pixar" ];
579 list_name = "Pixar Collection";
580 includeItemTypes = [ "Movie" ];
581 }
582 # Studio Ghibli
583 {
584 studios = [ "Studio Ghibli" ];
585 list_name = "Studio Ghibli Collection";
586 includeItemTypes = [ "Movie" ];
587 }
588 # Sing Movies (Illumination)
589 {
590 searchTerm = "Sing";
591 studios = [ "Illumination Entertainment" ];
592 list_name = "Sing Movies";
593 includeItemTypes = [ "Movie" ];
594 }
595 # Christopher Nolan Films
596 {
597 person = [ "Christopher Nolan" ];
598 list_name = "Christopher Nolan Collection";
599 includeItemTypes = [ "Movie" ];
600 }
601 # Highly Rated Sci-Fi
602 {
603 genres = [ "Science Fiction" ];
604 minCriticRating = [ "8" ];
605 list_name = "Top Sci-Fi Movies";
606 includeItemTypes = [ "Movie" ];
607 }
608 # Recent Movies (2024-2025)
609 {
610 years = [
611 2024
612 2025
613 ];
614 list_name = "Recent Releases";
615 includeItemTypes = [ "Movie" ];
616 }
617 # Award Winners
618 {
619 tags = [ "Oscar Winner" ];
620 list_name = "Oscar Winners";
621 includeItemTypes = [ "Movie" ];
622 }
623 ];
624 };
625 };
626 };
627 };
628 jellyfin-favorites-sync = {
629 enable = true;
630 schedule = "daily"; # Run daily at midnight
631
632 jellyfinUrl = "http://localhost:8096";
633 apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
634 userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
635
636 # Use "Keep" playlist instead of favorites
637 playlistName = "Keep";
638
639 sourceRoot = "/neo/videos";
640
641 destination = {
642 host = "aix.sbr.pm";
643 user = "vincent";
644 root = "/data/videos";
645 };
646
647 # SSH key for authentication
648 sshKeyFile = config.age.secrets."jellyfin-favorites-sync-ssh-key".path;
649
650 sshArgs = [
651 "-o StrictHostKeyChecking=no"
652 "-o UserKnownHostsFile=/dev/null"
653 ];
654
655 # Dry-run verified, now syncing for real
656 dryRun = false;
657 };
658 transmission = serviceDefaults // {
659 enable = true;
660 package = pkgs.transmission_4;
661 openRPCPort = true; # Open firewall for RPC
662 home = "/neo/torrents";
663 settings = {
664 # Override default settings
665 incomplete-dir-enabled = true;
666 rpc-bind-address = "0.0.0.0"; # Bind to own IP
667 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";
668 rpc-host-whitelist-enabled = true;
669 rpc-whitelist-enabled = true;
670 rpc-whitelist = "127.0.0.1,192.168.1.*,10.100.0.*"; # Whitelist your remote machine (10.0.0.1 in this example)
671 rpc-username = "transmission";
672 rpc-password = "transmission";
673 download-queue-enabled = true;
674 download-queue-size = 15;
675 queue-stalled-enabled = true;
676 queue-stalled-minutes = 30;
677 ratio-limit = 0.1;
678 ratio-limit-enabled = true;
679 };
680 };
681 # *arr services - ports configured via exportarrServices
682 sonarr = serviceDefaults // {
683 enable = true;
684 settings.server.port = exportarrServices.sonarr.servicePort;
685 };
686 radarr = serviceDefaults // {
687 enable = true;
688 settings.server.port = exportarrServices.radarr.servicePort;
689 };
690 bazarr = serviceDefaults // {
691 enable = true;
692 listenPort = exportarrServices.bazarr.servicePort;
693 };
694 prowlarr = {
695 enable = true;
696 openFirewall = true;
697 settings.server.port = exportarrServices.prowlarr.servicePort;
698 };
699
700 # Rsync replica jobs to backup FROM aion (disabled until migration)
701 rsync-replica = {
702 enable = true; # Enable after audio services migration to aion
703 jobs = {
704 aion-music-hourly = aionBackupDefaults // {
705 source = aionBackupDefaults.source // {
706 paths = [ "/zion/music" ];
707 };
708 schedule = "hourly";
709 };
710 aion-audiobooks-daily = aionBackupDefaults // {
711 source = aionBackupDefaults.source // {
712 paths = [ "/zion/audiobooks" ];
713 };
714 schedule = "daily";
715 };
716 };
717 };
718
719 # Generate prometheus exporters for all exportarr services
720 prometheus.exporters = lib.mapAttrs' (
721 name: cfg:
722 lib.nameValuePair "exportarr-${name}" {
723 enable = true;
724 inherit (cfg) port;
725 url = "http://localhost:${toString cfg.servicePort}";
726 apiKeyFile = config.age.secrets."exportarr-${name}-apikey".path;
727 }
728 ) exportarrServices;
729
730 # Restic backup to aix (off-site backup)
731 # Note: Media files are rsync'd (rhea → aion → aix)
732 # This backup focuses on arr service databases and configs
733 restic.backups.aix-critical = {
734 user = "vincent";
735 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/rhea";
736
737 # Use password-based encryption
738 passwordFile = config.age.secrets."restic-aix-password".path;
739
740 paths = [
741 "/var/lib/sonarr" # Sonarr database and config (~501MB)
742 "/var/lib/radarr" # Radarr database and config (~729MB)
743 "/var/lib/bazarr" # Bazarr database and config (~25MB)
744 "/var/lib/readarr" # Readarr database and config (~6MB)
745 "/var/lib/prowlarr" # Prowlarr database and config
746 "/var/lib/jellyfin" # Jellyfin database and config
747 # "/var/lib/immich" # Immich app data # Already handled in aion
748 # "/var/lib/traefik" # Traefik acme.json (Let's Encrypt certs)
749 ];
750
751 # Backup schedule - weekly for moderate dataset
752 timerConfig = {
753 OnCalendar = "weekly";
754 Persistent = true;
755 RandomizedDelaySec = "2h"; # Avoid conflict with aion backup
756 };
757
758 # Retention policy
759 pruneOpts = [
760 "--keep-daily 7" # Last 7 days
761 "--keep-weekly 4" # Last 4 weeks
762 "--keep-monthly 12" # Last 12 months
763 "--keep-yearly 3" # Last 3 years
764 ];
765
766 # Backup options
767 extraBackupArgs = [
768 "--exclude-caches"
769 "--exclude='*.Trash-*'"
770 "--exclude='lost+found'"
771 "--exclude='logs.db'" # Exclude log databases (large, not critical)
772 "--verbose"
773 ];
774
775 # Check repository integrity after backup
776 checkOpts = [
777 "--read-data-subset=5%" # Verify 5% of data each run
778 ];
779
780 # Backup monitoring with ntfy.sh
781 backupPrepareCommand = ''
782 ${pkgs.curl}/bin/curl \
783 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
784 config.age.secrets."ntfy-token".path
785 })" \
786 -H "Title: Restic Backup Starting (rhea)" \
787 -d "Starting backup to aix (arr services + configs)" \
788 https://ntfy.sbr.pm/backups
789 '';
790
791 backupCleanupCommand = ''
792 ${pkgs.curl}/bin/curl \
793 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
794 config.age.secrets."ntfy-token".path
795 })" \
796 -H "Title: Restic Backup Complete (rhea)" \
797 -H "Tags: white_check_mark" \
798 -d "Backup to aix completed successfully" \
799 https://ntfy.sbr.pm/backups || \
800 ${pkgs.curl}/bin/curl \
801 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
802 config.age.secrets."ntfy-token".path
803 })" \
804 -H "Title: Restic Backup Failed (rhea)" \
805 -H "Tags: x,warning" \
806 -H "Priority: high" \
807 -d "Backup to aix failed! Check logs: journalctl -u restic-backups-aix-critical.service" \
808 https://ntfy.sbr.pm/backups
809 '';
810 };
811 };
812
813 security.acme = {
814 acceptTerms = true;
815 defaults.email = "vincent@sbr.pm";
816 };
817
818 # Grant vincent ownership and superuser privileges for the immich database
819 # Grant healthchecks user permissions for the healthchecks database
820 systemd.services.postgresql.postStart = lib.mkAfter ''
821 PSQL="${config.services.postgresql.package}/bin/psql --port=${toString config.services.postgresql.settings.port}"
822 $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'vincent'" | grep -q 1 || $PSQL -tAc "CREATE ROLE vincent WITH LOGIN SUPERUSER"
823 $PSQL -tAc "ALTER ROLE vincent WITH SUPERUSER"
824 $PSQL -tAc "ALTER DATABASE immich OWNER TO vincent"
825 $PSQL immich -tAc "ALTER SCHEMA public OWNER TO vincent"
826 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON SCHEMA public TO vincent"
827 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO vincent"
828 $PSQL immich -tAc "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO vincent"
829 $PSQL immich -tAc "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vincent"
830 '';
831
832 # Calibre Content Server for ebook library
833 systemd.services.calibre-server = {
834 description = "Calibre Content Server";
835 after = [ "network.target" ];
836 wantedBy = [ "multi-user.target" ];
837
838 serviceConfig = {
839 Type = "simple";
840 ExecStart = "${pkgs.calibre}/bin/calibre-server --port=8083 /zion/ebooks";
841 Restart = "on-failure";
842 User = "vincent";
843 Group = "users";
844 };
845 };
846
847 networking.useDHCP = lib.mkDefault true;
848
849 # Open firewall for Traefik and NFS
850 networking.firewall = {
851 allowedTCPPorts = [
852 80
853 443
854 1883 # MQTT
855 8883 # MQTTS
856 8080 # Traefik metrics
857 9000 # Node exporter
858 9187 # PostgreSQL exporter
859 # Exportarr exporters
860 9707 # Sonarr
861 9708 # Radarr
862 9710 # Prowlarr
863 9712 # Bazarr
864 # NFS ports
865 111 # rpcbind
866 2049 # NFS daemon
867 4000 # statd
868 4001 # lockd
869 4002 # mountd
870 20048 # mountd (NFSv4)
871 ];
872 allowedUDPPorts = [
873 # NFS ports
874 111 # rpcbind
875 2049 # NFS daemon
876 4000 # statd
877 4001 # lockd
878 4002 # mountd
879 20048 # mountd (NFSv4)
880 ];
881 };
882
883 # Add ffsubsync and ffmpeg to bazarr's PATH for subtitle synchronization
884 systemd.services.bazarr.path = with pkgs; [
885 ffsubsync
886 ffmpeg-full
887 ];
888
889 # Environment file for Gandi API key (managed by agenix)
890 systemd.services.traefik.serviceConfig = {
891 EnvironmentFile = config.age.secrets."gandi.env".path;
892 };
893
894 environment.systemPackages = with pkgs; [
895 lm_sensors
896 gnumake
897 ffmpeg-full
898 ];
899
900}