{ config, pkgs, lib, ... }: # ZimaBlade NAS host: hardware + disk + the services it runs. { imports = [ ./hardware-configuration.nix ./disk-config.nix # disko: OS-disk partitions + filesystems ./secrets.nix # sops-nix: samba password, tailscale key, ... ../../common.nix # shared base: user / ssh / nix / firewall ../../services/network/samba.nix ../../services/network/avahi.nix ../../services/media/audiobookshelf.nix ../../services/containers.nix ../../services/network/caddy.nix ../../services/vpn/tailscale.nix ../../services/monitoring/node-exporter.nix ../../services/monitoring/victoriametrics.nix ../../services/media/jellyfin.nix ../../services/media/sabnzbd.nix ../../services/media/prowlarr.nix ../../services/media/sonarr.nix ../../services/media/radarr.nix ../../services/media/clonarr.nix ../../services/media/seerr.nix ../../services/media/immich.nix ../../services/dev/gitea.nix ../../services/dev/obsidian-livesync.nix ]; # sabnzbd's unrar dependency is unfree; scope the allowance to just that # package rather than blanket-allowing unfree across the host. nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "unrar" ]; # ---- Host identity ---- networking.hostName = "jupiter"; networking.networkmanager.enable = true; users.users.darman.extraGroups = [ "docker" ]; # merges with common.nix # ---- Boot ---- # systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub. boot.loader.systemd-boot.enable = true; boot.loader.efi.canTouchEfiVariables = true; # common.nix's default of 5 is still too many boot entries for a 29G eMMC — override down to 2. boot.loader.systemd-boot.configurationLimit = lib.mkForce 2; # A `switch` pins the old generation as a GC root until reboot; common.nix's weekly # nix.gc is too slow for a 29G eMMC, so collect garbage on every boot instead. systemd.services.gc-on-boot = { description = "Full nix-collect-garbage on every boot"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = "${pkgs.nix}/bin/nix-collect-garbage -d"; }; }; # Root lives on the ZimaBlade eMMC (mmcblk0). nixos-generate-config runs in # the RAM installer and does NOT detect these, so pin them here (merged with # hardware-configuration.nix) or stage-1 can't mount root and the box panics. boot.initrd.availableKernelModules = [ "mmc_block" "sdhci_pci" "sdhci_acpi" ]; # Warm reboot hangs at firmware reset on this board (cold power-cycle works). # Force the PCI-chipset reset method. If a warm `reboot` still hangs, try the # next value: acpi -> bios -> cold -> efi. boot.kernelParams = [ "reboot=pci" ]; # ---- GPU (jellyfin hardware transcoding) ---- # Apollo Lake N3450 / HD Graphics 500 (Gen9). i915 binds on its own, but VAAPI needs # the iHD userspace driver (Gen9; i965 is Gen8-only) or jellyfin-ffmpeg exits 251 on # every transcode with no clearer error than "FFmpeg exited with code 251" in the log. hardware.graphics = { enable = true; extraPackages = [ pkgs.intel-media-driver ]; }; # ⚠️ Use VAAPI, not QSV, in jellyfin's UI — QSV needs an MFX runtime not safely # available for this Gen9 chip (only insecure/EOL options) and fails with exit 171. # 4K HDR remuxes also can't be tone-mapped here (needs OpenCL or Gen11+); keep those # as 1080p SDR or let them direct-play. # ---- NAS data array ---- # Existing ext4 on mdadm RAID0 (sda+sdb, md0, 29.1T) — mounted, not formatted, kept # out of disko. ⚠️ RAID0 has no redundancy: either disk failing loses ALL data. boot.swraid.enable = true; # Silences the "mdmon service will crash" eval warning — mdmon never actually runs # here (native superblocks, not external-metadata) but the module warns regardless. boot.swraid.mdadmConf = "MAILADDR root"; fileSystems."/mnt/data" = { # fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0. device = "/dev/disk/by-uuid/dadbff6f-652e-49b2-bfed-eb1308ab8b78"; fsType = "ext4"; options = [ "nofail" ]; # don't block boot if the array is degraded/absent }; # `nofail` alone isn't enough — mounts layered on the array (prowlarr/seerr binds) # are RequiredBy local-fs.target and can still trip Emergency Mode, which is a dead # end here (root locked, no ssh). Boot as far as possible instead; the array-backed # services carry RequiresMountsFor=/mnt/data so they still won't write to the eMMC. systemd.enableEmergencyMode = false; # ---- Heavy state moved off the eMMC ---- # A deploy holds two full closures (~9G each) on this 29G disk at once, so state # that grows on its own can't live there — moved under /mnt/data/AppData like every # other service's state. Settings below are jupiter-only; services/containers.nix # stays engine/host-agnostic (mercury runs podman with no array). # runroot stays on /run (per-boot tmpfs, doesn't grow); graphroot moves to the array # since the gitea runner's CI images alone run several GB. virtualisation.containers.storage.settings.storage = { driver = "overlay"; graphroot = "/mnt/data/AppData/containers/storage"; runroot = "/run/containers/storage"; }; # immich's postgres cluster. Version-qualified path (matches upstream default) so a # major bump gets a fresh dir instead of reusing the old cluster's files. # ⚠️ Puts the DB in the same RAID0 failure domain as the photos it indexes — # deliberate (the two are useless apart) but neither is backed up. services.postgresql.dataDir = "/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}"; # /mnt/data/AppData is drwx--x--- darman:users, so postgres needs the "users" group # just to traverse into its dataDir (same reason immich needs it) — postgres itself # refuses to start unless the cluster dir is 0700 or 0750. users.users.postgres.extraGroups = [ "users" ]; # Neither path is under /var/lib, so no module creates it automatically — same # reason immich needs its own mediaLocation tmpfiles rule. systemd.tmpfiles.rules = [ "d /mnt/data/AppData/postgresql 0750 postgres postgres -" "d /mnt/data/AppData/containers 0700 root root -" ]; # Without this, podman would recreate an empty store on the eMMC if the array mounts # late or is absent, and the runner would re-pull every image. # (podman-clonarr already sets this in services/media/clonarr.nix.) systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; # ---- Caddy vhosts (LAN) ---- # Reached via pihole local-DNS names -> jupiter IP. services.caddy.virtualHosts = { "http://audiobookshelf.jupiter.sol".extraConfig = "reverse_proxy localhost:8000"; "http://jellyfin.jupiter.sol".extraConfig = "reverse_proxy localhost:8096"; "http://sabnzbd.jupiter.sol".extraConfig = "reverse_proxy localhost:8085"; "http://prowlarr.jupiter.sol".extraConfig = "reverse_proxy localhost:9696"; "http://sonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:8989"; "http://radarr.jupiter.sol".extraConfig = "reverse_proxy localhost:7878"; "http://clonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:6060"; "http://seerr.jupiter.sol".extraConfig = "reverse_proxy localhost:5055"; "http://gitea.jupiter.sol".extraConfig = "reverse_proxy localhost:3000"; # Immich uploads are large: raise the body limit off caddy's default and # give slow phone uploads room before the proxy gives up. "http://immich.jupiter.sol".extraConfig = '' request_body { max_size 50GB } reverse_proxy localhost:2283 ''; }; system.stateVersion = "26.05"; }