Add *arr media stack + Gitea to jupiter

- sabnzbd, prowlarr, sonarr, radarr, clonarr, seerr, cinephage, mediamanager
  services, wired into jupiter with LAN Caddy vhosts.
- Gitea: migrated the old ZimaOS docker instance's data (sqlite db, 4 repos,
  no LFS objects) into the NixOS module's default stateDir layout. HTTP via
  Caddy; git SSH on its own built-in server at :2222 (not :222 - the unpriv
  gitea user can't bind <1024).
- mediamanager-nix flake input for the mediamanager service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:27:50 +02:00
co-authored by Claude Sonnet 5
parent fe5b363b73
commit 69a63dc615
16 changed files with 476 additions and 27 deletions
+41
View File
@@ -0,0 +1,41 @@
{ config, ... }:
# Cinephage — indexer search + streaming/library manager. Runs the official
# container image, not upstream's nix flake module: their flake's
# npmDepsHash is stale against their own package-lock.json (fixed-output
# hash mismatch), and past that, a transitive dep's postinstall hard-enforces
# pnpm (`only-allow pnpm`), which fails under nix's network-sandboxed npm
# build regardless. Docker is their actually-maintained deployment path.
# Paired sops secret in hosts/jupiter/secrets.nix — BETTER_AUTH_SECRET signs
# sessions/encrypts stored API keys; losing it invalidates all sessions and
# makes saved keys unrecoverable, so it must be static, not app-generated.
{
virtualisation.oci-containers.containers.cinephage = {
image = "ghcr.io/moldytaint/cinephage:latest";
autoStart = true;
# Host networking, not a published port: cinephage needs to reach
# Prowlarr/SABnzbd on jupiter's own localhost (they're native systemd
# services, not containers) — bridge-mode "localhost" would be the
# container's own netns, not the host's.
extraOptions = [ "--network=host" ];
volumes = [
"/mnt/data/AppData/cinephage:/config"
"/mnt/data/HighSeas:/media"
"/mnt/data/HighSeas/Downloads:/downloads"
];
environment = {
PUID = "1000";
PGID = "100"; # darman:users — matches HighSeas' real on-disk ownership
TZ = "Europe/Berlin";
ORIGIN = "http://cinephage.jupiter.sol";
};
environmentFiles = [ config.sops.templates."cinephage.env".path ];
};
sops.templates."cinephage.env".content =
"BETTER_AUTH_SECRET=${config.sops.placeholder.cinephage_better_auth_secret}";
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/cinephage 0755 darman users -"
];
}
+28
View File
@@ -0,0 +1,28 @@
{ ... }:
# Clonarr — visual TRaSH-Guides sync tool for Radarr/Sonarr (quality
# profiles, custom formats, scores). No nixpkgs package; runs the official
# container image (ghcr.io/prophetse7en/clonarr).
{
virtualisation.oci-containers.containers.clonarr = {
image = "ghcr.io/prophetse7en/clonarr:latest";
autoStart = true;
# Host networking: clonarr needs to reach Radarr/Sonarr/Prowlarr on
# jupiter's own localhost (native systemd services, not containers) —
# bridge-mode "localhost" would be the container's own netns, not the host's.
extraOptions = [ "--network=host" ];
volumes = [
"/mnt/data/AppData/clonarr:/config"
];
environment = {
TZ = "Europe/Berlin";
PUID = "1000";
PGID = "100"; # darman:users
PORT = "6060";
};
};
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/clonarr 0755 darman users -"
];
}
+3 -11
View File
@@ -1,7 +1,8 @@
{ ... }:
# Podman (Docker-compatible) + declarative OCI containers.
# Copy the whoami block per app; publish ports and mount /mnt/data volumes.
# Add containers under virtualisation.oci-containers.containers; publish
# ports and mount /mnt/data volumes as needed.
{
virtualisation.podman = {
enable = true;
@@ -9,14 +10,5 @@
defaultNetwork.settings.dns_enabled = true;
};
virtualisation.oci-containers = {
backend = "podman";
containers = {
whoami = {
image = "traefik/whoami:latest";
ports = [ "8080:80" ];
autoStart = true;
};
};
};
virtualisation.oci-containers.backend = "podman";
}
+48
View File
@@ -0,0 +1,48 @@
{ ... }:
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
# ZimaOS docker instance (single user/org "darman", sqlite db, 4 repos, no
# LFS objects yet) into stateDir's default layout (data/gitea.db,
# data/{indexers,avatars,sessions,...}, repositories/), so no import step is
# needed on first boot — NixOS just needs to chown it to the gitea user
# (see below).
#
# HTTP is reverse-proxied through Caddy (see hosts/jupiter/configuration.nix
# for the vhost). SSH clone/push uses gitea's own built-in SSH server on
# :2222, kept separate from the host's OpenSSH on :22. NOT :222 (the old
# docker setup's external port) — gitea runs as the unpriv "gitea" user with
# an empty capability set, so it can't bind <1024: "listen tcp :222: bind:
# permission denied".
#
# After first deploy, fix ownership of the migrated data (it currently
# belongs to darman:users from the CIFS copy):
# chown -R gitea:gitea /mnt/data/AppData/gitea
{
services.gitea = {
enable = true;
stateDir = "/mnt/data/AppData/gitea";
lfs.enable = true;
settings = {
repository = {
DEFAULT_BRANCH = "master";
};
server = {
DOMAIN = "git.mgaction.town";
SSH_DOMAIN = "git.mgaction.town";
ROOT_URL = "http://git.mgaction.town/";
HTTP_PORT = 3000;
START_SSH_SERVER = true;
SSH_PORT = 2222;
SSH_LISTEN_PORT = 2222;
};
service = {
DISABLE_REGISTRATION = true;
};
};
};
networking.firewall.allowedTCPPorts = [ 2222 ];
users.users.gitea.extraGroups = [ "users" ];
}
+10 -8
View File
@@ -1,16 +1,18 @@
{ ... }:
{ lib, ... }:
# Audiobookshelf audiobook/podcast server.
# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall),
# but reachable over the trusted tailscale0 interface and via localhost (caddy).
# Library/media paths are set in the web UI — point them at /mnt/data/...
# Runs as user `audiobookshelf`; added to `users` so it can read group-owned
# library dirs on the RAID.
{
services.jellyfin = {
enable = true;
dataDir = "/mnt/data/AppData/jellyfin";
cacheDir = "${cfg.dataDir}/cache";
cacheDir = "/mnt/data/AppData/jellyfin/cache";
};
users.users.jellyfin.extraGroups = [ "users" ];
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing
# trickplay thumbnails into stray new show folders it invented itself,
# owned jellyfin:jellyfin 700, invisible to every other service sharing
# the library (cinephage, mediamanager, ...). New files/dirs it creates
# from here on inherit group "users" (library roots are setgid, see the
# one-time chmod g+s done by hand) and stay group-writable.
systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002";
}
+54
View File
@@ -0,0 +1,54 @@
{ config, ... }:
# MediaManager — media request/library manager. Module comes from the
# community flake input `mediamanager-nix` (wired into jupiter's module list
# in flake.nix, NOT nixpkgs). Paired sops secret declared in
# hosts/jupiter/secrets.nix — without it the module mints+discards a random
# auth token_secret on every service restart, logging everyone out.
# Port 8010: 8000 is taken by audiobookshelf on this host.
{
services.media-manager = {
enable = true;
dataDir = "/mnt/data/AppData/mediamanager";
host = "0.0.0.0";
port = 8010;
postgres.enable = true;
environmentFile = config.sops.templates."mediamanager.env".path;
settings = {
misc = {
frontend_url = "http://mediamanager.jupiter.sol";
# Point straight at the existing library instead of the empty
# dirs under dataDir — group "users" needs write access (see
# the chmod note below); files stay darman-owned.
movie_directory = "/mnt/data/HighSeas/Movies";
tv_directory = "/mnt/data/HighSeas/Shows";
image_directory = "/mnt/data/HighSeas/images";
torrent_directory = "/mnt/data/HighSeas/Downloads";
};
auth.admin_emails = [ "mail@erik-s.dev" ];
# API keys are secret -> env vars via the sops template below, not here
# (settings.* is rendered to a world-readable file in /nix/store).
torrents.sabnzbd = {
enabled = true;
host = "http://localhost";
port = 8085;
};
indexers.prowlarr = {
enabled = true;
url = "http://localhost:9696";
};
};
};
sops.templates."mediamanager.env".content = ''
MEDIAMANAGER_AUTH__TOKEN_SECRET=${config.sops.placeholder.mediamanager_token_secret}
MEDIAMANAGER_TORRENTS__SABNZBD__API_KEY=${config.sops.placeholder.sabnzbd_api_key}
MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key}
'';
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 on disk —
# group has no write bit. media-manager is in "users" (below); the dirs
# themselves were chmod g+w by hand once (not declarative — see CLAUDE.md
# gotchas), since this is pre-existing data, not something tmpfiles owns.
users.users.media-manager.extraGroups = [ "users" ];
}
+30
View File
@@ -0,0 +1,30 @@
{ ... }:
# Prowlarr — indexer manager (usenet + torrent), feeds SABnzbd/MediaManager.
# services.prowlarr.dataDir is left at its module default (/var/lib/prowlarr)
# on purpose: passing a *custom* dataDir makes the upstream module bind-mount
# it and force-reset the outer dir to 0700 root:root via a tmpfiles rule on
# every boot (nixos/modules/services/misc/servarr/prowlarr.nix) — that stomps
# DynamicUser's access to pre-existing content and causes intermittent
# "unable to open database file".
#
# Instead we bind-mount the real (migrated-from-ZimaOS) config dir straight
# onto the module's own default path, so prowlarr never sees a "custom"
# dataDir and none of that logic triggers. DynamicUser+StateDirectory then
# recursively chowns the pre-existing content to its assigned uid on first
# activation, same as it does for a fresh install — no manual chown needed.
#
# Mount onto /var/lib/private/prowlarr, NOT the public /var/lib/prowlarr:
# DynamicUser+StateDirectory keeps real data at .../private/<name> and makes
# the public path a symlink to it; binding onto the public path turns it into
# a mountpoint systemd then can't rename during its migrate-on-start dance
# ("Device or resource busy", exit 238/STATE_DIRECTORY).
{
services.prowlarr.enable = true;
fileSystems."/var/lib/private/prowlarr" = {
device = "/mnt/data/AppData/prowlarr/config";
fsType = "none";
options = [ "bind" ];
};
}
+18
View File
@@ -0,0 +1,18 @@
{ ... }:
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr. dataDir points
# at the config migrated from the old ZimaOS docker stack (indexers/download
# client/history already set up). Unlike prowlarr, this module uses a static
# `radarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
# module's own default path — since we point at a pre-existing migrated dir,
# chown it by hand once after first deploy:
# chown -R radarr:radarr /mnt/data/AppData/radarr/config
{
services.radarr = {
enable = true;
dataDir = "/mnt/data/AppData/radarr/config";
};
# Write access to the shared library/downloads dirs (owned darman:users).
users.users.radarr.extraGroups = [ "users" ];
}
+46
View File
@@ -0,0 +1,46 @@
{ ... }:
# SABnzbd — usenet downloader. Reuses the config migrated from the old
# ZimaOS docker stack (servers/API key/history already set up) by pointing
# straight at the real ini instead of generating a fresh NixOS-managed one.
# Runs as the module's default dedicated `sabnzbd` system user — after first
# deploy, chown the migrated config dir to it (see README/CLAUDE notes):
# chown -R sabnzbd:sabnzbd /mnt/data/AppData/sabnzbd/config
{
services.sabnzbd = {
enable = true;
configFile = "/mnt/data/AppData/sabnzbd/config/sabnzbd.ini";
allowConfigWrite = true; # real pre-existing state — let sabnzbd keep saving it
};
# Write access to the shared downloads dir (owned darman:users on disk).
users.users.sabnzbd.extraGroups = [ "users" ];
# SABnzbd hardcodes completed job folders to 0700 on every single job —
# independent of the ini's `umask` setting (that only covers files
# created during unpack, not the enclosing per-job dir). setgid on
# Downloads keeps the *group* as "users", but group perm bits still come
# back zeroed, locking out cinephage/mediamanager. Sweep it clean instead
# of fighting SABnzbd's own behavior.
systemd.services.fix-downloads-perms = {
description = "Fix group perms SABnzbd resets on completed downloads";
serviceConfig.Type = "oneshot";
script = ''
find /mnt/data/HighSeas/Downloads \
! -group users -exec chgrp users {} + 2>/dev/null || true
find /mnt/data/HighSeas/Downloads -type d ! -perm -g+rwx \
-exec chmod g+rwx {} + 2>/dev/null || true
find /mnt/data/HighSeas/Downloads -type f ! -perm -g+rw \
-exec chmod g+rw {} + 2>/dev/null || true
'';
};
systemd.timers.fix-downloads-perms = {
description = "Periodically fix group perms under HighSeas/Downloads";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "1m";
OnUnitActiveSec = "2m";
};
};
}
+30
View File
@@ -0,0 +1,30 @@
{ ... }:
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talks to
# Sonarr/Radarr to fulfill requests. Fresh install, no migrated data.
#
# configDir is left at the module default (/var/lib/seerr) on purpose:
# systemd's StateDirectory= auto-chown for DynamicUser only applies to that
# exact default path, not to an arbitrary custom one (same class of issue as
# prowlarr.nix, worked around the same way) — bind-mount AppData onto the
# default path instead of overriding configDir, so it stays on the RAID array
# and survives an OS-disk reinstall like every other service's data.
#
# Mount onto /var/lib/private/seerr, NOT the public /var/lib/seerr:
# DynamicUser+StateDirectory keeps real data at .../private/<name> and makes
# the public path a symlink to it; binding onto the public path turns it into
# a mountpoint systemd then can't rename during its migrate-on-start dance
# ("Device or resource busy", exit 238/STATE_DIRECTORY).
{
services.seerr.enable = true;
fileSystems."/var/lib/private/seerr" = {
device = "/mnt/data/AppData/seerr";
fsType = "none";
options = [ "bind" ];
};
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/seerr 0755 darman users -"
];
}
+18
View File
@@ -0,0 +1,18 @@
{ ... }:
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr. dataDir points at
# the config migrated from the old ZimaOS docker stack (indexers/download
# client/history already set up). Unlike prowlarr, this module uses a static
# `sonarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
# module's own default path — since we point at a pre-existing migrated dir,
# chown it by hand once after first deploy:
# chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config
{
services.sonarr = {
enable = true;
dataDir = "/mnt/data/AppData/sonarr/config";
};
# Write access to the shared library/downloads dirs (owned darman:users).
users.users.sonarr.extraGroups = [ "users" ];
}