My nix-darwin and NixOS config
3
fork

Configure Feed

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

refactor: reorganise modules/server into infra/ and services/ subtrees

Split the flat modules/server/ directory into a structured hierarchy:

infra/
network/ — caddy, cloudflare-tunnel, firewall, split-dns
security/ — ssh, intrusion
system/ — storage, packages, services, maintenance,
disable-noise, hardware-health, smartd template
services/
atproto/ — pds, pds-gatekeeper, pds-landing/
fediverse/ — sharkey, sharkey-precise
forge/ — forgejo
media/ — immich, jellyfin
nextcloud/ — nextcloud, nextcloud-meta/
observability/ — grafana, grafana-dashboard.json
utils/ — vaultwarden, timemachine

Also move profiles/ → modules/profiles/, settings/darwin/ →
modules/darwin/settings/, and settings/plasma/ → home/settings/,
eliminating the top-level profiles/ and settings/ directories.

Update all import paths and sopsFile references accordingly.

+94 -72
+47 -25
docs/secrets.md
··· 1 1 # Secrets Management 2 2 3 - Encrypted secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix) using [age](https://age-encryption.org/) as the encryption backend. Secrets are decrypted at activation time and referenced via `config.sops.secrets.<name>.path` (system-level) or `config.sops.secrets.<name>.path` inside home-manager. 3 + Encrypted secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix) using 4 + [age](https://age-encryption.org/) as the encryption backend. Secrets are decrypted at 5 + activation time and referenced via `config.sops.secrets.<name>.path`. 4 6 5 7 ## How it works 6 8 7 - - Each secret file is committed to the repo **already encrypted** — it is useless without the private key. 8 - - `sops` uses the rules in `.sops.yaml` at the repo root to know which age keys can decrypt each file. 9 - - On NixOS hosts, `sops-nix` decrypts secrets at activation using the host's `/etc/ssh/ssh_host_ed25519_key` (automatically converted to an age key). No separate key file is needed on the system itself. 9 + - Each secret file is committed to the repo **already encrypted** — it is useless without 10 + the private key. 11 + - `sops` uses the rules in `.sops.yaml` at the repo root to know which age keys can decrypt 12 + each file. 13 + - On NixOS hosts, `sops-nix` decrypts secrets at activation using the host's 14 + `/etc/ssh/ssh_host_ed25519_key` (automatically converted to an age key). No separate key 15 + file is needed on the system itself. 10 16 - On macOS, your personal age key (`~/.config/age/keys.txt`) is used. 11 17 12 18 ## Key inventory ··· 14 20 Keys are declared in `.sops.yaml`: 15 21 16 22 | Name | Type | Location | 17 - |---|---|---| 23 + | --- | --- | --- | 18 24 | `ewan` | User (personal) | `~/.config/age/keys.txt` | 19 25 | `macmini` | Host | `/etc/ssh/ssh_host_ed25519_key` on macmini | 20 26 | `laptop` | Host | `/etc/ssh/ssh_host_ed25519_key` on laptop | ··· 48 54 rm /tmp/my-secret.env 49 55 ``` 50 56 51 - `sops` reads `.sops.yaml` automatically and encrypts for the correct recipients based on the filename. 57 + `sops` reads `.sops.yaml` automatically and encrypts for the correct recipients based on 58 + the filename. 52 59 53 60 ### 2. Declare it in the NixOS module that uses it 54 61 55 62 ```nix 56 - # e.g. modules/my-service.nix 63 + # e.g. modules/server/services/my-service/my-service.nix 57 64 sops.secrets."my-secret.env" = { 58 - sopsFile = ../secrets/my-secret.env; 65 + sopsFile = ../../../../secrets/my-secret.env; 59 66 format = "binary"; # for env files / raw content 60 67 owner = "my-service"; 61 68 mode = "0400"; ··· 66 73 67 74 ```nix 68 75 sops.secrets."my-service/api-key" = { 69 - sopsFile = ../secrets/my-service.yaml; 76 + sopsFile = ../../../../secrets/my-service.yaml; 70 77 # sops-nix extracts the "my-service/api-key" key automatically 71 78 }; 72 79 ``` ··· 97 104 }; 98 105 ``` 99 106 100 - The `path` field places the decrypted file at a specific location rather than `/run/user/<uid>/secrets/`. 107 + The `path` field places the decrypted file at a specific location rather than 108 + `/run/user/<uid>/secrets/`. 101 109 102 110 ## Adding a new host 103 111 104 - When a new machine is provisioned, its SSH host key must be added to `.sops.yaml` so it can decrypt the secrets it needs. 112 + When a new machine is provisioned, its SSH host key must be added to `.sops.yaml` so it 113 + can decrypt the secrets it needs. 105 114 106 115 ```bash 107 116 # 1. Get the host's age public key from its SSH host key ··· 121 130 ## Existing secrets 122 131 123 132 | File | Purpose | Accessible by | 124 - |---|---|---| 125 - | `secrets/docker-config.json` | Docker Hub credentials | all hosts | 126 - | `secrets/claude.json` | Claude API / config | all hosts | 127 - | `secrets/duckdns.tar.gz` | DuckDNS config bundle | all hosts | 133 + | --- | --- | --- | 128 134 | `secrets/pds.env` | Bluesky PDS runtime secrets | ewan + server | 129 - | `secrets/matrix.env` | Matrix Synapse secrets | ewan + server | 130 135 | `secrets/forgejo.env` | Forgejo `SECRET_KEY` etc. | ewan + server | 131 136 | `secrets/cf-tunnel.json` | Cloudflare tunnel credentials | ewan + server | 137 + | `secrets/cloudflare-acme.env` | Cloudflare DNS-01 token (ewancroft.uk) | ewan + server | 138 + | `secrets/cloudflare-acme-croft-click.env` | Cloudflare DNS-01 token (croft.click) | ewan + server | 139 + | `secrets/cloudflare.token` | Cloudflare API token | ewan + server | 140 + | `secrets/forgejo-user-token` | Forgejo user API token | ewan + server | 141 + | `secrets/meilisearch-master-key` | Meilisearch master key | ewan + server | 142 + | `secrets/nextcloud-admin-pass` | Nextcloud initial admin password | ewan + server | 143 + | `secrets/nextcloud-smtp-pass` | Nextcloud SMTP (Resend) API key | ewan + server | 144 + | `secrets/smartd-smtp-pass` | smartd alert SMTP (Resend) API key | ewan + server | 145 + | `secrets/tailscale-auth-key` | Tailscale auth key | ewan + server | 146 + | `secrets/vaultwarden.env` | Vaultwarden admin token + SMTP key | ewan + server | 132 147 133 148 ## Security rules 134 149 135 - 1. `~/.config/age/keys.txt` is your personal private key — treat it like an SSH private key. Never commit it. 136 - 2. Sync it to other machines via `scp` over Tailscale: `scp ~/.config/age/keys.txt ewan@laptop:~/.config/age/keys.txt` 137 - 3. Encrypted secret files (in `secrets/`) **are** committed to git — they are useless without a matching private key. 138 - 4. Host keys are derived from the host's SSH `ed25519` host key and are never stored anywhere beyond the key itself. 150 + 1. `~/.config/age/keys.txt` is your personal private key — treat it like an SSH private 151 + key. Never commit it. 152 + 2. Sync it to other machines via `scp` over Tailscale: 153 + `scp ~/.config/age/keys.txt ewan@laptop:~/.config/age/keys.txt` 154 + 3. Encrypted secret files (in `secrets/`) **are** committed to git — they are useless 155 + without a matching private key. 156 + 4. Host keys are derived from the host's SSH `ed25519` host key and are never stored 157 + anywhere beyond the key itself. 139 158 140 159 ## Troubleshooting 141 160 142 161 | Error | Cause | Fix | 143 - |---|---|---| 162 + | --- | --- | --- | 144 163 | `no matching keys` | Secret not encrypted for this key | Add key to `.sops.yaml`, run `sops updatekeys <file>` | 145 164 | `key not found` | Missing `~/.config/age/keys.txt` or host SSH key | Restore key or re-derive host key | 146 165 | `failed to decrypt` | Wrong key or corrupted file | Verify key with `age-keygen --to-public-key` | 147 166 | Secret path is empty | sops-nix activation failed | Check `journalctl -b \| grep sops` | 148 - | `attribute '<user>' missing` at eval time | Secret has `owner = "<user>"` but the service uses `DynamicUser` in its systemd unit, so no static entry exists in `config.users.users` | Declare the user/group explicitly (see below) | 167 + | `attribute '<user>' missing` at eval time | Secret owner uses `DynamicUser` — no static user entry exists | Declare the user/group explicitly (see below) | 149 168 150 169 ### DynamicUser services and sops-nix 151 170 152 - Some NixOS services (including `cloudflared`) use systemd's `DynamicUser = true`, which means they do **not** create a static entry in `config.users.users`. sops-nix tries to derive `group` from that attribute at evaluation time and fails with `attribute '<user>' missing`. 171 + Some NixOS services (including `cloudflared`) use systemd's `DynamicUser = true`, which 172 + means they do **not** create a static entry in `config.users.users`. sops-nix tries to 173 + derive `group` from that attribute at evaluation time and fails with 174 + `attribute '<user>' missing`. 153 175 154 176 Fix: explicitly declare the user and group alongside the secret: 155 177 ··· 161 183 users.groups.cloudflared = { }; 162 184 163 185 sops.secrets."cf-tunnel.json" = { 164 - sopsFile = ../secrets/cf-tunnel.json; 186 + sopsFile = ../../../../secrets/cf-tunnel.json; 165 187 format = "binary"; 166 188 owner = "cloudflared"; 167 189 group = "cloudflared"; # must be set explicitly — cannot be derived from DynamicUser ··· 169 191 }; 170 192 ``` 171 193 172 - This pattern is already applied in `modules/cloudflare-tunnel.nix`. 194 + This pattern is already applied in `modules/server/infra/network/cloudflare-tunnel.nix`.
+5 -5
home/programs/kde.nix
··· 14 14 in 15 15 { 16 16 imports = [ 17 - ../../settings/plasma 17 + ../settings/plasma.nix 18 18 ]; 19 19 20 20 # ── Wallpaper systemd user service ───────────────────────────────────────── 21 21 systemd.user.services.set-plasma-wallpaper = { 22 22 Unit = { 23 23 Description = "Apply KDE Plasma wallpaper"; 24 - After = [ "plasma-plasmashell.service" ]; 25 - PartOf = [ "graphical-session.target" ]; 24 + After = [ "plasma-plasmashell.service" ]; 25 + PartOf = [ "graphical-session.target" ]; 26 26 }; 27 27 Service = { 28 - Type = "oneshot"; 28 + Type = "oneshot"; 29 29 ExecStart = "${pkgs.kdePackages.plasma-workspace}/bin/plasma-apply-wallpaperimage ${wallpaper}"; 30 30 # Restart on failure in case plasmashell wasn't fully ready yet 31 - Restart = "on-failure"; 31 + Restart = "on-failure"; 32 32 RestartSec = "2s"; 33 33 }; 34 34 Install = {
+14 -14
hosts/server/default.nix
··· 9 9 imports = [ 10 10 ./minimal-hardware.nix 11 11 ../../modules/users.nix 12 - ../../modules/server/caddy.nix 13 - ../../modules/server/split-dns.nix 14 - ../../modules/server/cloudflare-tunnel.nix 15 - ../../modules/server/pds.nix 16 - ../../modules/server/pds-gatekeeper.nix 17 - ../../modules/server/forgejo.nix 18 - ../../modules/server/nextcloud.nix 19 - ../../modules/server/immich.nix 20 - ../../modules/server/jellyfin.nix 21 - ../../modules/server/grafana.nix 22 - ../../modules/server/vaultwarden.nix 23 - ../../modules/server/timemachine.nix 24 - ../../modules/server/sharkey.nix 25 - ../../profiles/server-hardened.nix 12 + ../../modules/server/infra/network/caddy.nix 13 + ../../modules/server/infra/network/split-dns.nix 14 + ../../modules/server/infra/network/cloudflare-tunnel.nix 15 + ../../modules/server/services/atproto/pds.nix 16 + ../../modules/server/services/atproto/pds-gatekeeper.nix 17 + ../../modules/server/services/forge/forgejo.nix 18 + ../../modules/server/services/nextcloud/nextcloud.nix 19 + ../../modules/server/services/media/immich.nix 20 + ../../modules/server/services/media/jellyfin.nix 21 + ../../modules/server/services/observability/grafana.nix 22 + ../../modules/server/services/utils/vaultwarden.nix 23 + ../../modules/server/services/utils/timemachine.nix 24 + ../../modules/server/services/fediverse/sharkey.nix 25 + ../../modules/profiles/server-hardened.nix 26 26 ]; 27 27 28 28 # Service toggles
+1 -1
modules/darwin/system.nix
··· 7 7 in 8 8 { 9 9 imports = [ 10 - ../../settings/darwin 10 + ./settings 11 11 ]; 12 12 13 13 # Keyboard – driven from myConfig.darwin.keyboard
+11
modules/profiles/server-base.nix
··· 1 + { ... }: 2 + { 3 + imports = [ 4 + ../server/infra/system/packages.nix 5 + ../server/infra/system/storage.nix 6 + ../server/infra/system/services.nix 7 + ../server/infra/system/maintenance.nix 8 + ../server/infra/system/hardware-health.nix 9 + ../server/infra/system/disable-noise.nix 10 + ]; 11 + }
+2 -2
modules/server/caddy.nix modules/server/infra/network/caddy.nix
··· 55 55 # containing the raw token value only (no KEY= prefix, no trailing newline). 56 56 # The token needs Zone.DNS edit permission for ewancroft.uk. 57 57 sops.secrets."cloudflare-acme.env" = lib.mkIf hasTailnet { 58 - sopsFile = ../../secrets/cloudflare-acme.env; 58 + sopsFile = ../../../../secrets/cloudflare-acme.env; 59 59 format = "binary"; 60 60 owner = "acme"; 61 61 mode = "0440"; ··· 63 63 64 64 # Separate token for the croft.click zone — needed to issue *.pds.croft.click. 65 65 sops.secrets."cloudflare-acme-croft-click.env" = { 66 - sopsFile = ../../secrets/cloudflare-acme-croft-click.env; 66 + sopsFile = ../../../../secrets/cloudflare-acme-croft-click.env; 67 67 format = "binary"; 68 68 owner = "acme"; 69 69 mode = "0440";
+2 -2
modules/server/cloudflare-tunnel.nix modules/server/infra/network/cloudflare-tunnel.nix
··· 84 84 # JSON credentials file created by `cloudflared tunnel create server`. 85 85 # Encrypt with: sops --encrypt --age <age-pubkey> cf-tunnel.json > secrets/cf-tunnel.json 86 86 sops.secrets."cf-tunnel.json" = { 87 - sopsFile = ../../secrets/cf-tunnel.json; 87 + sopsFile = ../../../../secrets/cf-tunnel.json; 88 88 format = "binary"; 89 89 owner = "cloudflared"; 90 90 group = "cloudflared"; ··· 94 94 }; 95 95 96 96 sops.secrets."cloudflare.token" = { 97 - sopsFile = ../../secrets/cloudflare.token; 97 + sopsFile = ../../../../secrets/cloudflare.token; 98 98 format = "binary"; 99 99 owner = "root"; 100 100 };
modules/server/disable-noise.nix modules/server/infra/system/disable-noise.nix
modules/server/firewall.nix modules/server/infra/network/firewall.nix
+1 -1
modules/server/forgejo.nix modules/server/services/forge/forgejo.nix
··· 29 29 lib.mkIf cfg.services.forgejo.enable { 30 30 31 31 sops.secrets."forgejo.env" = { 32 - sopsFile = ../../secrets/forgejo.env; 32 + sopsFile = ../../../../secrets/forgejo.env; 33 33 format = "dotenv"; 34 34 owner = "forgejo"; 35 35 group = "forgejo";
modules/server/grafana-dashboard.json modules/server/services/observability/grafana-dashboard.json
+1 -1
modules/server/grafana.nix modules/server/services/observability/grafana.nix
··· 95 95 # then: sops secrets/nextcloud-metrics-token (binary, raw token). 96 96 # Only activated when myConfig.server.grafana.nextcloudMetrics = true. 97 97 sops.secrets."nextcloud-metrics-token" = lib.mkIf (cfg.services.nextcloud.enable && cfg.server.grafana.nextcloudMetrics) { 98 - sopsFile = ../../secrets/nextcloud-metrics-token; 98 + sopsFile = ../../../../secrets/nextcloud-metrics-token; 99 99 format = "binary"; 100 100 owner = "nextcloud-exporter"; 101 101 mode = "0440";
+1 -1
modules/server/hardware-health.nix modules/server/infra/system/hardware-health.nix
··· 100 100 in 101 101 { 102 102 sops.secrets."smartd-smtp-pass" = { 103 - sopsFile = ../../secrets/smartd-smtp-pass; 103 + sopsFile = ../../../../secrets/smartd-smtp-pass; 104 104 format = "binary"; 105 105 owner = "root"; 106 106 mode = "0400";
modules/server/immich.nix modules/server/services/media/immich.nix
modules/server/intrusion.nix modules/server/infra/security/intrusion.nix
modules/server/jellyfin.nix modules/server/services/media/jellyfin.nix
modules/server/maintenance.nix modules/server/infra/system/maintenance.nix
modules/server/nextcloud-meta/legal.txt modules/server/services/nextcloud/nextcloud-meta/legal.txt
modules/server/nextcloud-meta/privacy.txt modules/server/services/nextcloud/nextcloud-meta/privacy.txt
+2 -2
modules/server/nextcloud.nix modules/server/services/nextcloud/nextcloud.nix
··· 52 52 lib.mkIf cfg.services.nextcloud.enable { 53 53 54 54 sops.secrets."nextcloud-smtp-pass" = { 55 - sopsFile = ../../secrets/nextcloud-smtp-pass; 55 + sopsFile = ../../../../secrets/nextcloud-smtp-pass; 56 56 format = "binary"; 57 57 owner = "nextcloud"; 58 58 group = "nextcloud"; ··· 60 60 }; 61 61 62 62 sops.secrets."nextcloud-admin-pass" = { 63 - sopsFile = ../../secrets/nextcloud-admin-pass; 63 + sopsFile = ../../../../secrets/nextcloud-admin-pass; 64 64 format = "binary"; 65 65 owner = "nextcloud"; 66 66 group = "nextcloud";
+1 -1
modules/server/packages.nix modules/server/infra/system/packages.nix
··· 5 5 }: 6 6 let 7 7 cfg = config.myConfig; 8 - resolvePackages = (import ../../lib).resolveFrom pkgs; 8 + resolvePackages = (import ../../../../lib).resolveFrom pkgs; 9 9 in 10 10 { 11 11 environment.systemPackages =
modules/server/pds-gatekeeper.nix modules/server/services/atproto/pds-gatekeeper.nix
modules/server/pds-landing/.gitignore modules/server/services/atproto/pds-landing/.gitignore
modules/server/pds-landing/.npmrc modules/server/services/atproto/pds-landing/.npmrc
modules/server/pds-landing/.prettierignore modules/server/services/atproto/pds-landing/.prettierignore
modules/server/pds-landing/.prettierrc modules/server/services/atproto/pds-landing/.prettierrc
modules/server/pds-landing/README.md modules/server/services/atproto/pds-landing/README.md
modules/server/pds-landing/package.json modules/server/services/atproto/pds-landing/package.json
modules/server/pds-landing/pnpm-lock.yaml modules/server/services/atproto/pds-landing/pnpm-lock.yaml
modules/server/pds-landing/pnpm-workspace.yaml modules/server/services/atproto/pds-landing/pnpm-workspace.yaml
modules/server/pds-landing/src/app.d.ts modules/server/services/atproto/pds-landing/src/app.d.ts
modules/server/pds-landing/src/app.html modules/server/services/atproto/pds-landing/src/app.html
modules/server/pds-landing/src/lib/assets/favicon.svg modules/server/services/atproto/pds-landing/src/lib/assets/favicon.svg
modules/server/pds-landing/src/lib/index.ts modules/server/services/atproto/pds-landing/src/lib/index.ts
modules/server/pds-landing/src/routes/+layout.svelte modules/server/services/atproto/pds-landing/src/routes/+layout.svelte
modules/server/pds-landing/src/routes/+layout.ts modules/server/services/atproto/pds-landing/src/routes/+layout.ts
modules/server/pds-landing/src/routes/+page.svelte modules/server/services/atproto/pds-landing/src/routes/+page.svelte
modules/server/pds-landing/src/routes/layout.css modules/server/services/atproto/pds-landing/src/routes/layout.css
modules/server/pds-landing/svelte.config.js modules/server/services/atproto/pds-landing/svelte.config.js
modules/server/pds-landing/tsconfig.json modules/server/services/atproto/pds-landing/tsconfig.json
modules/server/pds-landing/vite.config.ts modules/server/services/atproto/pds-landing/vite.config.ts
+1 -1
modules/server/pds.nix modules/server/services/atproto/pds.nix
··· 57 57 lib.mkIf cfg.services.pds.enable { 58 58 59 59 sops.secrets."pds.env" = { 60 - sopsFile = ../../secrets/pds.env; 60 + sopsFile = ../../../../secrets/pds.env; 61 61 format = "dotenv"; 62 62 owner = "pds"; 63 63 group = "pds";
modules/server/services.nix modules/server/infra/system/services.nix
modules/server/sharkey-precise.nix modules/server/services/fediverse/sharkey-precise.nix
+1 -1
modules/server/sharkey.nix modules/server/services/fediverse/sharkey.nix
··· 402 402 users.groups.meilisearch = { }; 403 403 404 404 sops.secrets."meilisearch-master-key" = { 405 - sopsFile = ../../secrets/meilisearch-master-key; 405 + sopsFile = ../../../../secrets/meilisearch-master-key; 406 406 format = "binary"; 407 407 owner = "meilisearch"; 408 408 group = "meilisearch";
modules/server/smartd-alert-template.html modules/server/infra/system/smartd-alert-template.html
modules/server/split-dns.nix modules/server/infra/network/split-dns.nix
modules/server/ssh.nix modules/server/infra/security/ssh.nix
modules/server/storage.nix modules/server/infra/system/storage.nix
modules/server/timemachine.nix modules/server/services/utils/timemachine.nix
+1 -1
modules/server/vaultwarden.nix modules/server/services/utils/vaultwarden.nix
··· 45 45 46 46 # ── Secrets ─────────────────────────────────────────────────────────────── 47 47 sops.secrets."vaultwarden.env" = { 48 - sopsFile = ../../secrets/vaultwarden.env; 48 + sopsFile = ../../../../secrets/vaultwarden.env; 49 49 format = "dotenv"; 50 50 owner = "vaultwarden"; 51 51 group = "vaultwarden";
-11
profiles/server-base.nix
··· 1 - { ... }: 2 - { 3 - imports = [ 4 - ../modules/server/packages.nix 5 - ../modules/server/storage.nix 6 - ../modules/server/services.nix 7 - ../modules/server/maintenance.nix 8 - ../modules/server/hardware-health.nix 9 - ../modules/server/disable-noise.nix 10 - ]; 11 - }
+3 -3
profiles/server-hardened.nix modules/profiles/server-hardened.nix
··· 2 2 { 3 3 imports = [ 4 4 ./server-base.nix 5 - ../modules/server/ssh.nix 6 - ../modules/server/intrusion.nix 7 - ../modules/server/firewall.nix 5 + ../server/infra/security/ssh.nix 6 + ../server/infra/security/intrusion.nix 7 + ../server/infra/network/firewall.nix 8 8 ]; 9 9 10 10 # Headless — no display manager, no getty on tty1.
settings/darwin/default.nix modules/darwin/settings/default.nix
settings/plasma/default.nix home/settings/plasma.nix