1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.pixelfed;
12 user = cfg.user;
13 group = cfg.group;
14 pixelfed = cfg.package.override { inherit (cfg) dataDir runtimeDir; };
15 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L185-L190
16 extraPrograms = with pkgs; [
17 jpegoptim
18 optipng
19 pngquant
20 gifsicle
21 ffmpeg
22 ];
23 # Ensure PHP extensions: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L135-L147
24 phpPackage = cfg.phpPackage.buildEnv {
25 extensions =
26 { enabled, all }:
27 enabled
28 ++ (with all; [
29 bcmath
30 ctype
31 curl
32 mbstring
33 gd
34 intl
35 zip
36 redis
37 imagick
38 ]);
39 };
40 configFile = pkgs.writeText "pixelfed-env" (lib.generators.toKeyValue { } cfg.settings);
41 # Management script
42 pixelfed-manage = pkgs.writeShellScriptBin "pixelfed-manage" ''
43 cd ${pixelfed}
44 sudo=exec
45 if [[ "$USER" != ${user} ]]; then
46 sudo='exec /run/wrappers/bin/sudo -u ${user}'
47 fi
48 $sudo ${phpPackage}/bin/php artisan "$@"
49 '';
50 dbSocket =
51 {
52 "pgsql" = "/run/postgresql";
53 "mysql" = "/run/mysqld/mysqld.sock";
54 }
55 .${cfg.database.type};
56 dbUnit =
57 {
58 "pgsql" = "postgresql.target";
59 "mysql" = "mysql.service";
60 }
61 .${cfg.database.type};
62 redisService = "redis-pixelfed.service";
63in
64{
65 options.services = {
66 pixelfed = {
67 enable = mkEnableOption "a Pixelfed instance";
68 package = mkPackageOption pkgs "pixelfed" { };
69 phpPackage = mkPackageOption pkgs "php83" { };
70
71 user = mkOption {
72 type = types.str;
73 default = "pixelfed";
74 description = ''
75 User account under which pixelfed runs.
76
77 ::: {.note}
78 If left as the default value this user will automatically be created
79 on system activation, otherwise you are responsible for
80 ensuring the user exists before the pixelfed application starts.
81 :::
82 '';
83 };
84
85 group = mkOption {
86 type = types.str;
87 default = "pixelfed";
88 description = ''
89 Group account under which pixelfed runs.
90
91 ::: {.note}
92 If left as the default value this group will automatically be created
93 on system activation, otherwise you are responsible for
94 ensuring the group exists before the pixelfed application starts.
95 :::
96 '';
97 };
98
99 domain = mkOption {
100 type = types.str;
101 description = ''
102 FQDN for the Pixelfed instance.
103 '';
104 };
105
106 secretFile = mkOption {
107 type = types.path;
108 description = ''
109 A secret file to be sourced for the .env settings.
110 Place `APP_KEY` and other settings that should not end up in the Nix store here.
111 '';
112 };
113
114 settings = mkOption {
115 type =
116 with types;
117 (attrsOf (oneOf [
118 bool
119 int
120 str
121 ]));
122 description = ''
123 .env settings for Pixelfed.
124 Secrets should use `secretFile` option instead.
125 '';
126 };
127
128 nginx = mkOption {
129 type = types.nullOr (
130 types.submodule (
131 import ../web-servers/nginx/vhost-options.nix {
132 inherit config lib;
133 }
134 )
135 );
136 default = null;
137 example = lib.literalExpression ''
138 {
139 serverAliases = [
140 "pics.''${config.networking.domain}"
141 ];
142 enableACME = true;
143 forceHttps = true;
144 }
145 '';
146 description = ''
147 With this option, you can customize an nginx virtual host which already has sensible defaults for Pixelfed.
148 Set to {} if you do not need any customization to the virtual host.
149 If enabled, then by default, the {option}`serverName` is
150 `''${domain}`,
151 If this is set to null (the default), no nginx virtualHost will be configured.
152 '';
153 };
154
155 redis.createLocally = mkEnableOption "a local Redis database using UNIX socket authentication" // {
156 default = true;
157 };
158
159 database = {
160 createLocally = mkEnableOption "a local database using UNIX socket authentication" // {
161 default = true;
162 };
163 automaticMigrations = mkEnableOption "automatic migrations for database schema and data" // {
164 default = true;
165 };
166
167 type = mkOption {
168 type = types.enum [
169 "mysql"
170 "pgsql"
171 ];
172 example = "pgsql";
173 default = "mysql";
174 description = ''
175 Database engine to use.
176 Note that PGSQL is not well supported: <https://github.com/pixelfed/pixelfed/issues/2727>
177 '';
178 };
179
180 name = mkOption {
181 type = types.str;
182 default = "pixelfed";
183 description = "Database name.";
184 };
185 };
186
187 maxUploadSize = mkOption {
188 type = types.str;
189 default = "8M";
190 description = ''
191 Max upload size with units.
192 '';
193 };
194
195 poolConfig = mkOption {
196 type =
197 with types;
198 attrsOf (oneOf [
199 int
200 str
201 bool
202 ]);
203 default = { };
204
205 description = ''
206 Options for Pixelfed's PHP-FPM pool.
207 '';
208 };
209
210 dataDir = mkOption {
211 type = types.str;
212 default = "/var/lib/pixelfed";
213 description = ''
214 State directory of the `pixelfed` user which holds
215 the application's state and data.
216 '';
217 };
218
219 runtimeDir = mkOption {
220 type = types.str;
221 default = "/run/pixelfed";
222 description = ''
223 Ruutime directory of the `pixelfed` user which holds
224 the application's caches and temporary files.
225 '';
226 };
227
228 schedulerInterval = mkOption {
229 type = types.str;
230 default = "1d";
231 description = "How often the Pixelfed cron task should run";
232 };
233 };
234 };
235
236 config = mkIf cfg.enable {
237 users.users.pixelfed = mkIf (cfg.user == "pixelfed") {
238 isSystemUser = true;
239 group = cfg.group;
240 extraGroups = lib.optional cfg.redis.createLocally "redis-pixelfed";
241 };
242 users.groups.pixelfed = mkIf (cfg.group == "pixelfed") { };
243
244 services.redis.servers.pixelfed.enable = lib.mkIf cfg.redis.createLocally true;
245 services.pixelfed.settings = mkMerge [
246 ({
247 APP_ENV = mkDefault "production";
248 APP_DEBUG = mkDefault false;
249 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L312-L316
250 APP_URL = mkDefault "https://${cfg.domain}";
251 ADMIN_DOMAIN = mkDefault cfg.domain;
252 APP_DOMAIN = mkDefault cfg.domain;
253 SESSION_DOMAIN = mkDefault cfg.domain;
254 SESSION_SECURE_COOKIE = mkDefault true;
255 OPEN_REGISTRATION = mkDefault false;
256 # ActivityPub: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L360-L364
257 ACTIVITY_PUB = mkDefault true;
258 AP_REMOTE_FOLLOW = mkDefault true;
259 AP_INBOX = mkDefault true;
260 AP_OUTBOX = mkDefault true;
261 AP_SHAREDINBOX = mkDefault true;
262 # Image optimization: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L367-L404
263 PF_OPTIMIZE_IMAGES = mkDefault true;
264 IMAGE_DRIVER = mkDefault "imagick";
265 # Mobile APIs
266 OAUTH_ENABLED = mkDefault true;
267 # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L351
268 EXP_EMC = mkDefault true;
269 # Defer to systemd
270 LOG_CHANNEL = mkDefault "stderr";
271 # TODO: find out the correct syntax?
272 # TRUST_PROXIES = mkDefault "127.0.0.1/8, ::1/128";
273 })
274 (mkIf (cfg.redis.createLocally) {
275 BROADCAST_DRIVER = mkDefault "redis";
276 CACHE_DRIVER = mkDefault "redis";
277 QUEUE_DRIVER = mkDefault "redis";
278 SESSION_DRIVER = mkDefault "redis";
279 WEBSOCKET_REPLICATION_MODE = mkDefault "redis";
280 # Support phpredis and predis configuration-style.
281 REDIS_SCHEME = "unix";
282 REDIS_HOST = config.services.redis.servers.pixelfed.unixSocket;
283 REDIS_PATH = config.services.redis.servers.pixelfed.unixSocket;
284 })
285 (mkIf (cfg.database.createLocally) {
286 DB_CONNECTION = cfg.database.type;
287 DB_SOCKET = dbSocket;
288 DB_DATABASE = cfg.database.name;
289 DB_USERNAME = user;
290 # No TCP/IP connection.
291 DB_PORT = 0;
292 })
293 ];
294
295 environment.systemPackages = [ pixelfed-manage ];
296
297 services.mysql = mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
298 enable = mkDefault true;
299 package = mkDefault pkgs.mariadb;
300 ensureDatabases = [ cfg.database.name ];
301 ensureUsers = [
302 {
303 name = user;
304 ensurePermissions = {
305 "${cfg.database.name}.*" = "ALL PRIVILEGES";
306 };
307 }
308 ];
309 };
310
311 services.postgresql = mkIf (cfg.database.createLocally && cfg.database.type == "pgsql") {
312 enable = mkDefault true;
313 ensureDatabases = [ cfg.database.name ];
314 ensureUsers = [
315 {
316 name = user;
317 }
318 ];
319 };
320
321 # Make each individual option overridable with lib.mkDefault.
322 services.pixelfed.poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
323 "pm" = "dynamic";
324 "php_admin_value[error_log]" = "stderr";
325 "php_admin_flag[log_errors]" = true;
326 "catch_workers_output" = true;
327 "pm.max_children" = "32";
328 "pm.start_servers" = "2";
329 "pm.min_spare_servers" = "2";
330 "pm.max_spare_servers" = "4";
331 "pm.max_requests" = "500";
332 };
333
334 services.phpfpm.pools.pixelfed = {
335 inherit user group;
336 inherit phpPackage;
337
338 phpOptions = ''
339 post_max_size = ${toString cfg.maxUploadSize}
340 upload_max_filesize = ${toString cfg.maxUploadSize}
341 max_execution_time = 600;
342 '';
343
344 settings = {
345 "listen.owner" = user;
346 "listen.group" = group;
347 "listen.mode" = "0660";
348 "catch_workers_output" = "yes";
349 }
350 // cfg.poolConfig;
351 };
352
353 systemd.services.phpfpm-pixelfed.after = [ "pixelfed-data-setup.service" ];
354 systemd.services.phpfpm-pixelfed.requires = [
355 "pixelfed-horizon.service"
356 "pixelfed-data-setup.service"
357 ]
358 ++ lib.optional cfg.database.createLocally dbUnit
359 ++ lib.optional cfg.redis.createLocally redisService;
360 # Ensure image optimizations programs are available.
361 systemd.services.phpfpm-pixelfed.path = extraPrograms;
362
363 systemd.services.pixelfed-horizon = {
364 description = "Pixelfed task queueing via Laravel Horizon framework";
365 after = [
366 "network.target"
367 "pixelfed-data-setup.service"
368 ];
369 requires = [
370 "pixelfed-data-setup.service"
371 ]
372 ++ (lib.optional cfg.database.createLocally dbUnit)
373 ++ (lib.optional cfg.redis.createLocally redisService);
374 wantedBy = [ "multi-user.target" ];
375 # Ensure image optimizations programs are available.
376 path = extraPrograms;
377
378 serviceConfig = {
379 Type = "simple";
380 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage horizon";
381 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
382 User = user;
383 Group = group;
384 Restart = "on-failure";
385 };
386 };
387
388 systemd.timers.pixelfed-cron = {
389 description = "Pixelfed periodic tasks timer";
390 after = [ "pixelfed-data-setup.service" ];
391 requires = [ "phpfpm-pixelfed.service" ];
392 wantedBy = [ "timers.target" ];
393
394 timerConfig = {
395 OnBootSec = cfg.schedulerInterval;
396 OnUnitActiveSec = cfg.schedulerInterval;
397 };
398 };
399
400 systemd.services.pixelfed-cron = {
401 description = "Pixelfed periodic tasks";
402 # Ensure image optimizations programs are available.
403 path = extraPrograms;
404
405 serviceConfig = {
406 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage schedule:run";
407 User = user;
408 Group = group;
409 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
410 };
411 };
412
413 systemd.services.pixelfed-data-setup = {
414 description = "Pixelfed setup: migrations, environment file update, cache reload, data changes";
415 wantedBy = [ "multi-user.target" ];
416 after = lib.optional cfg.database.createLocally dbUnit;
417 requires = lib.optional cfg.database.createLocally dbUnit;
418 path =
419 with pkgs;
420 [
421 bash
422 pixelfed-manage
423 rsync
424 ]
425 ++ extraPrograms;
426
427 serviceConfig = {
428 Type = "oneshot";
429 User = user;
430 Group = group;
431 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
432 LoadCredential = "env-secrets:${cfg.secretFile}";
433 UMask = "077";
434 };
435
436 script = ''
437 # Before running any PHP program, cleanup the code cache.
438 # It's necessary if you upgrade the application otherwise you might
439 # try to import non-existent modules.
440 rm -f ${cfg.runtimeDir}/app.php
441 rm -rf ${cfg.runtimeDir}/cache/*
442
443 # Concatenate non-secret .env and secret .env
444 rm -f ${cfg.dataDir}/.env
445 cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
446 echo -e '\n' >> ${cfg.dataDir}/.env
447 cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
448
449 # Link the static storage (package provided) to the runtime storage
450 # Necessary for cities.json and static images.
451 mkdir -p ${cfg.dataDir}/storage
452 rsync -av --no-perms ${pixelfed}/storage-static/ ${cfg.dataDir}/storage
453 chmod -R +w ${cfg.dataDir}/storage
454
455 chmod g+x ${cfg.dataDir}/storage ${cfg.dataDir}/storage/app
456 chmod -R g+rX ${cfg.dataDir}/storage/app/public
457
458 # Link the app.php in the runtime folder.
459 # We cannot link the cache folder only because bootstrap folder needs to be writeable.
460 ln -sf ${pixelfed}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
461
462 # https://laravel.com/docs/10.x/filesystem#the-public-disk
463 # Creating the public/storage → storage/app/public link
464 # is unnecessary as it's part of the installPhase of pixelfed.
465
466 # Install Horizon
467 # FIXME: require write access to public/ — should be done as part of install — pixelfed-manage horizon:publish
468
469 # Perform the first migration.
470 [[ ! -f ${cfg.dataDir}/.initial-migration ]] && pixelfed-manage migrate --force && touch ${cfg.dataDir}/.initial-migration
471
472 ${lib.optionalString cfg.database.automaticMigrations ''
473 # Force migrate the database.
474 pixelfed-manage migrate --force
475 ''}
476
477 # Import location data
478 pixelfed-manage import:cities
479
480 ${lib.optionalString cfg.settings.ACTIVITY_PUB ''
481 # ActivityPub federation bookkeeping
482 [[ ! -f ${cfg.dataDir}/.instance-actor-created ]] && pixelfed-manage instance:actor && touch ${cfg.dataDir}/.instance-actor-created
483 ''}
484
485 ${lib.optionalString cfg.settings.OAUTH_ENABLED ''
486 # Generate Passport encryption keys
487 [[ ! -f ${cfg.dataDir}/.passport-keys-generated ]] && pixelfed-manage passport:keys && touch ${cfg.dataDir}/.passport-keys-generated
488 ''}
489
490 pixelfed-manage route:cache
491 pixelfed-manage view:cache
492 pixelfed-manage config:cache
493 '';
494 };
495
496 systemd.tmpfiles.rules = [
497 # Cache must live across multiple systemd units runtimes.
498 "d ${cfg.runtimeDir}/ 0700 ${user} ${group} - -"
499 "d ${cfg.runtimeDir}/cache 0700 ${user} ${group} - -"
500 ];
501
502 # Enable NGINX to access our phpfpm-socket.
503 users.users."${config.services.nginx.user}".extraGroups = [ cfg.group ];
504 services.nginx = mkIf (cfg.nginx != null) {
505 enable = true;
506 virtualHosts."${cfg.domain}" = mkMerge [
507 cfg.nginx
508 {
509 root = lib.mkForce "${pixelfed}/public/";
510 locations."/".tryFiles = "$uri $uri/ /index.php?$query_string";
511 locations."/favicon.ico".extraConfig = ''
512 access_log off; log_not_found off;
513 '';
514 locations."/robots.txt".extraConfig = ''
515 access_log off; log_not_found off;
516 '';
517 locations."~ \\.php$".extraConfig = ''
518 fastcgi_split_path_info ^(.+\.php)(/.+)$;
519 fastcgi_pass unix:${config.services.phpfpm.pools.pixelfed.socket};
520 fastcgi_index index.php;
521 '';
522 locations."~ /\\.(?!well-known).*".extraConfig = ''
523 deny all;
524 '';
525 extraConfig = ''
526 add_header X-Frame-Options "SAMEORIGIN";
527 add_header X-XSS-Protection "1; mode=block";
528 add_header X-Content-Type-Options "nosniff";
529 index index.html index.htm index.php;
530 error_page 404 /index.php;
531 client_max_body_size ${toString cfg.maxUploadSize};
532 '';
533 }
534 ];
535 };
536 };
537}