An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

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

docs: add MM-135 NixOS module design plan

Completed brainstorming session. Design includes:
- services.ezpds settings submodule (typed, snake_case, mirrors relay.toml)
- pkgs.formats.toml {} for TOML generation with null filtering
- configFile escape hatch for secret injection (agenix/sops-nix)
- nixosModules.default flake wrapper with lib.mkDefault package injection
- 3 implementation phases

authored by

Malpercio and committed by
Tangled
85bacbd7 900dea59

+204
+204
docs/design-plans/2026-03-09-MM-135.md
··· 1 + # MM-135: NixOS Module for Relay Deployment 2 + 3 + ## Summary 4 + 5 + This milestone packages the relay binary for deployment on NixOS by writing a standard NixOS module (`nix/module.nix`) and exposing it as a flake output. The module declares a `services.ezpds` option tree that lets operators configure the relay through the same NixOS configuration language they use for the rest of their system — no manual service files or config templates required. When enabled, the module generates a `relay.toml` from the declared settings using `pkgs.formats.toml {}`, creates a dedicated `ezpds` system user and group, and wires everything into a hardened systemd service that starts automatically and restarts on failure. 6 + 7 + The design has two layers. The bare `nix/module.nix` is a reusable module that works in any NixOS context but requires the operator to supply the relay package explicitly. A thin wrapper added to `flake.nix` as `nixosModules.default` closes that gap by injecting the flake's own relay build as the default package, making the common case a one-liner import. The `configFile` escape hatch allows operators who need secret injection (via tools like agenix or sops-nix) to bypass the Nix-store-generated TOML entirely and supply a file managed outside the store. 8 + 9 + ## Definition of Done 10 + 11 + `nix/module.nix` defines a NixOS module that runs the relay binary as a systemd service under a dedicated `ezpds` user/group. The module exposes `services.ezpds.enable`, `services.ezpds.package`, `services.ezpds.settings.*` (snake_case keys mirroring relay.toml), and `services.ezpds.configFile` as an escape hatch. It generates a `relay.toml` from the `settings` attrset using `pkgs.formats.toml {}` and passes it to the binary via `--config`. The module is importable as `imports = [ ./nix/module.nix ]` and exposed as `nixosModules.default` in `flake.nix`. A NixOS system with the module enabled and `services.ezpds.settings.data_dir` and `services.ezpds.settings.public_url` set can bring up the relay service via `nixos-rebuild switch`. 12 + 13 + ## Acceptance Criteria 14 + 15 + ### MM-135.AC1: Module options are correctly declared 16 + - **MM-135.AC1.1 Success:** `nix/module.nix` exists and is tracked by git (`git ls-files nix/module.nix` returns it) 17 + - **MM-135.AC1.2 Success:** `services.ezpds.enable`, `services.ezpds.package`, `services.ezpds.configFile`, and all five `services.ezpds.settings.*` fields are defined as NixOS options 18 + - **MM-135.AC1.3 Success:** Default values match relay.toml defaults — `bind_address = "0.0.0.0"`, `port = 8080`, `data_dir = "/var/lib/ezpds"`, `database_url = null` 19 + - **MM-135.AC1.4 Failure:** Nix evaluation fails with a missing-option error when `services.ezpds.settings.public_url` is not set 20 + - **MM-135.AC1.5 Failure:** Nix evaluation fails when `services.ezpds.package` is not set and the bare module (not the flake wrapper) is used 21 + 22 + ### MM-135.AC2: TOML config generation 23 + - **MM-135.AC2.1 Success:** Generated `relay.toml` contains `bind_address`, `port`, `data_dir`, and `public_url` when all are set 24 + - **MM-135.AC2.2 Success:** When `settings.database_url` is `null`, the generated TOML does not contain a `database_url` key 25 + - **MM-135.AC2.3 Success:** When `settings.database_url` is set to a string, the generated TOML contains that `database_url` key 26 + - **MM-135.AC2.4 Success:** The relay `ExecStart` line uses `--config <path>` pointing to the generated TOML derivation 27 + 28 + ### MM-135.AC3: `configFile` escape hatch 29 + - **MM-135.AC3.1 Success:** When `services.ezpds.configFile` is set to a path, `ExecStart` uses that path instead of the generated TOML 30 + - **MM-135.AC3.2 Success:** When `configFile` is set, changes to `settings.*` do not affect the `ExecStart` command 31 + 32 + ### MM-135.AC4: User/group and state directory 33 + - **MM-135.AC4.1 Success:** `users.users.ezpds` is defined as a system user with `group = "ezpds"` and `isSystemUser = true` 34 + - **MM-135.AC4.2 Success:** `users.groups.ezpds` is defined 35 + - **MM-135.AC4.3 Success:** `systemd.services.ezpds.serviceConfig.StateDirectory = "ezpds"` — systemd creates `/var/lib/ezpds` owned by `ezpds:ezpds` on first activation 36 + 37 + ### MM-135.AC5: `nixosModules.default` flake output 38 + - **MM-135.AC5.1 Success:** `nix flake show --accept-flake-config` lists `nixosModules.default` 39 + - **MM-135.AC5.2 Success:** When imported via `nixosModules.default`, `services.ezpds.package` defaults to the flake's `relay` build for the current system 40 + - **MM-135.AC5.3 Success:** The bare `nix/module.nix` is importable directly as `imports = [ ./nix/module.nix ]` without the flake wrapper, provided the user sets `services.ezpds.package` 41 + 42 + ### MM-135.AC6: Scope boundaries 43 + - **MM-135.AC6.1 Negative:** `nix/module.nix` defines no options for `[blobs]`, `[oauth]`, or `[iroh]` relay.toml sections (deferred to later milestones) 44 + 45 + ## Glossary 46 + 47 + - **NixOS module**: A Nix file following the NixOS module system's calling convention (`{ lib, pkgs, config, ... }:`) that declares options and a corresponding `config` block. The NixOS module system merges modules from many sources into a single evaluated system configuration. 48 + - **NixOS option**: A typed, documented configuration knob declared with `lib.mkOption` (or helpers like `lib.mkEnableOption`). Options have types, defaults, and descriptions; the module system enforces types at eval time. 49 + - **flake output**: A named attribute exported from a Nix flake's `outputs` function. Standard output schemas include `packages`, `devShells`, and `nixosModules`. Consumers reference outputs via `inputs.<name>.<output>`. 50 + - **`nixosModules`**: The conventional flake output key for NixOS modules. Unlike `packages`, it is not split per system — modules are system-agnostic and sit at the top level of outputs. 51 + - **`forEachSystem`**: A helper in this flake that iterates over supported CPU/OS pairs to produce per-system outputs (packages, dev shells). `nixosModules` is intentionally placed outside this helper because modules are not system-specific. 52 + - **`pkgs.formats.toml {}`**: A nixpkgs utility that serializes a Nix attrset to a TOML file and returns it as a store derivation. The resulting path is world-readable (all Nix store paths are). 53 + - **`lib.mkDefault`**: A NixOS priority marker that flags a value as a default that user configuration can override. The flake wrapper uses it so that `services.ezpds.package` can still be overridden by the operator even though the flake supplies a default. 54 + - **`lib.mkIf cfg.enable`**: A NixOS idiom that makes the entire `config` block conditional on the `enable` option. Nothing is added to the system if the service is not enabled. 55 + - **`lib.filterAttrs`**: A Nix standard library function that removes attrset keys based on a predicate. Used here to drop `database_url` from the generated TOML when its value is `null`. 56 + - **systemd service**: A Linux init system unit that manages a long-running process. The module produces a `systemd.services.ezpds` unit with restart behavior and sandboxing directives. 57 + - **`StateDirectory`**: A systemd `serviceConfig` key that tells systemd to create and own a subdirectory under `/var/lib/` on activation, chowned to the service's `User`/`Group`. Removes the need for a separate activation script. 58 + - **`ProtectSystem = "strict"`**: A systemd sandboxing directive that mounts the root filesystem read-only for the service. Combined with `StateDirectory`, the relay can only write to `/var/lib/ezpds`. 59 + - **`isSystemUser = true`**: A NixOS user option that creates a system account (no home directory, no login shell, UID below the normal user range). Appropriate for daemon processes. 60 + - **agenix / sops-nix**: Third-party NixOS secret-management tools that decrypt secrets at activation time and write them to paths outside the Nix store. Relevant because the Nix store is world-readable, making it unsuitable for passwords or tokens. 61 + - **relay.toml**: The TOML configuration file consumed by the relay binary (defined in MM-69). Its top-level keys (`bind_address`, `port`, `data_dir`, `public_url`, `database_url`) are mirrored directly as `services.ezpds.settings.*` options. 62 + - **crane**: A Nix library (`github:ipetkov/crane`) for building Cargo workspaces. Already used in `flake.nix` to produce the relay package that the flake wrapper injects as the default. 63 + - **`lib.types.str` vs `lib.types.path`**: NixOS distinguishes string options from path options. The `path` type coerces string literals to Nix store paths; `str` preserves the value verbatim. `data_dir` uses `str` to avoid accidentally rewriting `/var/lib/ezpds` to a store path. 64 + - **escape hatch (`configFile`)**: A pattern in NixOS module design where an override option lets operators bypass generated config entirely. Particularly useful when the generated file would land in the world-readable Nix store but needs to contain secrets. 65 + - **`nixos-rebuild switch`**: The NixOS command that evaluates the system configuration, builds any changed derivations, and atomically activates the new generation. The point at which Nix eval errors (such as a missing required option) surface. 66 + 67 + ## Architecture 68 + 69 + Two files change: `nix/module.nix` (new) and `flake.nix` (extended). 70 + 71 + ``` 72 + nix/ 73 + ├── docker.nix ← existing package expression (unchanged) 74 + └── module.nix ← new NixOS module 75 + flake.nix ← add nixosModules.default output 76 + ``` 77 + 78 + **Important distinction:** `docker.nix` is a package expression (`{ pkgs, relay }:` → derivation). NixOS modules use the standard module argument convention (`{ lib, pkgs, config, ... }`). These are different Nix calling conventions; `module.nix` follows the module convention. 79 + 80 + ### Options exposed under `services.ezpds` 81 + 82 + | Option | Type | Default | Notes | 83 + |---|---|---|---| 84 + | `enable` | `bool` | `false` | `lib.mkEnableOption` | 85 + | `package` | `package` | *(flake wrapper provides)* | Required unless using `nixosModules.default` | 86 + | `configFile` | `nullOr path` | `null` | Escape hatch — overrides `settings` entirely | 87 + | `settings.bind_address` | `str` | `"0.0.0.0"` | | 88 + | `settings.port` | `port` | `8080` | | 89 + | `settings.data_dir` | `str` | `"/var/lib/ezpds"` | Matches `StateDirectory`; `lib.types.str` not `path` (avoids Nix store coercion) | 90 + | `settings.public_url` | `str` | *(none — required)* | Nix eval fails if not set | 91 + | `settings.database_url` | `nullOr str` | `null` | Omitted from generated TOML when null; Rust derives from `data_dir` | 92 + 93 + ### Config file generation 94 + 95 + ```nix 96 + # Filter out database_url when null — Rust derives it from data_dir 97 + settingsToml = lib.filterAttrs (_: v: v != null) { 98 + inherit (cfg.settings) bind_address port data_dir public_url database_url; 99 + }; 100 + generatedConfigFile = (pkgs.formats.toml {}).generate "relay.toml" settingsToml; 101 + activeConfigFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile; 102 + ``` 103 + 104 + `pkgs.formats.toml {}` converts the attrset to a TOML file derivation in the Nix store. The `configFile` escape hatch replaces `generatedConfigFile` entirely — suitable for secret injection via agenix or sops-nix (since Nix store paths are world-readable). 105 + 106 + ### systemd service 107 + 108 + ```nix 109 + users.users.ezpds = { isSystemUser = true; group = "ezpds"; }; 110 + users.groups.ezpds = {}; 111 + 112 + systemd.services.ezpds = { 113 + description = "ezpds relay server"; 114 + wantedBy = [ "multi-user.target" ]; 115 + after = [ "network.target" ]; 116 + serviceConfig = { 117 + User = "ezpds"; 118 + Group = "ezpds"; 119 + ExecStart = "${cfg.package}/bin/relay --config ${activeConfigFile}"; 120 + StateDirectory = "ezpds"; # creates /var/lib/ezpds, chowned to ezpds 121 + StateDirectoryMode = "0750"; 122 + Restart = "on-failure"; 123 + PrivateTmp = true; 124 + ProtectSystem = "strict"; 125 + ProtectHome = true; 126 + NoNewPrivileges = true; 127 + }; 128 + }; 129 + ``` 130 + 131 + `StateDirectory = "ezpds"` instructs systemd to create `/var/lib/ezpds` owned by `User`/`Group`. Combined with `ProtectSystem = "strict"`, the root filesystem is read-only while the state directory remains writable — no `ReadWritePaths` override needed. 132 + 133 + ### `flake.nix` addition 134 + 135 + `nixosModules` is not per-system. It sits at the top level of outputs alongside `packages` and `devShells`: 136 + 137 + ```nix 138 + nixosModules.default = { lib, pkgs, ... }: { 139 + imports = [ ./nix/module.nix ]; 140 + config.services.ezpds.package = 141 + lib.mkDefault self.packages.${pkgs.system}.relay; 142 + }; 143 + ``` 144 + 145 + The wrapper module injects the flake's own relay build as the default `package` via `lib.mkDefault`, allowing users to override it. The bare module (`nix/module.nix`) remains usable standalone with `imports = [ ./nix/module.nix ]` as long as the user sets `services.ezpds.package` explicitly. 146 + 147 + ## Existing Patterns 148 + 149 + The `nix/` subdirectory was established in MM-66 as the canonical location for distribution-channel Nix expressions. `module.nix` continues that convention. 150 + 151 + `flake.nix` already uses a top-level outputs structure with `packages` and `devShells`; `nixosModules` is added at the same level following the same Nix flake schema. 152 + 153 + This design introduces the first NixOS module in the repository. NixOS modules use a different argument convention (`{ lib, pkgs, config, ... }`) than the package expressions in `nix/docker.nix` (`{ pkgs, relay }:`). This divergence is intentional and follows the NixOS module system's own conventions — mixing them would be incorrect. 154 + 155 + ## Implementation Phases 156 + 157 + <!-- START_PHASE_1 --> 158 + ### Phase 1: Write `nix/module.nix` 159 + **Goal:** Define the complete NixOS module with option declarations, TOML config generation, user/group creation, and the systemd service. 160 + 161 + **Components:** 162 + - `nix/module.nix` — new file; NixOS module (`{ lib, pkgs, config, ... }:`) with `options.services.ezpds` option set and `config` block guarded by `lib.mkIf cfg.enable` 163 + 164 + **Dependencies:** None (MM-65 relay build and MM-69 config system are prerequisites at runtime, not at module authoring time) 165 + 166 + **Done when:** `nix/module.nix` exists and is tracked by git; `nix eval --file nix/module.nix --apply (m: m { lib = (import <nixpkgs> {}).lib; pkgs = import <nixpkgs> {}; config = {}; options = {}; })` evaluates without error (syntax and basic type validity confirmed) 167 + <!-- END_PHASE_1 --> 168 + 169 + <!-- START_PHASE_2 --> 170 + ### Phase 2: Extend `flake.nix` with `nixosModules.default` 171 + **Goal:** Expose the module as a flake output so consumers can import it via `inputs.ezpds.nixosModules.default`. 172 + 173 + **Components:** 174 + - `flake.nix` — add `nixosModules.default` at the top level of outputs (outside `forEachSystem`); the wrapper module imports `./nix/module.nix` and injects the flake's relay build as the default package via `lib.mkDefault` 175 + 176 + **Dependencies:** Phase 1 (`nix/module.nix` must exist) 177 + 178 + **Done when:** `nix flake show --accept-flake-config` lists `nixosModules.default`; `nix eval .#nixosModules --apply builtins.attrNames --accept-flake-config` returns `["default"]` 179 + <!-- END_PHASE_2 --> 180 + 181 + <!-- START_PHASE_3 --> 182 + ### Phase 3: Validate module evaluation 183 + **Goal:** Confirm the module evaluates correctly with minimal and full configurations without a live NixOS system. 184 + 185 + **Components:** 186 + - Nix eval smoke tests using `nix eval` with inline NixOS system configurations 187 + - Verify required-field enforcement: eval with `public_url` omitted must fail 188 + - Verify TOML generation: eval the generated config file path and confirm field presence 189 + - Verify `configFile` escape hatch: confirm `ExecStart` uses the provided path when set 190 + 191 + **Dependencies:** Phase 2 (`nixosModules.default` exposed in flake) 192 + 193 + **Done when:** `nix flake check --accept-flake-config` passes; eval with a minimal valid config (only `public_url` set) succeeds; eval without `public_url` produces a Nix evaluation error; eval with `configFile` set confirms the generated path is bypassed 194 + <!-- END_PHASE_3 --> 195 + 196 + ## Additional Considerations 197 + 198 + **`data_dir` type is `lib.types.str`, not `lib.types.path`:** NixOS's `path` type coerces string literals to Nix store paths, which is wrong for a mutable runtime directory like `/var/lib/ezpds`. Using `str` preserves the value as-is. 199 + 200 + **Nix store world-readability:** The TOML file generated by `pkgs.formats.toml {}` lands in `/nix/store/...` and is readable by all users on the system. This is acceptable for the current config fields (no secrets). Users who need to inject secrets (e.g., a future `database_password` field) should use the `configFile` escape hatch with a secret-manager-generated file outside the store. 201 + 202 + **Stub TOML sections omitted:** The `[blobs]`, `[oauth]`, and `[iroh]` sections in `relay.toml` are empty stubs in v0.1. They are deliberately excluded from `settings` — when they gain real fields, corresponding NixOS options can be added as non-breaking module extensions. 203 + 204 + **`nixos-rebuild` bootstrap:** On first activation, `StateDirectory` creates `/var/lib/ezpds` and chowns it to `ezpds:ezpds`. The relay writes its SQLite database there on startup. The required `public_url` field is enforced at Nix eval time, so misconfiguration is caught at `nixos-rebuild`, not at runtime.