My nix-darwin and NixOS config
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add immich and jellyfin services

Integrates Immich and Jellyfin into the server config. Both services
are gated behind Tailscale and configured to share the Nextcloud
data directory for media storage. Also increases Nix GC heap size.

+319 -17
+18 -12
home/programs/zsh.nix
··· 91 91 "sudo nix-collect-garbage -d" 92 92 else 93 93 "sudo nix-collect-garbage -d && nix-collect-garbage -d"; 94 - } 95 - ); 94 + }); 96 95 97 96 initContent = '' 98 97 # Display system info on new shell ··· 137 136 setopt PUSHD_MINUS 138 137 ''; 139 138 140 - profileExtra = 141 - '' 142 - [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" 143 - export PATH="$PATH:$HOME/.local/bin" 144 - '' 145 - + lib.optionalString isDarwin '' 146 - [ -f "$HOME/.deno/env" ] && . "$HOME/.deno/env" 147 - [ -x "/opt/homebrew/bin/brew" ] && eval "$(/opt/homebrew/bin/brew shellenv)" 148 - [ -f "$HOME/.orbstack/shell/init.zsh" ] && source "$HOME/.orbstack/shell/init.zsh" 149 - ''; 139 + sessionVariables = { 140 + # Nix uses the Boehm GC internally. The default 384 MiB initial heap is 141 + # too small when evaluating large configs (e.g. `nix flake check 142 + # --all-systems`), causing repeated GC expansion warnings. 1 GiB avoids 143 + # that without meaningfully affecting smaller evaluations. 144 + GC_INITIAL_HEAP_SIZE = toString (1 * 1024 * 1024 * 1024); 145 + }; 146 + 147 + profileExtra = '' 148 + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" 149 + export PATH="$PATH:$HOME/.local/bin" 150 + '' 151 + + lib.optionalString isDarwin '' 152 + [ -f "$HOME/.deno/env" ] && . "$HOME/.deno/env" 153 + [ -x "/opt/homebrew/bin/brew" ] && eval "$(/opt/homebrew/bin/brew shellenv)" 154 + [ -f "$HOME/.orbstack/shell/init.zsh" ] && source "$HOME/.orbstack/shell/init.zsh" 155 + ''; 150 156 }; 151 157 }
+5 -1
hosts/server/default.nix
··· 14 14 ../../modules/pds.nix 15 15 ../../modules/forgejo.nix 16 16 ../../modules/nextcloud.nix 17 + ../../modules/immich.nix 18 + ../../modules/jellyfin.nix 17 19 ../../profiles/server-hardened.nix 18 20 ]; 19 21 20 22 # Service toggles 21 23 myConfig.services.forgejo.enable = true; 22 - myConfig.services.nextcloud.enable = true; 24 + myConfig.services.nextcloud.enable = true; # Tailnet-only — not in CF tunnel 25 + myConfig.services.immich.enable = true; # Tailnet-only — not in CF tunnel 26 + myConfig.services.jellyfin.enable = true; # Tailnet-only — not in CF tunnel 23 27 myConfig.services.pds.enable = true; 24 28 myConfig.services.cloudflare.enable = true; 25 29
+6 -3
modules/cloudflare-tunnel.nix
··· 30 30 cfg = config.myConfig; 31 31 32 32 # Build ingress routes based on enabled services. 33 + # NOTE: Nextcloud is intentionally excluded — it is gated to the Tailnet only. 34 + # Its Caddy port is not in allowedTCPPorts, and tailscale0 is a trusted 35 + # interface, so only Tailnet peers can reach it. Update the Cloudflare DNS 36 + # record for ${cfg.nextcloud.hostname} to an A record pointing to the 37 + # server's Tailscale IP (`tailscale ip -4`) so the hostname still resolves 38 + # correctly for Tailnet clients. 33 39 ingressRoutes = 34 40 lib.optionalAttrs cfg.services.pds.enable { 35 41 ${cfg.pds.hostname} = "http://127.0.0.1:${toString cfg.pds.caddyPort}"; ··· 37 43 } 38 44 // lib.optionalAttrs cfg.services.forgejo.enable { 39 45 ${cfg.forgejo.hostname} = "http://127.0.0.1:${toString cfg.forgejo.caddyPort}"; 40 - } 41 - // lib.optionalAttrs cfg.services.nextcloud.enable { 42 - ${cfg.nextcloud.hostname} = "http://127.0.0.1:${toString cfg.nextcloud.caddyPort}"; 43 46 }; 44 47 in 45 48 lib.mkIf cfg.services.cloudflare.enable {
+107
modules/immich.nix
··· 1 + ############################################################################## 2 + # Immich — NixOS module. 3 + # 4 + # Architecture: 5 + # Immich server (127.0.0.1:cfg.immich.port) 6 + # ↑ reverse proxy 7 + # Caddy (http://immich.ewancroft.uk:cfg.immich.caddyPort — Tailnet-only) 8 + # ✗ NOT in Cloudflare Tunnel — Tailnet access only 9 + # 10 + # Access model (Tailnet-only): 11 + # Port cfg.immich.caddyPort is NOT in allowedTCPPorts, so it is blocked 12 + # on all external interfaces. The firewall marks tailscale0 as a 13 + # trustedInterface, meaning Tailnet peers bypass the firewall entirely. 14 + # Point immich.ewancroft.uk at the server's Tailscale IP in Cloudflare DNS 15 + # (A record, proxying disabled — grey cloud). 16 + # 17 + # Storage — shared with Nextcloud: 18 + # Media lives at cfg.immich.mediaDir, which defaults to 19 + # /srv/nextcloud/data/ewan/files/Photos — inside the Nextcloud user files 20 + # tree. This means: 21 + # • Photos uploaded via the Immich mobile app appear in Nextcloud. 22 + # • Photos synced to Nextcloud via the desktop client appear in Immich 23 + # (picked up by the nextcloud-files-scan timer, which runs daily). 24 + # 25 + # Permissions: 26 + # The `immich` user is added to the `nextcloud` group. The Photos directory 27 + # is created with mode 2770 (setgid) so all files created inside inherit 28 + # the nextcloud group, keeping Nextcloud happy when it scans them. 29 + # Nextcloud's own data directory is group-traversable (0750) by default, 30 + # so group membership is sufficient for access. 31 + # 32 + # Database & cache: 33 + # PostgreSQL and Redis are managed locally by NixOS (separate instances 34 + # from Nextcloud's — no conflicts). 35 + # 36 + # First-run: 37 + # Navigate to http://immich.ewancroft.uk:<caddyPort> (or the raw Tailscale 38 + # IP) and complete the onboarding wizard to create your admin account. 39 + # Then configure the library path to cfg.immich.mediaDir from the UI. 40 + ############################################################################## 41 + { 42 + config, 43 + lib, 44 + ... 45 + }: 46 + let 47 + cfg = config.myConfig; 48 + im = cfg.immich; 49 + in 50 + lib.mkIf cfg.services.immich.enable { 51 + 52 + # ── User / group ───────────────────────────────────────────────────────── 53 + # Add immich to the nextcloud group so it can read/write inside the 54 + # Nextcloud data tree with the setgid directories created below. 55 + users.users.immich = { 56 + extraGroups = [ "nextcloud" ]; 57 + }; 58 + 59 + # ── Storage ─────────────────────────────────────────────────────────────── 60 + # Create the Photos directory inside the Nextcloud user files tree. 61 + # Mode 2770: owner=nextcloud, group=nextcloud, setgid so new files 62 + # inherit the group — Nextcloud's scanner expects group-owned files. 63 + systemd.tmpfiles.rules = [ 64 + # Ensure the Media parent (shared with Jellyfin) exists even if jellyfin.nix 65 + # is disabled. Referencing cfg.jellyfin.mediaDir directly avoids builtins.dirOf, 66 + # which strips option context and causes an activation-script derivation warning. 67 + "d ${cfg.jellyfin.mediaDir} 2770 nextcloud nextcloud -" 68 + "d ${im.mediaDir} 2770 nextcloud nextcloud -" 69 + ]; 70 + 71 + # ── Immich service ──────────────────────────────────────────────────────── 72 + services.immich = { 73 + enable = true; 74 + 75 + # Bind to localhost only — Caddy is the sole entry point. 76 + host = "127.0.0.1"; 77 + port = im.port; 78 + 79 + # Point at the shared directory inside the Nextcloud data tree. 80 + mediaLocation = im.mediaDir; 81 + 82 + # Local PostgreSQL — NixOS creates a dedicated DB and user automatically. 83 + database.enable = true; 84 + 85 + # Redis for job queues and caching (separate instance from Nextcloud's). 86 + redis.enable = true; 87 + }; 88 + 89 + # Wait for /srv (and Nextcloud's initial setup) before starting Immich, 90 + # so the Photos directory is guaranteed to exist with correct ownership. 91 + systemd.services.immich-server = { 92 + after = [ 93 + "srv.mount" 94 + "nextcloud-setup.service" 95 + ]; 96 + wants = [ "srv.mount" ]; 97 + }; 98 + 99 + # ── Caddy reverse proxy (Tailnet-only) ──────────────────────────────────── 100 + # Port cfg.immich.caddyPort is NOT in allowedTCPPorts. Access is restricted 101 + # to Tailnet peers via the trustedInterfaces firewall rule on tailscale0. 102 + services.caddy.virtualHosts."http://${im.hostname}:${toString im.caddyPort}" = { 103 + extraConfig = '' 104 + reverse_proxy http://127.0.0.1:${toString im.port} 105 + ''; 106 + }; 107 + }
+112
modules/jellyfin.nix
··· 1 + ############################################################################## 2 + # Jellyfin — NixOS module. 3 + # 4 + # Architecture: 5 + # Jellyfin server (127.0.0.1:cfg.jellyfin.port) 6 + # ↑ reverse proxy 7 + # Caddy (http://jellyfin.ewancroft.uk:cfg.jellyfin.caddyPort — Tailnet-only) 8 + # ✗ NOT in Cloudflare Tunnel — Tailnet access only 9 + # 10 + # Access model (Tailnet-only): 11 + # Port cfg.jellyfin.caddyPort is NOT in allowedTCPPorts, so it is blocked 12 + # on all external interfaces. The firewall marks tailscale0 as a 13 + # trustedInterface, meaning Tailnet peers bypass the firewall entirely. 14 + # Point jellyfin.ewancroft.uk at the server's Tailscale IP in Cloudflare 15 + # DNS (A record, proxying disabled — grey cloud). 16 + # 17 + # Storage — shared with Nextcloud: 18 + # Media lives at cfg.jellyfin.mediaDir, which defaults to 19 + # /srv/nextcloud/data/ewan/files/Media — inside the Nextcloud user files 20 + # tree. This means: 21 + # • Media uploaded via Nextcloud (desktop/mobile sync) is immediately 22 + # available to add as a Jellyfin library. 23 + # • The nextcloud-files-scan timer (daily) keeps Nextcloud aware of any 24 + # files written directly to this path. 25 + # 26 + # After first-run, add libraries from the Jellyfin web UI pointing at 27 + # subdirectories of cfg.jellyfin.mediaDir, e.g.: 28 + # /srv/nextcloud/data/ewan/files/Media/Movies 29 + # /srv/nextcloud/data/ewan/files/Media/TV 30 + # /srv/nextcloud/data/ewan/files/Media/Music 31 + # 32 + # Permissions: 33 + # The `jellyfin` user is added to the `nextcloud` group. The Media 34 + # directory is created with mode 2770 (setgid) so new files created by 35 + # either service inherit the nextcloud group. Jellyfin only reads media — 36 + # it does not write to cfg.jellyfin.mediaDir. 37 + # 38 + # Config/metadata: 39 + # Jellyfin's own config, metadata, and plugins live at cfg.jellyfin.dataDir 40 + # (/var/lib/jellyfin by default) — managed entirely by the NixOS module, 41 + # separate from the /srv volume. 42 + # 43 + # First-run: 44 + # Navigate to http://jellyfin.ewancroft.uk:<caddyPort> (or the raw 45 + # Tailscale IP) and complete the setup wizard. Add media libraries pointing 46 + # at subdirectories of cfg.jellyfin.mediaDir. 47 + ############################################################################## 48 + { 49 + config, 50 + lib, 51 + ... 52 + }: 53 + let 54 + cfg = config.myConfig; 55 + jf = cfg.jellyfin; 56 + in 57 + lib.mkIf cfg.services.jellyfin.enable { 58 + 59 + # ── User / group ───────────────────────────────────────────────────────── 60 + # Add jellyfin to the nextcloud group so it can traverse and read inside 61 + # the Nextcloud data tree. 62 + users.users.jellyfin = { 63 + extraGroups = [ "nextcloud" ]; 64 + }; 65 + 66 + # ── Storage ─────────────────────────────────────────────────────────────── 67 + # Create the Media root inside the Nextcloud user files tree. 68 + # Mode 2770: setgid ensures files created here inherit the nextcloud group. 69 + # Subdirectories (Movies, TV, Music etc.) should be created manually or by 70 + # a future module — Jellyfin does not create library directories itself. 71 + systemd.tmpfiles.rules = [ 72 + "d ${jf.mediaDir} 2770 nextcloud nextcloud -" 73 + "d ${jf.mediaDir}/Movies 2770 nextcloud nextcloud -" 74 + "d ${jf.mediaDir}/TV 2770 nextcloud nextcloud -" 75 + "d ${jf.mediaDir}/Music 2770 nextcloud nextcloud -" 76 + ]; 77 + 78 + # ── Jellyfin service ────────────────────────────────────────────────────── 79 + services.jellyfin = { 80 + enable = true; 81 + 82 + # Config, metadata, and plugins on the system disk. 83 + dataDir = jf.dataDir; 84 + 85 + # Do NOT open Jellyfin's default ports (8096/8920) — Caddy is the sole 86 + # entry point and only tailscale0 traffic can reach the Caddy port. 87 + openFirewall = false; 88 + }; 89 + 90 + # Wait for /srv (and Nextcloud's initial setup) before starting Jellyfin, 91 + # so the Media directory is guaranteed to exist with correct ownership. 92 + systemd.services.jellyfin = { 93 + after = [ 94 + "srv.mount" 95 + "nextcloud-setup.service" 96 + ]; 97 + wants = [ "srv.mount" ]; 98 + serviceConfig = { 99 + Restart = "on-failure"; 100 + RestartSec = cfg.server.servicePolicy.restartSec; 101 + }; 102 + }; 103 + 104 + # ── Caddy reverse proxy (Tailnet-only) ──────────────────────────────────── 105 + # Port cfg.jellyfin.caddyPort is NOT in allowedTCPPorts. Access is restricted 106 + # to Tailnet peers via the trustedInterfaces firewall rule on tailscale0. 107 + services.caddy.virtualHosts."http://${jf.hostname}:${toString jf.caddyPort}" = { 108 + extraConfig = '' 109 + reverse_proxy http://127.0.0.1:${toString jf.port} 110 + ''; 111 + }; 112 + }
+1 -1
modules/nextcloud.nix
··· 192 192 }; 193 193 194 194 # Periodically scan the data directory so files added directly to /srv 195 - # (e.g. large archives copied via rsync) are picked up by Nextcloud. 195 + # or written by co-located services (Immich, Jellyfin) are picked up by Nextcloud. 196 196 systemd.services.nextcloud-files-scan = { 197 197 description = "Nextcloud periodic file scan"; 198 198 after = [ "nextcloud-setup.service" ];
+70
modules/options.nix
··· 408 408 type = bool; 409 409 default = false; 410 410 }; 411 + immich.enable = mkOption { 412 + type = bool; 413 + default = false; 414 + }; 415 + jellyfin.enable = mkOption { 416 + type = bool; 417 + default = false; 418 + }; 411 419 cloudflare.enable = mkOption { 412 420 type = bool; 413 421 default = false; ··· 456 464 type = str; 457 465 default = "server.ewancroft.uk"; 458 466 }; 467 + }; 468 + }; 469 + 470 + # ── Immich ──────────────────────────────────────────────────────────────── 471 + immich = { 472 + hostname = mkOption { 473 + type = str; 474 + default = "immich.ewancroft.uk"; 475 + description = "Hostname used by Caddy for Immich — should resolve to the server's Tailscale IP via a Cloudflare A record."; 476 + }; 477 + port = mkOption { 478 + type = int; 479 + default = 2283; 480 + description = "Internal Immich server port — not exposed, Caddy proxies to this."; 481 + }; 482 + caddyPort = mkOption { 483 + type = int; 484 + default = 3004; 485 + description = "Caddy virtual host port — accessible only via Tailnet (not in allowedTCPPorts)."; 486 + }; 487 + mediaDir = mkOption { 488 + type = str; 489 + default = "/srv/nextcloud/data/ewan/files/Media/Photos"; 490 + description = '' 491 + Primary media directory for Immich. Defaults to inside the Nextcloud 492 + user files tree so that photos synced via Nextcloud clients are visible in 493 + Immich, and vice-versa (the nextcloud-files-scan timer picks up writes daily). 494 + Override this if nextcloud.dataDir or nextcloud.adminUser differ from defaults.''; 495 + }; 496 + }; 497 + 498 + # ── Jellyfin ────────────────────────────────────────────────────────────── 499 + jellyfin = { 500 + hostname = mkOption { 501 + type = str; 502 + default = "jellyfin.ewancroft.uk"; 503 + description = "Hostname used by Caddy for Jellyfin — should resolve to the server's Tailscale IP via a Cloudflare A record."; 504 + }; 505 + port = mkOption { 506 + type = int; 507 + default = 8096; 508 + description = "Internal Jellyfin HTTP port — not exposed, Caddy proxies to this."; 509 + }; 510 + caddyPort = mkOption { 511 + type = int; 512 + default = 3005; 513 + description = "Caddy virtual host port — accessible only via Tailnet (not in allowedTCPPorts)."; 514 + }; 515 + dataDir = mkOption { 516 + type = str; 517 + default = "/var/lib/jellyfin"; 518 + description = "Jellyfin config/metadata/plugin directory — managed by the NixOS jellyfin service."; 519 + }; 520 + mediaDir = mkOption { 521 + type = str; 522 + default = "/srv/nextcloud/data/ewan/files/Media"; 523 + description = '' 524 + Root directory created for Jellyfin media libraries. Defaults to inside 525 + the Nextcloud user files tree so media uploaded via Nextcloud clients is 526 + immediately available to Jellyfin. Add libraries (movies, TV, music etc.) 527 + as subdirectories of this path from the Jellyfin web UI after first-run. 528 + Override this if nextcloud.dataDir or nextcloud.adminUser differ from defaults.''; 459 529 }; 460 530 }; 461 531