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.
···1414 ../../modules/pds.nix
1515 ../../modules/forgejo.nix
1616 ../../modules/nextcloud.nix
1717+ ../../modules/immich.nix
1818+ ../../modules/jellyfin.nix
1719 ../../profiles/server-hardened.nix
1820 ];
19212022 # Service toggles
2123 myConfig.services.forgejo.enable = true;
2222- myConfig.services.nextcloud.enable = true;
2424+ myConfig.services.nextcloud.enable = true; # Tailnet-only — not in CF tunnel
2525+ myConfig.services.immich.enable = true; # Tailnet-only — not in CF tunnel
2626+ myConfig.services.jellyfin.enable = true; # Tailnet-only — not in CF tunnel
2327 myConfig.services.pds.enable = true;
2428 myConfig.services.cloudflare.enable = true;
2529
+6-3
modules/cloudflare-tunnel.nix
···3030 cfg = config.myConfig;
31313232 # Build ingress routes based on enabled services.
3333+ # NOTE: Nextcloud is intentionally excluded — it is gated to the Tailnet only.
3434+ # Its Caddy port is not in allowedTCPPorts, and tailscale0 is a trusted
3535+ # interface, so only Tailnet peers can reach it. Update the Cloudflare DNS
3636+ # record for ${cfg.nextcloud.hostname} to an A record pointing to the
3737+ # server's Tailscale IP (`tailscale ip -4`) so the hostname still resolves
3838+ # correctly for Tailnet clients.
3339 ingressRoutes =
3440 lib.optionalAttrs cfg.services.pds.enable {
3541 ${cfg.pds.hostname} = "http://127.0.0.1:${toString cfg.pds.caddyPort}";
···3743 }
3844 // lib.optionalAttrs cfg.services.forgejo.enable {
3945 ${cfg.forgejo.hostname} = "http://127.0.0.1:${toString cfg.forgejo.caddyPort}";
4040- }
4141- // lib.optionalAttrs cfg.services.nextcloud.enable {
4242- ${cfg.nextcloud.hostname} = "http://127.0.0.1:${toString cfg.nextcloud.caddyPort}";
4346 };
4447in
4548lib.mkIf cfg.services.cloudflare.enable {
+107
modules/immich.nix
···11+##############################################################################
22+# Immich — NixOS module.
33+#
44+# Architecture:
55+# Immich server (127.0.0.1:cfg.immich.port)
66+# ↑ reverse proxy
77+# Caddy (http://immich.ewancroft.uk:cfg.immich.caddyPort — Tailnet-only)
88+# ✗ NOT in Cloudflare Tunnel — Tailnet access only
99+#
1010+# Access model (Tailnet-only):
1111+# Port cfg.immich.caddyPort is NOT in allowedTCPPorts, so it is blocked
1212+# on all external interfaces. The firewall marks tailscale0 as a
1313+# trustedInterface, meaning Tailnet peers bypass the firewall entirely.
1414+# Point immich.ewancroft.uk at the server's Tailscale IP in Cloudflare DNS
1515+# (A record, proxying disabled — grey cloud).
1616+#
1717+# Storage — shared with Nextcloud:
1818+# Media lives at cfg.immich.mediaDir, which defaults to
1919+# /srv/nextcloud/data/ewan/files/Photos — inside the Nextcloud user files
2020+# tree. This means:
2121+# • Photos uploaded via the Immich mobile app appear in Nextcloud.
2222+# • Photos synced to Nextcloud via the desktop client appear in Immich
2323+# (picked up by the nextcloud-files-scan timer, which runs daily).
2424+#
2525+# Permissions:
2626+# The `immich` user is added to the `nextcloud` group. The Photos directory
2727+# is created with mode 2770 (setgid) so all files created inside inherit
2828+# the nextcloud group, keeping Nextcloud happy when it scans them.
2929+# Nextcloud's own data directory is group-traversable (0750) by default,
3030+# so group membership is sufficient for access.
3131+#
3232+# Database & cache:
3333+# PostgreSQL and Redis are managed locally by NixOS (separate instances
3434+# from Nextcloud's — no conflicts).
3535+#
3636+# First-run:
3737+# Navigate to http://immich.ewancroft.uk:<caddyPort> (or the raw Tailscale
3838+# IP) and complete the onboarding wizard to create your admin account.
3939+# Then configure the library path to cfg.immich.mediaDir from the UI.
4040+##############################################################################
4141+{
4242+ config,
4343+ lib,
4444+ ...
4545+}:
4646+let
4747+ cfg = config.myConfig;
4848+ im = cfg.immich;
4949+in
5050+lib.mkIf cfg.services.immich.enable {
5151+5252+ # ── User / group ─────────────────────────────────────────────────────────
5353+ # Add immich to the nextcloud group so it can read/write inside the
5454+ # Nextcloud data tree with the setgid directories created below.
5555+ users.users.immich = {
5656+ extraGroups = [ "nextcloud" ];
5757+ };
5858+5959+ # ── Storage ───────────────────────────────────────────────────────────────
6060+ # Create the Photos directory inside the Nextcloud user files tree.
6161+ # Mode 2770: owner=nextcloud, group=nextcloud, setgid so new files
6262+ # inherit the group — Nextcloud's scanner expects group-owned files.
6363+ systemd.tmpfiles.rules = [
6464+ # Ensure the Media parent (shared with Jellyfin) exists even if jellyfin.nix
6565+ # is disabled. Referencing cfg.jellyfin.mediaDir directly avoids builtins.dirOf,
6666+ # which strips option context and causes an activation-script derivation warning.
6767+ "d ${cfg.jellyfin.mediaDir} 2770 nextcloud nextcloud -"
6868+ "d ${im.mediaDir} 2770 nextcloud nextcloud -"
6969+ ];
7070+7171+ # ── Immich service ────────────────────────────────────────────────────────
7272+ services.immich = {
7373+ enable = true;
7474+7575+ # Bind to localhost only — Caddy is the sole entry point.
7676+ host = "127.0.0.1";
7777+ port = im.port;
7878+7979+ # Point at the shared directory inside the Nextcloud data tree.
8080+ mediaLocation = im.mediaDir;
8181+8282+ # Local PostgreSQL — NixOS creates a dedicated DB and user automatically.
8383+ database.enable = true;
8484+8585+ # Redis for job queues and caching (separate instance from Nextcloud's).
8686+ redis.enable = true;
8787+ };
8888+8989+ # Wait for /srv (and Nextcloud's initial setup) before starting Immich,
9090+ # so the Photos directory is guaranteed to exist with correct ownership.
9191+ systemd.services.immich-server = {
9292+ after = [
9393+ "srv.mount"
9494+ "nextcloud-setup.service"
9595+ ];
9696+ wants = [ "srv.mount" ];
9797+ };
9898+9999+ # ── Caddy reverse proxy (Tailnet-only) ────────────────────────────────────
100100+ # Port cfg.immich.caddyPort is NOT in allowedTCPPorts. Access is restricted
101101+ # to Tailnet peers via the trustedInterfaces firewall rule on tailscale0.
102102+ services.caddy.virtualHosts."http://${im.hostname}:${toString im.caddyPort}" = {
103103+ extraConfig = ''
104104+ reverse_proxy http://127.0.0.1:${toString im.port}
105105+ '';
106106+ };
107107+}
+112
modules/jellyfin.nix
···11+##############################################################################
22+# Jellyfin — NixOS module.
33+#
44+# Architecture:
55+# Jellyfin server (127.0.0.1:cfg.jellyfin.port)
66+# ↑ reverse proxy
77+# Caddy (http://jellyfin.ewancroft.uk:cfg.jellyfin.caddyPort — Tailnet-only)
88+# ✗ NOT in Cloudflare Tunnel — Tailnet access only
99+#
1010+# Access model (Tailnet-only):
1111+# Port cfg.jellyfin.caddyPort is NOT in allowedTCPPorts, so it is blocked
1212+# on all external interfaces. The firewall marks tailscale0 as a
1313+# trustedInterface, meaning Tailnet peers bypass the firewall entirely.
1414+# Point jellyfin.ewancroft.uk at the server's Tailscale IP in Cloudflare
1515+# DNS (A record, proxying disabled — grey cloud).
1616+#
1717+# Storage — shared with Nextcloud:
1818+# Media lives at cfg.jellyfin.mediaDir, which defaults to
1919+# /srv/nextcloud/data/ewan/files/Media — inside the Nextcloud user files
2020+# tree. This means:
2121+# • Media uploaded via Nextcloud (desktop/mobile sync) is immediately
2222+# available to add as a Jellyfin library.
2323+# • The nextcloud-files-scan timer (daily) keeps Nextcloud aware of any
2424+# files written directly to this path.
2525+#
2626+# After first-run, add libraries from the Jellyfin web UI pointing at
2727+# subdirectories of cfg.jellyfin.mediaDir, e.g.:
2828+# /srv/nextcloud/data/ewan/files/Media/Movies
2929+# /srv/nextcloud/data/ewan/files/Media/TV
3030+# /srv/nextcloud/data/ewan/files/Media/Music
3131+#
3232+# Permissions:
3333+# The `jellyfin` user is added to the `nextcloud` group. The Media
3434+# directory is created with mode 2770 (setgid) so new files created by
3535+# either service inherit the nextcloud group. Jellyfin only reads media —
3636+# it does not write to cfg.jellyfin.mediaDir.
3737+#
3838+# Config/metadata:
3939+# Jellyfin's own config, metadata, and plugins live at cfg.jellyfin.dataDir
4040+# (/var/lib/jellyfin by default) — managed entirely by the NixOS module,
4141+# separate from the /srv volume.
4242+#
4343+# First-run:
4444+# Navigate to http://jellyfin.ewancroft.uk:<caddyPort> (or the raw
4545+# Tailscale IP) and complete the setup wizard. Add media libraries pointing
4646+# at subdirectories of cfg.jellyfin.mediaDir.
4747+##############################################################################
4848+{
4949+ config,
5050+ lib,
5151+ ...
5252+}:
5353+let
5454+ cfg = config.myConfig;
5555+ jf = cfg.jellyfin;
5656+in
5757+lib.mkIf cfg.services.jellyfin.enable {
5858+5959+ # ── User / group ─────────────────────────────────────────────────────────
6060+ # Add jellyfin to the nextcloud group so it can traverse and read inside
6161+ # the Nextcloud data tree.
6262+ users.users.jellyfin = {
6363+ extraGroups = [ "nextcloud" ];
6464+ };
6565+6666+ # ── Storage ───────────────────────────────────────────────────────────────
6767+ # Create the Media root inside the Nextcloud user files tree.
6868+ # Mode 2770: setgid ensures files created here inherit the nextcloud group.
6969+ # Subdirectories (Movies, TV, Music etc.) should be created manually or by
7070+ # a future module — Jellyfin does not create library directories itself.
7171+ systemd.tmpfiles.rules = [
7272+ "d ${jf.mediaDir} 2770 nextcloud nextcloud -"
7373+ "d ${jf.mediaDir}/Movies 2770 nextcloud nextcloud -"
7474+ "d ${jf.mediaDir}/TV 2770 nextcloud nextcloud -"
7575+ "d ${jf.mediaDir}/Music 2770 nextcloud nextcloud -"
7676+ ];
7777+7878+ # ── Jellyfin service ──────────────────────────────────────────────────────
7979+ services.jellyfin = {
8080+ enable = true;
8181+8282+ # Config, metadata, and plugins on the system disk.
8383+ dataDir = jf.dataDir;
8484+8585+ # Do NOT open Jellyfin's default ports (8096/8920) — Caddy is the sole
8686+ # entry point and only tailscale0 traffic can reach the Caddy port.
8787+ openFirewall = false;
8888+ };
8989+9090+ # Wait for /srv (and Nextcloud's initial setup) before starting Jellyfin,
9191+ # so the Media directory is guaranteed to exist with correct ownership.
9292+ systemd.services.jellyfin = {
9393+ after = [
9494+ "srv.mount"
9595+ "nextcloud-setup.service"
9696+ ];
9797+ wants = [ "srv.mount" ];
9898+ serviceConfig = {
9999+ Restart = "on-failure";
100100+ RestartSec = cfg.server.servicePolicy.restartSec;
101101+ };
102102+ };
103103+104104+ # ── Caddy reverse proxy (Tailnet-only) ────────────────────────────────────
105105+ # Port cfg.jellyfin.caddyPort is NOT in allowedTCPPorts. Access is restricted
106106+ # to Tailnet peers via the trustedInterfaces firewall rule on tailscale0.
107107+ services.caddy.virtualHosts."http://${jf.hostname}:${toString jf.caddyPort}" = {
108108+ extraConfig = ''
109109+ reverse_proxy http://127.0.0.1:${toString jf.port}
110110+ '';
111111+ };
112112+}
+1-1
modules/nextcloud.nix
···192192 };
193193194194 # Periodically scan the data directory so files added directly to /srv
195195- # (e.g. large archives copied via rsync) are picked up by Nextcloud.
195195+ # or written by co-located services (Immich, Jellyfin) are picked up by Nextcloud.
196196 systemd.services.nextcloud-files-scan = {
197197 description = "Nextcloud periodic file scan";
198198 after = [ "nextcloud-setup.service" ];
+70
modules/options.nix
···408408 type = bool;
409409 default = false;
410410 };
411411+ immich.enable = mkOption {
412412+ type = bool;
413413+ default = false;
414414+ };
415415+ jellyfin.enable = mkOption {
416416+ type = bool;
417417+ default = false;
418418+ };
411419 cloudflare.enable = mkOption {
412420 type = bool;
413421 default = false;
···456464 type = str;
457465 default = "server.ewancroft.uk";
458466 };
467467+ };
468468+ };
469469+470470+ # ── Immich ────────────────────────────────────────────────────────────────
471471+ immich = {
472472+ hostname = mkOption {
473473+ type = str;
474474+ default = "immich.ewancroft.uk";
475475+ description = "Hostname used by Caddy for Immich — should resolve to the server's Tailscale IP via a Cloudflare A record.";
476476+ };
477477+ port = mkOption {
478478+ type = int;
479479+ default = 2283;
480480+ description = "Internal Immich server port — not exposed, Caddy proxies to this.";
481481+ };
482482+ caddyPort = mkOption {
483483+ type = int;
484484+ default = 3004;
485485+ description = "Caddy virtual host port — accessible only via Tailnet (not in allowedTCPPorts).";
486486+ };
487487+ mediaDir = mkOption {
488488+ type = str;
489489+ default = "/srv/nextcloud/data/ewan/files/Media/Photos";
490490+ description = ''
491491+ Primary media directory for Immich. Defaults to inside the Nextcloud
492492+ user files tree so that photos synced via Nextcloud clients are visible in
493493+ Immich, and vice-versa (the nextcloud-files-scan timer picks up writes daily).
494494+ Override this if nextcloud.dataDir or nextcloud.adminUser differ from defaults.'';
495495+ };
496496+ };
497497+498498+ # ── Jellyfin ──────────────────────────────────────────────────────────────
499499+ jellyfin = {
500500+ hostname = mkOption {
501501+ type = str;
502502+ default = "jellyfin.ewancroft.uk";
503503+ description = "Hostname used by Caddy for Jellyfin — should resolve to the server's Tailscale IP via a Cloudflare A record.";
504504+ };
505505+ port = mkOption {
506506+ type = int;
507507+ default = 8096;
508508+ description = "Internal Jellyfin HTTP port — not exposed, Caddy proxies to this.";
509509+ };
510510+ caddyPort = mkOption {
511511+ type = int;
512512+ default = 3005;
513513+ description = "Caddy virtual host port — accessible only via Tailnet (not in allowedTCPPorts).";
514514+ };
515515+ dataDir = mkOption {
516516+ type = str;
517517+ default = "/var/lib/jellyfin";
518518+ description = "Jellyfin config/metadata/plugin directory — managed by the NixOS jellyfin service.";
519519+ };
520520+ mediaDir = mkOption {
521521+ type = str;
522522+ default = "/srv/nextcloud/data/ewan/files/Media";
523523+ description = ''
524524+ Root directory created for Jellyfin media libraries. Defaults to inside
525525+ the Nextcloud user files tree so media uploaded via Nextcloud clients is
526526+ immediately available to Jellyfin. Add libraries (movies, TV, music etc.)
527527+ as subdirectories of this path from the Jellyfin web UI after first-run.
528528+ Override this if nextcloud.dataDir or nextcloud.adminUser differ from defaults.'';
459529 };
460530 };
461531