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