Files
homelab/hosts/jupiter/configuration.nix
T
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

163 lines
7.7 KiB
Nix

{ 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";
}