{ 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/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 ]; # 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 cap of 5 comes from this box's own 34-generation incident, # but at ~5G free on a 29G eMMC even 5 is too many — override down to 2. boot.loader.systemd-boot.configurationLimit = lib.mkForce 2; # 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, pci 8086:5A85). The i915 KERNEL # driver binds on its own — /dev/dri/{card1,renderD128} exist without this — # but the libva USERSPACE driver only ships when hardware.graphics is on, and # nothing else here pulled it in. Without it VAAPI init fails with "unknown # libva error" and jellyfin-ffmpeg exits 251 on EVERY transcode, which the # client shows as generic playback failure: the server log only says "FFmpeg # exited with code 251", never that a driver is missing. Verified on the box: # the same h264_vaapi encode goes 251 -> 0 once iHD is on LIBVA_DRIVERS_PATH. # # iHD (intel-media-driver) is the right one for Gen9; i965 is for Gen8 and # older. Note the render node is 0666 but card1 is 0660 root:video, so the # group membership in services/media/jellyfin.nix matters for the card node. hardware.graphics = { enable = true; extraPackages = [ pkgs.intel-media-driver ]; }; # ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its # web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on # top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with # "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding # behind the first (fixing the missing driver only moved 251 -> 171). # There is no good way to provide it here: vpl-gpu-rt is Gen12+, and the # Gen9 runtime `intel-media-sdk` is marked INSECURE in nixpkgs (EOL, 5 CVEs # incl. local privilege escalation) — not worth it when VAAPI does the same # job on this chip at ~3.5x realtime for 1080p->720p. # # Also: 4K HDR (the 2160p HEVC/DV remuxes) can NOT be tone-mapped here. # tonemap_opencl needs OpenCL, which has no platform on this box, and # tonemap_vaapi is Gen11+ — both fail. Only a plain scale_vaapi=format=nv12 # succeeds, which drops HDR without tone-mapping (washed-out picture). # Those files need to direct-play, or be kept as 1080p SDR versions. # ---- NAS data array ---- # Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T). # Mounted, NOT formatted; kept out of disko so it is never wiped. # ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data. boot.swraid.enable = true; # assemble the mdadm array at boot 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` above is necessary but NOT sufficient — any mount layered on the # array (prowlarr/seerr binds) is RequiredBy local-fs.target and will fail it # regardless, and emergency mode on this box is a dead end: root is locked, so # sulogin drops you at a prompt you cannot answer, with no ssh. 2026-08-06: a # drive that failed to enumerate after the rack move did exactly this — # "Timed out waiting for device /dev/disk/by-uuid/dadbff6f-…" -> Dependency # failed for Local File Systems -> Reached target Emergency Mode, twice. # Boot as far as possible instead and leave the failed units to be read over # ssh. The array-backed services carry RequiresMountsFor=/mnt/data so they # still refuse to start rather than writing to the eMMC. systemd.enableEmergencyMode = false; # ---- Heavy state moved off the eMMC ---- # A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the # OS disk has no room for state that grows on its own. 2026-08-09: it hit 0 # bytes free with both gen 39 and gen 40 resident, and postgres died on # "No space left on device" — note ext4 reserves 5% for root, so non-root # services see zero while df still shows ~300M free. # # Paths live under /mnt/data/AppData like every other service's state. Both # settings below are jupiter-only on purpose: services/containers.nix stays # engine- and host-agnostic (mercury runs pihole on podman with no array). # podman: CI images dominate and keep growing — the gitea runner's # act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix # pulls another ~1.7G the first time a job requests it. # runroot stays on /run: it is per-boot tmpfs state, not a growing store. virtualisation.containers.storage.settings.storage = { driver = "overlay"; graphroot = "/mnt/data/AppData/containers/storage"; runroot = "/run/containers/storage"; }; # immich's postgres cluster. Version component mirrors the upstream default # (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir # instead of silently reusing the old cluster's files. # ⚠️ This puts the DB in the SAME failure domain as the photos it indexes: # /mnt/data is RAID0, so either 16TB disk now loses both, where before an # eMMC failure and an array failure each took only one. Chosen deliberately # — 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 group # "users" just to TRAVERSE into its own dataDir — exactly the reason immich # has the same line. The cluster dir itself keeps the mode it was initdb'd # with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when # the cluster was created with group access, and refuses to start otherwise. users.users.postgres.extraGroups = [ "users" ]; # Neither path is under /var/lib, so no module creates it: the postgresql # module's own tmpfiles entry only adjusts a dataDir that already exists, # the same way immich's mediaLocation rule does. systemd.tmpfiles.rules = [ "d /mnt/data/AppData/postgresql 0750 postgres postgres -" "d /mnt/data/AppData/containers 0700 root root -" ]; # graphroot is not a systemd path dependency the way dataDir is, so nothing # derives a mount ordering from it. Without these, podman would recreate an # empty store on the eMMC under the mountpoint when the array is late or # absent, and the runner would re-pull every image into it. # (podman-clonarr already carries this from 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"; }