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 dbService =
57 {
58 "pgsql" = "postgresql.service";
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 } // cfg.poolConfig;
350 };
351
352 systemd.services.phpfpm-pixelfed.after = [ "pixelfed-data-setup.service" ];
353 systemd.services.phpfpm-pixelfed.requires =
354 [
355 "pixelfed-horizon.service"
356 "pixelfed-data-setup.service"
357 ]
358 ++ lib.optional cfg.database.createLocally dbService
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 ++ (lib.optional cfg.database.createLocally dbService)
372 ++ (lib.optional cfg.redis.createLocally redisService);
373 wantedBy = [ "multi-user.target" ];
374 # Ensure image optimizations programs are available.
375 path = extraPrograms;
376
377 serviceConfig = {
378 Type = "simple";
379 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage horizon";
380 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
381 User = user;
382 Group = group;
383 Restart = "on-failure";
384 };
385 };
386
387 systemd.timers.pixelfed-cron = {
388 description = "Pixelfed periodic tasks timer";
389 after = [ "pixelfed-data-setup.service" ];
390 requires = [ "phpfpm-pixelfed.service" ];
391 wantedBy = [ "timers.target" ];
392
393 timerConfig = {
394 OnBootSec = cfg.schedulerInterval;
395 OnUnitActiveSec = cfg.schedulerInterval;
396 };
397 };
398
399 systemd.services.pixelfed-cron = {
400 description = "Pixelfed periodic tasks";
401 # Ensure image optimizations programs are available.
402 path = extraPrograms;
403
404 serviceConfig = {
405 ExecStart = "${pixelfed-manage}/bin/pixelfed-manage schedule:run";
406 User = user;
407 Group = group;
408 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
409 };
410 };
411
412 systemd.services.pixelfed-data-setup = {
413 description = "Pixelfed setup: migrations, environment file update, cache reload, data changes";
414 wantedBy = [ "multi-user.target" ];
415 after = lib.optional cfg.database.createLocally dbService;
416 requires = lib.optional cfg.database.createLocally dbService;
417 path =
418 with pkgs;
419 [
420 bash
421 pixelfed-manage
422 rsync
423 ]
424 ++ extraPrograms;
425
426 serviceConfig = {
427 Type = "oneshot";
428 User = user;
429 Group = group;
430 StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed";
431 LoadCredential = "env-secrets:${cfg.secretFile}";
432 UMask = "077";
433 };
434
435 script = ''
436 # Before running any PHP program, cleanup the code cache.
437 # It's necessary if you upgrade the application otherwise you might
438 # try to import non-existent modules.
439 rm -f ${cfg.runtimeDir}/app.php
440 rm -rf ${cfg.runtimeDir}/cache/*
441
442 # Concatenate non-secret .env and secret .env
443 rm -f ${cfg.dataDir}/.env
444 cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
445 echo -e '\n' >> ${cfg.dataDir}/.env
446 cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
447
448 # Link the static storage (package provided) to the runtime storage
449 # Necessary for cities.json and static images.
450 mkdir -p ${cfg.dataDir}/storage
451 rsync -av --no-perms ${pixelfed}/storage-static/ ${cfg.dataDir}/storage
452 chmod -R +w ${cfg.dataDir}/storage
453
454 chmod g+x ${cfg.dataDir}/storage ${cfg.dataDir}/storage/app
455 chmod -R g+rX ${cfg.dataDir}/storage/app/public
456
457 # Link the app.php in the runtime folder.
458 # We cannot link the cache folder only because bootstrap folder needs to be writeable.
459 ln -sf ${pixelfed}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php
460
461 # https://laravel.com/docs/10.x/filesystem#the-public-disk
462 # Creating the public/storage → storage/app/public link
463 # is unnecessary as it's part of the installPhase of pixelfed.
464
465 # Install Horizon
466 # FIXME: require write access to public/ — should be done as part of install — pixelfed-manage horizon:publish
467
468 # Perform the first migration.
469 [[ ! -f ${cfg.dataDir}/.initial-migration ]] && pixelfed-manage migrate --force && touch ${cfg.dataDir}/.initial-migration
470
471 ${lib.optionalString cfg.database.automaticMigrations ''
472 # Force migrate the database.
473 pixelfed-manage migrate --force
474 ''}
475
476 # Import location data
477 pixelfed-manage import:cities
478
479 ${lib.optionalString cfg.settings.ACTIVITY_PUB ''
480 # ActivityPub federation bookkeeping
481 [[ ! -f ${cfg.dataDir}/.instance-actor-created ]] && pixelfed-manage instance:actor && touch ${cfg.dataDir}/.instance-actor-created
482 ''}
483
484 ${lib.optionalString cfg.settings.OAUTH_ENABLED ''
485 # Generate Passport encryption keys
486 [[ ! -f ${cfg.dataDir}/.passport-keys-generated ]] && pixelfed-manage passport:keys && touch ${cfg.dataDir}/.passport-keys-generated
487 ''}
488
489 pixelfed-manage route:cache
490 pixelfed-manage view:cache
491 pixelfed-manage config:cache
492 '';
493 };
494
495 systemd.tmpfiles.rules = [
496 # Cache must live across multiple systemd units runtimes.
497 "d ${cfg.runtimeDir}/ 0700 ${user} ${group} - -"
498 "d ${cfg.runtimeDir}/cache 0700 ${user} ${group} - -"
499 ];
500
501 # Enable NGINX to access our phpfpm-socket.
502 users.users."${config.services.nginx.user}".extraGroups = [ cfg.group ];
503 services.nginx = mkIf (cfg.nginx != null) {
504 enable = true;
505 virtualHosts."${cfg.domain}" = mkMerge [
506 cfg.nginx
507 {
508 root = lib.mkForce "${pixelfed}/public/";
509 locations."/".tryFiles = "$uri $uri/ /index.php?$query_string";
510 locations."/favicon.ico".extraConfig = ''
511 access_log off; log_not_found off;
512 '';
513 locations."/robots.txt".extraConfig = ''
514 access_log off; log_not_found off;
515 '';
516 locations."~ \\.php$".extraConfig = ''
517 fastcgi_split_path_info ^(.+\.php)(/.+)$;
518 fastcgi_pass unix:${config.services.phpfpm.pools.pixelfed.socket};
519 fastcgi_index index.php;
520 '';
521 locations."~ /\\.(?!well-known).*".extraConfig = ''
522 deny all;
523 '';
524 extraConfig = ''
525 add_header X-Frame-Options "SAMEORIGIN";
526 add_header X-XSS-Protection "1; mode=block";
527 add_header X-Content-Type-Options "nosniff";
528 index index.html index.htm index.php;
529 error_page 404 /index.php;
530 client_max_body_size ${toString cfg.maxUploadSize};
531 '';
532 }
533 ];
534 };
535 };
536}