Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1self: {
2 lib,
3 pkgs,
4 config,
5 ...
6}: let
7 cfg = config.services.tranquil-pds;
8
9 inherit (lib) types mkOption;
10
11 settingsFormat = pkgs.formats.toml { };
12
13 backendUrl = "http://127.0.0.1:${toString cfg.settings.server.port}";
14
15 useACME = cfg.nginx.enableACME && cfg.nginx.useACMEHost == null;
16 hasSSL = useACME || cfg.nginx.useACMEHost != null;
17in {
18 _class = "nixos";
19
20 options.services.tranquil-pds = {
21 enable = lib.mkEnableOption "tranquil-pds AT Protocol personal data server";
22
23 package = mkOption {
24 type = types.package;
25 default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
26 defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-pds";
27 description = "The tranquil-pds package to use";
28 };
29
30 user = mkOption {
31 type = types.str;
32 default = "tranquil-pds";
33 description = "User under which tranquil-pds runs";
34 };
35
36 group = mkOption {
37 type = types.str;
38 default = "tranquil-pds";
39 description = "Group under which tranquil-pds runs";
40 };
41
42 dataDir = mkOption {
43 type = types.str;
44 default = "/var/lib/tranquil-pds";
45 description = "Directory for tranquil-pds data (blobs, backups)";
46 };
47
48 environmentFiles = mkOption {
49 type = types.listOf types.path;
50 default = [ ];
51 description = ''
52 File to load environment variables from. Loaded variables override
53 values set in {option}`environment`.
54
55 Use it to set values of `JWT_SECRET`, `DPOP_SECRET` and `MASTER_KEY`.
56
57 Generate these with:
58 ```
59 openssl rand --hex 32
60 ```
61 '';
62 };
63
64 database.createLocally = mkOption {
65 type = types.bool;
66 default = false;
67 description = ''
68 Create the postgres database and user on the local host.
69 '';
70 };
71
72 frontend.package = mkOption {
73 type = types.nullOr types.package;
74 default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-frontend;
75 defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-frontend";
76 description = "Frontend package to serve via nginx (set null to disable frontend)";
77 };
78
79 nginx = {
80 enable = lib.mkEnableOption "nginx reverse proxy for tranquil-pds";
81
82 enableACME = mkOption {
83 type = types.bool;
84 default = true;
85 description = "Enable ACME for the pds domain";
86 };
87
88 useACMEHost = mkOption {
89 type = types.nullOr types.str;
90 default = null;
91 description = ''
92 Use a pre-configured ACME certificate instead of generating one.
93 Set this to the cert name from security.acme.certs for wildcard setups.
94
95 REMEMBER: Handle subdomains (*.pds.example.com) require a wildcard cert via DNS-01.
96 '';
97 };
98 };
99
100 settings = mkOption {
101 type = types.submodule {
102 freeformType = settingsFormat.type;
103
104 options = {
105 server = {
106 host = mkOption {
107 type = types.str;
108 default = "127.0.0.1";
109 description = "Host for tranquil-pds to listen on";
110 };
111
112 port = mkOption {
113 type = types.int;
114 default = 3000;
115 description = "Port for tranquil-pds to listen on";
116 };
117
118 hostname = mkOption {
119 type = types.str;
120 default = "";
121 example = "pds.example.com";
122 description = "The public-facing hostname of the PDS";
123 };
124
125 max_blob_size = mkOption {
126 type = types.int;
127 default = 10737418240; # 10 GiB
128 description = "Maximum allowed blob size in bytes.";
129 };
130 };
131
132 storage = {
133 path = mkOption {
134 type = types.path;
135 default = "/var/lib/tranquil-pds/blobs";
136 description = "Directory for storing blobs";
137 };
138 };
139
140 backup = {
141 path = mkOption {
142 type = types.path;
143 default = "/var/lib/tranquil-pds/backups";
144 description = "Directory for storing backups";
145 };
146 };
147
148 email = {
149 sendmail_path = mkOption {
150 type = types.path;
151 default = lib.getExe pkgs.system-sendmail;
152 description = "Path to the sendmail executable to use for sending emails.";
153 };
154 };
155
156 signal = {
157 cli_path = mkOption {
158 type = types.path;
159 default = lib.getExe pkgs.signal-cli;
160 description = "Path to the signal-cli executable to use for sending Signal notifications.";
161 };
162 };
163 };
164 };
165
166 description = ''
167 Configuration options to set for the service. Secrets should be
168 specified using {option}`environmentFile`.
169
170 Refer to <https://tangled.org/tranquil.farm/tranquil-pds/blob/main/example.toml>
171 for available configuration options.
172 '';
173 };
174 };
175
176 config = lib.mkIf cfg.enable (
177 lib.mkMerge [
178 (lib.mkIf cfg.database.createLocally {
179 services.postgresql = {
180 enable = true;
181 ensureDatabases = [ cfg.user ];
182 ensureUsers = [
183 {
184 name = cfg.user;
185 ensureDBOwnership = true;
186 }
187 ];
188 };
189
190 services.tranquil-pds.settings.database.url =
191 lib.mkDefault "postgresql:///${cfg.user}?host=/run/postgresql";
192
193 systemd.services.tranquil-pds = {
194 requires = [ "postgresql.service" ];
195 after = [ "postgresql.service" ];
196 };
197 })
198
199 (lib.mkIf cfg.nginx.enable {
200 services.nginx = {
201 enable = true;
202
203 virtualHosts.${cfg.settings.server.hostname} = {
204 serverAliases = [ "*.${cfg.settings.server.hostname}" ];
205 forceSSL = hasSSL;
206 enableACME = useACME;
207 useACMEHost = cfg.nginx.useACMEHost;
208
209 root = lib.mkIf (cfg.frontend.package != null) cfg.frontend.package;
210
211 extraConfig = "client_max_body_size ${toString cfg.settings.server.max_blob_size};";
212
213 locations = lib.mkMerge [
214 {
215 "/xrpc/" = {
216 proxyPass = backendUrl;
217 proxyWebsockets = true;
218 extraConfig = ''
219 proxy_read_timeout 86400;
220 proxy_send_timeout 86400;
221 proxy_buffering off;
222 proxy_request_buffering off;
223 '';
224 };
225
226 "/oauth/" = {
227 proxyPass = backendUrl;
228 extraConfig = ''
229 proxy_read_timeout 300;
230 proxy_send_timeout 300;
231 '';
232 };
233
234 "/.well-known/" = {
235 proxyPass = backendUrl;
236 };
237
238 "/webhook/" = {
239 proxyPass = backendUrl;
240 };
241
242 "= /metrics" = {
243 proxyPass = backendUrl;
244 };
245
246 "= /health" = {
247 proxyPass = backendUrl;
248 };
249
250 "= /robots.txt" = {
251 proxyPass = backendUrl;
252 };
253
254 "= /logo" = {
255 proxyPass = backendUrl;
256 };
257
258 "~ ^/u/[^/]+/did\\.json$" = {
259 proxyPass = backendUrl;
260 };
261 }
262
263 (lib.optionalAttrs (cfg.frontend.package != null) {
264 "= /oauth/client-metadata.json" = {
265 root = "${cfg.frontend.package}";
266 extraConfig = ''
267 default_type application/json;
268 sub_filter_once off;
269 sub_filter_types application/json;
270 sub_filter '__PDS_HOSTNAME__' $host;
271 '';
272 };
273
274 "/assets/" = {
275 # TODO: use `add_header_inherit` when nixpkgs updates to nginx 1.29.3+
276 extraConfig = ''
277 expires 1y;
278 add_header Cache-Control "public, immutable";
279 '';
280 tryFiles = "$uri =404";
281 };
282
283 "/app/" = {
284 tryFiles = "$uri $uri/ /index.html";
285 };
286
287 "= /" = {
288 tryFiles = "/homepage.html /index.html";
289 };
290
291 "/" = {
292 tryFiles = "$uri $uri/ /index.html";
293 priority = 9999;
294 };
295 })
296 ];
297 };
298 };
299 })
300
301 {
302 users.users.${cfg.user} = {
303 isSystemUser = true;
304 inherit (cfg) group;
305 home = cfg.dataDir;
306 };
307
308 users.groups.${cfg.group} = { };
309
310 systemd.tmpfiles.settings."tranquil-pds" =
311 lib.genAttrs
312 [
313 cfg.dataDir
314 cfg.settings.storage.path
315 cfg.settings.backup.path
316 ]
317 (_: {
318 d = {
319 mode = "0750";
320 inherit (cfg) user group;
321 };
322 });
323
324 environment.etc = {
325 "tranquil-pds/config.toml".source = settingsFormat.generate "tranquil-pds.toml" cfg.settings;
326 };
327
328 systemd.services.tranquil-pds = {
329 description = "Tranquil PDS - AT Protocol Personal Data Server";
330 after = [ "network-online.target" ];
331 wants = [ "network-online.target" ];
332 wantedBy = [ "multi-user.target" ];
333
334 serviceConfig = {
335 User = cfg.user;
336 Group = cfg.group;
337 ExecStart = lib.getExe cfg.package;
338 Restart = "on-failure";
339 RestartSec = 5;
340
341 WorkingDirectory = cfg.dataDir;
342 StateDirectory = "tranquil-pds";
343
344 EnvironmentFile = cfg.environmentFiles;
345
346 NoNewPrivileges = true;
347 ProtectSystem = "strict";
348 ProtectHome = true;
349 PrivateTmp = true;
350 PrivateDevices = true;
351 ProtectKernelTunables = true;
352 ProtectKernelModules = true;
353 ProtectControlGroups = true;
354 RestrictAddressFamilies = [
355 "AF_INET"
356 "AF_INET6"
357 "AF_UNIX"
358 ];
359 RestrictNamespaces = true;
360 LockPersonality = true;
361 MemoryDenyWriteExecute = true;
362 RestrictRealtime = true;
363 RestrictSUIDSGID = true;
364 RemoveIPC = true;
365
366 ReadWritePaths = [
367 cfg.settings.storage.path
368 cfg.settings.backup.path
369 ];
370 };
371 };
372 }
373 ]
374 );
375}