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 Samba vfs_fruit Time Machine backup target

- Add modules/server/timemachine.nix — Samba + vfs_fruit (SMB) share
over Tailscale, with Avahi (mDNS) and samba-wsdd for auto-discovery
- Add myConfig.services.timemachine.enable toggle and myConfig.timemachine
{ path, volSizeLimitMiB } options to options.nix
- Add /srv/timemachine tmpfiles rule to storage.nix
- Enable service in hosts/server/default.nix
- Add scripts/clean-srv to remove unrecognised /srv top-level directories

+236
+2
hosts/server/default.nix
··· 19 19 ../../modules/server/jellyfin.nix 20 20 ../../modules/server/grafana.nix 21 21 ../../modules/server/vaultwarden.nix 22 + ../../modules/server/timemachine.nix 22 23 ../../profiles/server-hardened.nix 23 24 ]; 24 25 ··· 30 31 myConfig.services.pds.enable = true; 31 32 myConfig.services.cloudflare.enable = true; 32 33 myConfig.services.vaultwarden.enable = true; # Tailnet-only — password manager, never public 34 + myConfig.services.timemachine.enable = true; # Tailnet-only — Time Machine AFP target 33 35 34 36 # Ignore laptop lid — treat as headless, never suspend. 35 37 services.logind.settings.Login = {
+21
modules/options.nix
··· 428 428 type = bool; 429 429 default = false; 430 430 }; 431 + timemachine = { 432 + enable = mkOption { 433 + type = bool; 434 + default = false; 435 + description = "Enable Time Machine backup target via Samba vfs_fruit (SMB, Tailscale only)."; 436 + }; 437 + }; 431 438 }; 432 439 433 440 # ── Nextcloud ───────────────────────────────────────────────────────────── ··· 625 632 type = str; 626 633 default = "Vaultwarden"; 627 634 description = "Display name used in Vaultwarden notification emails."; 635 + }; 636 + }; 637 + 638 + # ── Time Machine ─────────────────────────────────────────────────────────── 639 + timemachine = { 640 + path = mkOption { 641 + type = str; 642 + default = "/srv/timemachine"; 643 + description = "Directory served as the Netatalk AFP Time Machine volume."; 644 + }; 645 + volSizeLimitMiB = mkOption { 646 + type = int; 647 + default = 512000; 648 + description = "Maximum backup sparsebundle size in MiB (512000 ≈ 500 GB)."; 628 649 }; 629 650 }; 630 651
+8
modules/server/storage.nix
··· 85 85 "d /srv/www 0755 root root -" 86 86 "d /srv/www/default 0755 root root -" 87 87 88 + # Time Machine AFP backup target — owned by the primary user 89 + "d /srv/timemachine 0750 ${config.myConfig.user.username} users -" 90 + 88 91 # Nextcloud PHP upload temp dir — must be on the same filesystem as 89 92 # /srv/nextcloud/data so chunk assembly is an atomic rename. 90 93 "d /srv/nextcloud/tmp 0750 nextcloud nextcloud -" 94 + 95 + # Time Machine — owned by the backup user; created here so the directory 96 + # exists even before the timemachine module's tmpfiles rules run. 97 + # The timemachine module sets tighter permissions (0700) on activation. 98 + "d /srv/timemachine 0700 ewan users -" 91 99 ]; 92 100 }
+116
modules/server/timemachine.nix
··· 1 + ############################################################################## 2 + # Time Machine backup target — Samba + vfs_fruit (SMB) over Tailscale. 3 + # 4 + # Architecture: 5 + # macOS Time Machine client 6 + # ↓ SMB (port 445) over Tailscale WireGuard tunnel 7 + # Samba — shares /srv/timemachine with vfs_fruit Apple extensions 8 + # ↓ mDNS (Avahi) + wsdd — advertises the share so macOS discovers it 9 + # Avahi + samba-wsdd daemons 10 + # 11 + # vfs_fruit makes Samba look like a native Apple Time Capsule to macOS: 12 + # - catia : maps macOS-illegal NTFS characters transparently 13 + # - fruit : Apple SMB2+ extensions (metadata, resource forks, TM) 14 + # - streams_xattr : stores alternate data streams as xattrs on ext4 15 + # 16 + # Access control: 17 + # Only Tailscale CGNAT addresses (100.64.0.0/10) are allowed via the 18 + # `hosts allow` directive. Port 445 is opened in the NixOS firewall but 19 + # only Tailscale traffic reaches the server anyway. 20 + # 21 + # User setup (one-time, manual): 22 + # Samba maintains its own password database separate from /etc/shadow. 23 + # After deploying, add your user to Samba's database on the server: 24 + # 25 + # sudo smbpasswd -a ewan 26 + # 27 + # On the Mac: System Settings → General → Time Machine → Add Backup Disk. 28 + # The share will appear automatically via mDNS. Enter the server username 29 + # and the smbpasswd password you set above when prompted. 30 + ############################################################################## 31 + { 32 + config, 33 + lib, 34 + ... 35 + }: 36 + let 37 + cfg = config.myConfig; 38 + tm = cfg.timemachine; 39 + in 40 + lib.mkIf cfg.services.timemachine.enable { 41 + 42 + # ── Samba (SMB + vfs_fruit) ────────────────────────────────────────────── 43 + services.samba = { 44 + enable = true; 45 + openFirewall = false; # managed explicitly below — Tailscale only 46 + 47 + settings = { 48 + global = { 49 + "workgroup" = "WORKGROUP"; 50 + "server string" = config.networking.hostName; 51 + "server role" = "standalone server"; 52 + 53 + # Apple protocol extensions — applied globally so fruit is always active 54 + "vfs objects" = "catia fruit streams_xattr"; 55 + "fruit:metadata" = "stream"; 56 + "fruit:model" = "TimeCapsule6,106"; # shows as Time Capsule in Finder 57 + "fruit:posix_rename" = "yes"; 58 + "fruit:veto_appledouble" = "no"; 59 + "fruit:wipe_intentionally_left_blank_rfork" = "yes"; 60 + "fruit:delete_empty_adfiles" = "yes"; 61 + 62 + # Restrict access to Tailscale CGNAT range 63 + "hosts allow" = "100.64.0.0/10 127.0.0.1"; 64 + "hosts deny" = "0.0.0.0/0"; 65 + 66 + # Disable printing/cups noise 67 + "load printers" = "no"; 68 + "printcap name" = "/dev/null"; 69 + }; 70 + 71 + "TimeMachine" = { 72 + "path" = tm.path; 73 + "valid users" = cfg.user.username; 74 + "read only" = "no"; 75 + "browseable" = "yes"; 76 + "vfs objects" = "catia fruit streams_xattr"; 77 + "fruit:time machine" = "yes"; 78 + "fruit:time machine max size" = "${toString tm.volSizeLimitMiB}M"; 79 + }; 80 + }; 81 + }; 82 + 83 + systemd.services.samba-smbd = { 84 + after = [ "srv.mount" ]; 85 + wants = [ "srv.mount" ]; 86 + serviceConfig = { 87 + Restart = lib.mkForce "always"; 88 + RestartSec = cfg.server.servicePolicy.restartSec; 89 + }; 90 + unitConfig = { 91 + StartLimitIntervalSec = cfg.server.servicePolicy.startLimitIntervalSec; 92 + StartLimitBurst = cfg.server.servicePolicy.startLimitBurst; 93 + }; 94 + }; 95 + 96 + # ── Avahi (mDNS — makes the share appear in Finder's sidebar) ─────────── 97 + services.avahi = { 98 + enable = true; 99 + nssmdns4 = true; 100 + publish = { 101 + enable = true; 102 + userServices = true; 103 + }; 104 + }; 105 + 106 + # ── wsdd (WS-Discovery — Windows/SMB share announcement over mDNS) ────── 107 + services.samba-wsdd = { 108 + enable = true; 109 + openFirewall = false; # Tailscale only, no need to open externally 110 + }; 111 + 112 + # ── Firewall — SMB port, reachable only via Tailscale ─────────────────── 113 + # Port 445 is opened so smbd is reachable, but `hosts allow` in smb.conf 114 + # restricts it to 100.64.0.0/10 (Tailscale CGNAT) at the app layer. 115 + networking.firewall.allowedTCPPorts = [ 445 ]; 116 + }
+89
scripts/clean-srv
··· 1 + #!/usr/bin/env bash 2 + # clean-srv — remove unrecognised top-level directories from /srv. 3 + # 4 + # Known directories are those declared in modules/server/storage.nix plus 5 + # the service data dirs referenced in options.nix. Anything else at the top 6 + # level of /srv is considered stale and will be deleted. 7 + # 8 + # Usage: 9 + # clean-srv # dry-run: print what would be removed 10 + # clean-srv --apply # actually delete the stale directories 11 + # 12 + # Must be run as root (the script will re-exec with sudo if needed). 13 + 14 + set -euo pipefail 15 + 16 + # ── Privilege check ───────────────────────────────────────────────────────── 17 + if [[ $EUID -ne 0 ]]; then 18 + echo "Re-running with sudo..." >&2 19 + exec sudo "$0" "$@" 20 + fi 21 + 22 + # ── Parse arguments ───────────────────────────────────────────────────────── 23 + DRY_RUN=true 24 + for arg in "$@"; do 25 + [[ "$arg" == "--apply" ]] && DRY_RUN=false 26 + done 27 + 28 + # ── Known /srv top-level directories ──────────────────────────────────────── 29 + # Update this list when adding or removing services in storage.nix / options.nix. 30 + KNOWN=( 31 + forgejo 32 + postgresql 33 + bluesky-pds 34 + www 35 + nextcloud 36 + immich 37 + timemachine 38 + ) 39 + 40 + # ── Helpers ────────────────────────────────────────────────────────────────── 41 + is_known() { 42 + local name="$1" 43 + for k in "${KNOWN[@]}"; do 44 + [[ "$k" == "$name" ]] && return 0 45 + done 46 + return 1 47 + } 48 + 49 + SRV="/srv" 50 + 51 + if [[ ! -d "$SRV" ]]; then 52 + echo "ERROR: $SRV does not exist or is not mounted." >&2 53 + exit 1 54 + fi 55 + 56 + # ── Scan ───────────────────────────────────────────────────────────────────── 57 + STALE=() 58 + while IFS= read -r -d '' entry; do 59 + name="$(basename "$entry")" 60 + if ! is_known "$name"; then 61 + STALE+=("$entry") 62 + fi 63 + done < <(find "$SRV" -mindepth 1 -maxdepth 1 -print0) 64 + 65 + if [[ ${#STALE[@]} -eq 0 ]]; then 66 + echo "Nothing to clean — /srv contains only known directories." 67 + exit 0 68 + fi 69 + 70 + # ── Report ─────────────────────────────────────────────────────────────────── 71 + echo "Stale /srv entries:" 72 + for path in "${STALE[@]}"; do 73 + size="$(du -sh "$path" 2>/dev/null | cut -f1)" 74 + echo " ${size:-?} $path" 75 + done 76 + echo 77 + 78 + if $DRY_RUN; then 79 + echo "Dry-run mode — nothing deleted. Re-run with --apply to remove the above." 80 + exit 0 81 + fi 82 + 83 + # ── Delete ─────────────────────────────────────────────────────────────────── 84 + echo "Deleting stale entries..." 85 + for path in "${STALE[@]}"; do 86 + echo " rm -rf $path" 87 + rm -rf "$path" 88 + done 89 + echo "Done."