1{
2 config,
3 options,
4 pkgs,
5 lib,
6 ...
7}:
8let
9 cfg = config.services.paperless;
10 opt = options.services.paperless;
11
12 defaultUser = "paperless";
13 defaultFont = "${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf";
14
15 # Don't start a redis instance if the user sets a custom redis connection
16 enableRedis = !(cfg.settings ? PAPERLESS_REDIS);
17 redisServer = config.services.redis.servers.paperless;
18
19 env = {
20 PAPERLESS_DATA_DIR = cfg.dataDir;
21 PAPERLESS_MEDIA_ROOT = cfg.mediaDir;
22 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir;
23 PAPERLESS_THUMBNAIL_FONT_NAME = defaultFont;
24 GRANIAN_HOST = cfg.address;
25 GRANIAN_PORT = toString cfg.port;
26 GRANIAN_WORKERS_KILL_TIMEOUT = "60";
27 }
28 // lib.optionalAttrs (config.time.timeZone != null) {
29 PAPERLESS_TIME_ZONE = config.time.timeZone;
30 }
31 // lib.optionalAttrs enableRedis {
32 PAPERLESS_REDIS = "unix://${redisServer.unixSocket}";
33 }
34 // lib.optionalAttrs (cfg.settings.PAPERLESS_ENABLE_NLTK or true) {
35 PAPERLESS_NLTK_DIR = cfg.package.nltkDataDir;
36 }
37 // lib.optionalAttrs (cfg.openMPThreadingWorkaround) {
38 OMP_NUM_THREADS = "1";
39 }
40 // (lib.mapAttrs (
41 _: s:
42 if (lib.isAttrs s || lib.isList s) then
43 builtins.toJSON s
44 else if lib.isBool s then
45 lib.boolToString s
46 else
47 toString s
48 ) cfg.settings);
49
50 manage = pkgs.writeShellScriptBin "paperless-manage" ''
51 set -o allexport # Export the following env vars
52 ${lib.toShellVars env}
53 ${lib.optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"}
54
55 cd '${cfg.dataDir}'
56 sudo=exec
57 if [[ "$USER" != ${cfg.user} ]]; then
58 ${
59 if config.security.sudo.enable then
60 "sudo='exec ${config.security.wrapperDir}/sudo -u ${cfg.user} -E'"
61 else
62 ">&2 echo 'Aborting, paperless-manage must be run as user `${cfg.user}`!'; exit 2"
63 }
64 fi
65 $sudo ${lib.getExe cfg.package} "$@"
66 '';
67
68 defaultServiceConfig = {
69 Slice = "system-paperless.slice";
70 # Secure the services
71 ReadWritePaths = [
72 cfg.consumptionDir
73 cfg.dataDir
74 cfg.mediaDir
75 ];
76 CacheDirectory = "paperless";
77 CapabilityBoundingSet = "";
78 # ProtectClock adds DeviceAllow=char-rtc r
79 DeviceAllow = "";
80 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
81 LockPersonality = true;
82 MemoryDenyWriteExecute = true;
83 NoNewPrivileges = true;
84 PrivateDevices = true;
85 PrivateMounts = true;
86 PrivateNetwork = true;
87 PrivateTmp = true;
88 PrivateUsers = true;
89 ProtectClock = true;
90 # Breaks if the home dir of the user is in /home
91 # ProtectHome = true;
92 ProtectHostname = true;
93 ProtectSystem = "strict";
94 ProtectControlGroups = true;
95 ProtectKernelLogs = true;
96 ProtectKernelModules = true;
97 ProtectKernelTunables = true;
98 ProtectProc = "invisible";
99 ProcSubset = "pid";
100 RestrictAddressFamilies = [
101 "AF_UNIX"
102 "AF_INET"
103 "AF_INET6"
104 ];
105 RestrictNamespaces = true;
106 RestrictRealtime = true;
107 RestrictSUIDSGID = true;
108 SupplementaryGroups = lib.optional enableRedis redisServer.user;
109 SystemCallArchitectures = "native";
110 SystemCallFilter = [
111 "@system-service"
112 "~@privileged @setuid @keyring"
113 ];
114 UMask = "0066";
115 };
116in
117{
118 meta.maintainers = with lib.maintainers; [
119 leona
120 SuperSandro2000
121 erikarvstedt
122 atemu
123 theuni
124 ];
125
126 imports = [
127 (lib.mkRenamedOptionModule [ "services" "paperless-ng" ] [ "services" "paperless" ])
128 (lib.mkRenamedOptionModule
129 [ "services" "paperless" "extraConfig" ]
130 [ "services" "paperless" "settings" ]
131 )
132 ];
133
134 options.services.paperless = {
135 enable = lib.mkOption {
136 type = lib.types.bool;
137 default = false;
138 description = ''
139 Whether to enable Paperless-ngx.
140
141 When started, the Paperless database is automatically created if it doesn't exist
142 and updated if the Paperless package has changed.
143 Both tasks are achieved by running a Django migration.
144
145 A script to manage the Paperless-ngx instance (by wrapping Django's manage.py) is available as `paperless-manage`.
146 '';
147 };
148
149 dataDir = lib.mkOption {
150 type = lib.types.str;
151 default = "/var/lib/paperless";
152 description = "Directory to store the Paperless data.";
153 };
154
155 mediaDir = lib.mkOption {
156 type = lib.types.str;
157 default = "${cfg.dataDir}/media";
158 defaultText = lib.literalExpression ''"''${dataDir}/media"'';
159 description = "Directory to store the Paperless documents.";
160 };
161
162 consumptionDir = lib.mkOption {
163 type = lib.types.str;
164 default = "${cfg.dataDir}/consume";
165 defaultText = lib.literalExpression ''"''${dataDir}/consume"'';
166 description = "Directory from which new documents are imported.";
167 };
168
169 consumptionDirIsPublic = lib.mkOption {
170 type = lib.types.bool;
171 default = false;
172 description = "Whether all users can write to the consumption dir.";
173 };
174
175 passwordFile = lib.mkOption {
176 type = lib.types.nullOr lib.types.path;
177 default = null;
178 example = "/run/keys/paperless-password";
179 description = ''
180 A file containing the superuser password.
181
182 A superuser is required to access the web interface.
183 If unset, you can create a superuser manually by running `paperless-manage createsuperuser`.
184
185 The default superuser name is `admin`. To change it, set
186 option {option}`settings.PAPERLESS_ADMIN_USER`.
187 WARNING: When changing the superuser name after the initial setup, the old superuser
188 will continue to exist.
189
190 To disable login for the web interface, set the following:
191 `settings.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";`.
192 WARNING: Only use this on a trusted system without internet access to Paperless.
193 '';
194 };
195
196 address = lib.mkOption {
197 type = lib.types.str;
198 default = "127.0.0.1";
199 description = "Web interface address.";
200 };
201
202 port = lib.mkOption {
203 type = lib.types.port;
204 default = 28981;
205 description = "Web interface port.";
206 };
207
208 settings = lib.mkOption {
209 type = lib.types.submodule {
210 freeformType =
211 with lib.types;
212 attrsOf (
213 let
214 typeList = [
215 bool
216 float
217 int
218 str
219 path
220 package
221 ];
222 in
223 oneOf (
224 typeList
225 ++ [
226 (listOf (oneOf typeList))
227 (attrsOf (oneOf typeList))
228 ]
229 )
230 );
231 };
232 default = { };
233 description = ''
234 Extra paperless config options.
235
236 See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options.
237
238 Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values.
239 Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience.
240 '';
241 example = {
242 PAPERLESS_OCR_LANGUAGE = "deu+eng";
243 PAPERLESS_CONSUMER_IGNORE_PATTERN = [
244 ".DS_STORE/*"
245 "desktop.ini"
246 ];
247 PAPERLESS_OCR_USER_ARGS = {
248 optimize = 1;
249 pdfa_image_compression = "lossless";
250 };
251 };
252 };
253
254 user = lib.mkOption {
255 type = lib.types.str;
256 default = defaultUser;
257 description = "User under which Paperless runs.";
258 };
259
260 package = lib.mkPackageOption pkgs "paperless-ngx" { } // {
261 apply =
262 pkg:
263 pkg.override {
264 tesseract5 = pkg.tesseract5.override {
265 # always enable detection modules
266 # tesseract fails to build when eng is not present
267 enableLanguages =
268 if cfg.settings ? PAPERLESS_OCR_LANGUAGE then
269 lib.lists.unique (
270 [
271 "equ"
272 "osd"
273 "eng"
274 ]
275 ++ lib.splitString "+" cfg.settings.PAPERLESS_OCR_LANGUAGE
276 )
277 else
278 null;
279 };
280 };
281 };
282
283 openMPThreadingWorkaround =
284 lib.mkEnableOption ''
285 a workaround for document classifier timeouts.
286
287 Paperless uses OpenBLAS via scikit-learn for document classification.
288
289 The default is to use threading for OpenMP but this would cause the
290 document classifier to spin on one core seemingly indefinitely if there
291 are large amounts of classes per classification; causing it to
292 effectively never complete due to running into timeouts.
293
294 This sets `OMP_NUM_THREADS` to `1` in order to mitigate the issue. See
295 https://github.com/NixOS/nixpkgs/issues/240591 for more information
296 ''
297 // lib.mkOption { default = true; };
298
299 environmentFile = lib.mkOption {
300 type = lib.types.nullOr lib.types.path;
301 default = null;
302 example = "/run/secrets/paperless";
303 description = ''
304 Path to a file containing extra paperless config options in the systemd `EnvironmentFile`
305 format. Refer to the [documentation](https://docs.paperless-ngx.com/configuration/) for
306 config options.
307
308 This can be used to pass secrets to paperless without putting them in the Nix store.
309
310 To set a database password, point `environmentFile` at a file containing:
311 ```
312 PAPERLESS_DBPASS=<pass>
313 ```
314 '';
315 };
316
317 database = {
318 createLocally = lib.mkOption {
319 type = lib.types.bool;
320 default = false;
321 description = ''
322 Configure local PostgreSQL database server for Paperless.
323 '';
324 };
325 };
326
327 configureNginx = lib.mkEnableOption "" // {
328 description = "Whether to configure nginx as a reverse proxy.";
329 };
330
331 domain = lib.mkOption {
332 type = with lib.types; nullOr str;
333 default = null;
334 example = "paperless.example.com";
335 description = "Domain under which paperless will be available.";
336 };
337
338 exporter = {
339 enable = lib.mkEnableOption "regular automatic document exports";
340
341 directory = lib.mkOption {
342 type = lib.types.str;
343 default = cfg.dataDir + "/export";
344 defaultText = lib.literalExpression "\${config.services.paperless.dataDir}/export";
345 description = "Directory to store export.";
346 };
347
348 onCalendar = lib.mkOption {
349 type = lib.types.nullOr lib.types.str;
350 default = "01:30:00";
351 description = ''
352 When to run the exporter. See {manpage}`systemd.time(7)`.
353
354 `null` disables the timer; allowing you to run the
355 `paperless-exporter` service through other means.
356 '';
357 };
358
359 settings = lib.mkOption {
360 type = with lib.types; attrsOf anything;
361 default = {
362 "no-progress-bar" = true;
363 "no-color" = true;
364 "compare-checksums" = true;
365 "delete" = true;
366 };
367 description = "Settings to pass to the document exporter as CLI arguments.";
368 };
369 };
370
371 configureTika = lib.mkOption {
372 type = lib.types.bool;
373 default = false;
374 description = ''
375 Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR.
376 '';
377 };
378
379 manage = lib.mkOption {
380 type = lib.types.package;
381 readOnly = true;
382 description = ''
383 The package derivation for the `paperless-manage` wrapper script.
384 Useful for other modules that need to add this specific script to a service's PATH.
385 '';
386 };
387 };
388
389 config = lib.mkIf cfg.enable (
390 lib.mkMerge [
391 {
392 assertions = [
393 {
394 assertion = cfg.configureNginx -> cfg.domain != null;
395 message = "${opt.configureNginx} requires ${opt.domain} to be configured.";
396 }
397 ];
398
399 services.paperless.manage = manage;
400 environment.systemPackages = [ manage ];
401
402 services.nginx = lib.mkIf cfg.configureNginx {
403 enable = true;
404 upstreams.paperless.servers."${cfg.address}:${toString cfg.port}" = { };
405 virtualHosts.${cfg.domain} = {
406 forceSSL = lib.mkDefault true;
407 locations = {
408 "/".proxyPass = "http://paperless";
409 "/static/" = {
410 root = config.services.paperless.package;
411 extraConfig = ''
412 rewrite ^/(.*)$ /lib/paperless-ngx/$1 break;
413 '';
414 };
415 "/ws/status" = {
416 proxyPass = "http://paperless";
417 proxyWebsockets = true;
418 };
419 };
420 };
421 };
422
423 services.redis.servers.paperless.enable = lib.mkIf enableRedis true;
424
425 services.postgresql = lib.mkIf cfg.database.createLocally {
426 enable = true;
427 ensureDatabases = [ "paperless" ];
428 ensureUsers = [
429 {
430 name = config.services.paperless.user;
431 ensureDBOwnership = true;
432 }
433 ];
434 };
435
436 services.paperless.settings = lib.mkMerge [
437 (lib.mkIf (cfg.domain != null) {
438 PAPERLESS_URL = "https://${cfg.domain}";
439 })
440 (lib.mkIf cfg.database.createLocally {
441 PAPERLESS_DBENGINE = "postgresql";
442 PAPERLESS_DBHOST = "/run/postgresql";
443 PAPERLESS_DBNAME = "paperless";
444 PAPERLESS_DBUSER = "paperless";
445 })
446 (lib.mkIf cfg.configureTika {
447 PAPERLESS_GOTENBERG_ENABLED = true;
448 PAPERLESS_TIKA_ENABLED = true;
449 })
450 ];
451
452 systemd.slices.system-paperless = {
453 description = "Paperless Document Management System Slice";
454 documentation = [ "https://docs.paperless-ngx.com" ];
455 };
456
457 systemd.tmpfiles.settings."10-paperless" =
458 let
459 defaultRule = {
460 inherit (cfg) user;
461 inherit (config.users.users.${cfg.user}) group;
462 };
463 in
464 {
465 "${cfg.dataDir}".d = defaultRule;
466 "${cfg.mediaDir}".d = defaultRule;
467 "${cfg.consumptionDir}".d = if cfg.consumptionDirIsPublic then { mode = "777"; } else defaultRule;
468 };
469
470 systemd.services.paperless-scheduler = {
471 description = "Paperless Celery Beat";
472 wantedBy = [ "multi-user.target" ];
473 wants = [
474 "paperless-consumer.service"
475 "paperless-web.service"
476 "paperless-task-queue.service"
477 ];
478 serviceConfig = defaultServiceConfig // {
479 User = cfg.user;
480 ExecStart = "${cfg.package}/bin/celery --app paperless beat --loglevel INFO";
481 Restart = "on-failure";
482 LoadCredential = lib.optionalString (
483 cfg.passwordFile != null
484 ) "PAPERLESS_ADMIN_PASSWORD:${cfg.passwordFile}";
485 PrivateNetwork = cfg.database.createLocally; # defaultServiceConfig enables this by default, needs to be disabled for remote DBs
486 };
487 environment = env;
488
489 preStart = ''
490 # remove old papaerless-manage symlink
491 # TODO: drop with NixOS 25.11
492 [[ -L '${cfg.dataDir}/paperless-manage' ]] && rm '${cfg.dataDir}/paperless-manage'
493
494 # Auto-migrate on first run or if the package has changed
495 versionFile="${cfg.dataDir}/src-version"
496 version=$(cat "$versionFile" 2>/dev/null || echo 0)
497
498 if [[ $version != ${cfg.package.version} ]]; then
499 ${cfg.package}/bin/paperless-ngx migrate
500
501 # Parse old version string format for backwards compatibility
502 version=$(echo "$version" | grep -ohP '[^-]+$')
503
504 versionLessThan() {
505 target=$1
506 [[ $({ echo "$version"; echo "$target"; } | sort -V | head -1) != "$target" ]]
507 }
508
509 if versionLessThan 1.12.0; then
510 # Reindex documents as mentioned in https://github.com/paperless-ngx/paperless-ngx/releases/tag/v1.12.1
511 echo "Reindexing documents, to allow searching old comments. Required after the 1.12.x upgrade."
512 ${cfg.package}/bin/paperless-ngx document_index reindex
513 fi
514
515 echo ${cfg.package.version} > "$versionFile"
516 fi
517
518 if ${lib.boolToString (cfg.passwordFile != null)} || [[ -n $PAPERLESS_ADMIN_PASSWORD ]]; then
519 export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}"
520 if [[ -e $CREDENTIALS_DIRECTORY/PAPERLESS_ADMIN_PASSWORD ]]; then
521 PAPERLESS_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/PAPERLESS_ADMIN_PASSWORD")
522 export PAPERLESS_ADMIN_PASSWORD
523 fi
524 superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD"
525 superuserStateFile="${cfg.dataDir}/superuser-state"
526
527 if [[ $(cat "$superuserStateFile" 2>/dev/null) != "$superuserState" ]]; then
528 ${cfg.package}/bin/paperless-ngx manage_superuser
529 echo "$superuserState" > "$superuserStateFile"
530 fi
531 fi
532 '';
533 requires = lib.optional cfg.database.createLocally "postgresql.target";
534 after =
535 lib.optional enableRedis "redis-paperless.service"
536 ++ lib.optional cfg.database.createLocally "postgresql.target";
537 };
538
539 systemd.services.paperless-task-queue = {
540 description = "Paperless Celery Workers";
541 requires = lib.optional cfg.database.createLocally "postgresql.target";
542 after = [
543 "paperless-scheduler.service"
544 ]
545 ++ lib.optional cfg.database.createLocally "postgresql.target";
546 serviceConfig = defaultServiceConfig // {
547 User = cfg.user;
548 ExecStart = "${cfg.package}/bin/celery --app paperless worker --loglevel INFO";
549 Restart = "on-failure";
550 # The `mbind` syscall is needed for running the classifier.
551 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ];
552 # Needs to talk to mail server for automated import rules
553 PrivateNetwork = false;
554 };
555 environment = env;
556 };
557
558 systemd.services.paperless-consumer = {
559 description = "Paperless document consumer";
560 # Bind to `paperless-scheduler` so that the consumer never runs
561 # during migrations
562 bindsTo = [ "paperless-scheduler.service" ];
563 requires = lib.optional cfg.database.createLocally "postgresql.target";
564 after = [
565 "paperless-scheduler.service"
566 ]
567 ++ lib.optional cfg.database.createLocally "postgresql.target";
568 serviceConfig = defaultServiceConfig // {
569 User = cfg.user;
570 ExecStart = "${cfg.package}/bin/paperless-ngx document_consumer";
571 Restart = "on-failure";
572 PrivateNetwork = cfg.database.createLocally; # defaultServiceConfig enables this by default, needs to be disabled for remote DBs
573 };
574 environment = env;
575 # Allow the consumer to access the private /tmp directory of the server.
576 # This is required to support consuming files via a local folder.
577 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
578 };
579
580 systemd.services.paperless-web = {
581 description = "Paperless web server";
582 # Bind to `paperless-scheduler` so that the web server never runs
583 # during migrations
584 bindsTo = [ "paperless-scheduler.service" ];
585 requires = lib.optional cfg.database.createLocally "postgresql.target";
586 after = [
587 "paperless-scheduler.service"
588 ]
589 ++ lib.optional cfg.database.createLocally "postgresql.target";
590 # Setup PAPERLESS_SECRET_KEY.
591 # If this environment variable is left unset, paperless-ngx defaults
592 # to a well-known value, which is insecure.
593 script =
594 let
595 secretKeyFile = "${cfg.dataDir}/nixos-paperless-secret-key";
596 in
597 ''
598 if [[ ! -f '${secretKeyFile}' ]]; then
599 (
600 umask 0377
601 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge '${secretKeyFile}'
602 )
603 fi
604 PAPERLESS_SECRET_KEY="$(cat '${secretKeyFile}')"
605 export PAPERLESS_SECRET_KEY
606 if [[ ! $PAPERLESS_SECRET_KEY ]]; then
607 echo "PAPERLESS_SECRET_KEY is empty, refusing to start."
608 exit 1
609 fi
610 exec ${lib.getExe cfg.package.python.pkgs.granian} --interface asginl --ws "paperless.asgi:application"
611 '';
612 serviceConfig = defaultServiceConfig // {
613 User = cfg.user;
614 Restart = "on-failure";
615
616 LimitNOFILE = 65536;
617 # liblapack needs mbind
618 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ];
619 # Needs to serve web page
620 PrivateNetwork = false;
621 };
622 environment = env // {
623 PYTHONPATH = "${cfg.package.python.pkgs.makePythonPath cfg.package.propagatedBuildInputs}:${cfg.package}/lib/paperless-ngx/src";
624 };
625 # Allow the web interface to access the private /tmp directory of the server.
626 # This is required to support uploading files via the web interface.
627 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
628 };
629
630 users = lib.optionalAttrs (cfg.user == defaultUser) {
631 users.${defaultUser} = {
632 group = defaultUser;
633 uid = config.ids.uids.paperless;
634 home = cfg.dataDir;
635 };
636
637 groups.${defaultUser} = {
638 gid = config.ids.gids.paperless;
639 };
640 };
641
642 services.gotenberg = lib.mkIf cfg.configureTika {
643 enable = true;
644 # https://github.com/paperless-ngx/paperless-ngx/blob/v2.18.2/docker/compose/docker-compose.sqlite-tika.yml#L60-L65
645 chromium.disableJavascript = true;
646 extraArgs = [ "--chromium-allow-list=file:///tmp/.*" ];
647 };
648
649 services.tika = lib.mkIf cfg.configureTika {
650 enable = true;
651 enableOcr = true;
652 };
653 }
654
655 (lib.mkIf cfg.exporter.enable {
656 systemd.tmpfiles.rules = [
657 "d '${cfg.exporter.directory}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
658 ];
659
660 services.paperless.exporter.settings = options.services.paperless.exporter.settings.default;
661
662 systemd.services.paperless-exporter = {
663 startAt = lib.defaultTo [ ] cfg.exporter.onCalendar;
664 serviceConfig = {
665 User = cfg.user;
666 WorkingDirectory = cfg.dataDir;
667 };
668 unitConfig =
669 let
670 services = [
671 "paperless-consumer.service"
672 "paperless-scheduler.service"
673 "paperless-task-queue.service"
674 "paperless-web.service"
675 ];
676 in
677 {
678 # Shut down the paperless services while the exporter runs
679 Conflicts = services;
680 After = services;
681 # Bring them back up afterwards, regardless of pass/fail
682 OnFailure = services;
683 OnSuccess = services;
684 };
685 enableStrictShellChecks = true;
686 path = [ manage ];
687 script = ''
688 paperless-manage document_exporter ${cfg.exporter.directory} ${
689 lib.cli.toGNUCommandLineShell { } cfg.exporter.settings
690 }
691 '';
692 };
693 })
694 ]
695 );
696}