1{ config, options, lib, pkgs, utils, ... }:
2
3let
4 json = pkgs.formats.json {};
5
6 cfg = config.services.discourse;
7 opt = options.services.discourse;
8
9 # Keep in sync with https://github.com/discourse/discourse_docker/blob/main/image/base/slim.Dockerfile#L5
10 upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13;
11
12 postgresqlPackage = if config.services.postgresql.enable then
13 config.services.postgresql.package
14 else
15 pkgs.postgresql;
16
17 postgresqlVersion = lib.getVersion postgresqlPackage;
18
19 # We only want to create a database if we're actually going to connect to it.
20 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null;
21
22 tlsEnabled = (cfg.enableACME
23 || cfg.sslCertificate != null
24 || cfg.sslCertificateKey != null);
25in
26{
27 options = {
28 services.discourse = {
29 enable = lib.mkEnableOption (lib.mdDoc "Discourse, an open source discussion platform");
30
31 package = lib.mkOption {
32 type = lib.types.package;
33 default = pkgs.discourse;
34 apply = p: p.override {
35 plugins = lib.unique (p.enabledPlugins ++ cfg.plugins);
36 };
37 defaultText = lib.literalExpression "pkgs.discourse";
38 description = lib.mdDoc ''
39 The discourse package to use.
40 '';
41 };
42
43 hostname = lib.mkOption {
44 type = lib.types.str;
45 default = config.networking.fqdnOrHostName;
46 defaultText = lib.literalExpression "config.networking.fqdnOrHostName";
47 example = "discourse.example.com";
48 description = lib.mdDoc ''
49 The hostname to serve Discourse on.
50 '';
51 };
52
53 secretKeyBaseFile = lib.mkOption {
54 type = with lib.types; nullOr path;
55 default = null;
56 example = "/run/keys/secret_key_base";
57 description = lib.mdDoc ''
58 The path to a file containing the
59 `secret_key_base` secret.
60
61 Discourse uses `secret_key_base` to encrypt
62 the cookie store, which contains session data, and to digest
63 user auth tokens.
64
65 Needs to be a 64 byte long string of hexadecimal
66 characters. You can generate one by running
67
68 ```
69 openssl rand -hex 64 >/path/to/secret_key_base_file
70 ```
71
72 This should be a string, not a nix path, since nix paths are
73 copied into the world-readable nix store.
74 '';
75 };
76
77 sslCertificate = lib.mkOption {
78 type = with lib.types; nullOr path;
79 default = null;
80 example = "/run/keys/ssl.cert";
81 description = lib.mdDoc ''
82 The path to the server SSL certificate. Set this to enable
83 SSL.
84 '';
85 };
86
87 sslCertificateKey = lib.mkOption {
88 type = with lib.types; nullOr path;
89 default = null;
90 example = "/run/keys/ssl.key";
91 description = lib.mdDoc ''
92 The path to the server SSL certificate key. Set this to
93 enable SSL.
94 '';
95 };
96
97 enableACME = lib.mkOption {
98 type = lib.types.bool;
99 default = cfg.sslCertificate == null && cfg.sslCertificateKey == null;
100 defaultText = lib.literalMD ''
101 `true`, unless {option}`services.discourse.sslCertificate`
102 and {option}`services.discourse.sslCertificateKey` are set.
103 '';
104 description = lib.mdDoc ''
105 Whether an ACME certificate should be used to secure
106 connections to the server.
107 '';
108 };
109
110 backendSettings = lib.mkOption {
111 type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ]));
112 default = {};
113 example = lib.literalExpression ''
114 {
115 max_reqs_per_ip_per_minute = 300;
116 max_reqs_per_ip_per_10_seconds = 60;
117 max_asset_reqs_per_ip_per_10_seconds = 250;
118 max_reqs_per_ip_mode = "warn+block";
119 };
120 '';
121 description = lib.mdDoc ''
122 Additional settings to put in the
123 {file}`discourse.conf` file.
124
125 Look in the
126 [discourse_defaults.conf](https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf)
127 file in the upstream distribution to find available options.
128
129 Setting an option to `null` means
130 “define variable, but leave right-hand side empty”.
131 '';
132 };
133
134 siteSettings = lib.mkOption {
135 type = json.type;
136 default = {};
137 example = lib.literalExpression ''
138 {
139 required = {
140 title = "My Cats";
141 site_description = "Discuss My Cats (and be nice plz)";
142 };
143 login = {
144 enable_github_logins = true;
145 github_client_id = "a2f6dfe838cb3206ce20";
146 github_client_secret._secret = /run/keys/discourse_github_client_secret;
147 };
148 };
149 '';
150 description = lib.mdDoc ''
151 Discourse site settings. These are the settings that can be
152 changed from the UI. This only defines their default values:
153 they can still be overridden from the UI.
154
155 Available settings can be found by looking in the
156 [site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml)
157 file of the upstream distribution. To find a setting's path,
158 you only need to care about the first two levels; i.e. its
159 category and name. See the example.
160
161 Settings containing secret data should be set to an
162 attribute set containing the attribute
163 `_secret` - a string pointing to a file
164 containing the value the option should be set to. See the
165 example to get a better picture of this: in the resulting
166 {file}`config/nixos_site_settings.json` file,
167 the `login.github_client_secret` key will
168 be set to the contents of the
169 {file}`/run/keys/discourse_github_client_secret`
170 file.
171 '';
172 };
173
174 admin = {
175 skipCreate = lib.mkOption {
176 type = lib.types.bool;
177 default = false;
178 description = lib.mdDoc ''
179 Do not create the admin account, instead rely on other
180 existing admin accounts.
181 '';
182 };
183
184 email = lib.mkOption {
185 type = lib.types.str;
186 example = "admin@example.com";
187 description = lib.mdDoc ''
188 The admin user email address.
189 '';
190 };
191
192 username = lib.mkOption {
193 type = lib.types.str;
194 example = "admin";
195 description = lib.mdDoc ''
196 The admin user username.
197 '';
198 };
199
200 fullName = lib.mkOption {
201 type = lib.types.str;
202 description = lib.mdDoc ''
203 The admin user's full name.
204 '';
205 };
206
207 passwordFile = lib.mkOption {
208 type = lib.types.path;
209 description = lib.mdDoc ''
210 A path to a file containing the admin user's password.
211
212 This should be a string, not a nix path, since nix paths are
213 copied into the world-readable nix store.
214 '';
215 };
216 };
217
218 nginx.enable = lib.mkOption {
219 type = lib.types.bool;
220 default = true;
221 description = lib.mdDoc ''
222 Whether an `nginx` virtual host should be
223 set up to serve Discourse. Only disable if you're planning
224 to use a different web server, which is not recommended.
225 '';
226 };
227
228 database = {
229 pool = lib.mkOption {
230 type = lib.types.int;
231 default = 8;
232 description = lib.mdDoc ''
233 Database connection pool size.
234 '';
235 };
236
237 host = lib.mkOption {
238 type = with lib.types; nullOr str;
239 default = null;
240 description = lib.mdDoc ''
241 Discourse database hostname. `null` means
242 “prefer local unix socket connection”.
243 '';
244 };
245
246 passwordFile = lib.mkOption {
247 type = with lib.types; nullOr path;
248 default = null;
249 description = lib.mdDoc ''
250 File containing the Discourse database user password.
251
252 This should be a string, not a nix path, since nix paths are
253 copied into the world-readable nix store.
254 '';
255 };
256
257 createLocally = lib.mkOption {
258 type = lib.types.bool;
259 default = true;
260 description = lib.mdDoc ''
261 Whether a database should be automatically created on the
262 local host. Set this to `false` if you plan
263 on provisioning a local database yourself. This has no effect
264 if {option}`services.discourse.database.host` is customized.
265 '';
266 };
267
268 name = lib.mkOption {
269 type = lib.types.str;
270 default = "discourse";
271 description = lib.mdDoc ''
272 Discourse database name.
273 '';
274 };
275
276 username = lib.mkOption {
277 type = lib.types.str;
278 default = "discourse";
279 description = lib.mdDoc ''
280 Discourse database user.
281 '';
282 };
283
284 ignorePostgresqlVersion = lib.mkOption {
285 type = lib.types.bool;
286 default = false;
287 description = lib.mdDoc ''
288 Whether to allow other versions of PostgreSQL than the
289 recommended one. Only effective when
290 {option}`services.discourse.database.createLocally`
291 is enabled.
292 '';
293 };
294 };
295
296 redis = {
297 host = lib.mkOption {
298 type = lib.types.str;
299 default = "localhost";
300 description = lib.mdDoc ''
301 Redis server hostname.
302 '';
303 };
304
305 passwordFile = lib.mkOption {
306 type = with lib.types; nullOr path;
307 default = null;
308 description = lib.mdDoc ''
309 File containing the Redis password.
310
311 This should be a string, not a nix path, since nix paths are
312 copied into the world-readable nix store.
313 '';
314 };
315
316 dbNumber = lib.mkOption {
317 type = lib.types.int;
318 default = 0;
319 description = lib.mdDoc ''
320 Redis database number.
321 '';
322 };
323
324 useSSL = lib.mkOption {
325 type = lib.types.bool;
326 default = cfg.redis.host != "localhost";
327 defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"'';
328 description = lib.mdDoc ''
329 Connect to Redis with SSL.
330 '';
331 };
332 };
333
334 mail = {
335 notificationEmailAddress = lib.mkOption {
336 type = lib.types.str;
337 default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}";
338 defaultText = lib.literalExpression ''
339 "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}"
340 '';
341 description = lib.mdDoc ''
342 The `from:` email address used when
343 sending all essential system emails. The domain specified
344 here must have SPF, DKIM and reverse PTR records set
345 correctly for email to arrive.
346 '';
347 };
348
349 contactEmailAddress = lib.mkOption {
350 type = lib.types.str;
351 default = "";
352 description = lib.mdDoc ''
353 Email address of key contact responsible for this
354 site. Used for critical notifications, as well as on the
355 `/about` contact form for urgent matters.
356 '';
357 };
358
359 outgoing = {
360 serverAddress = lib.mkOption {
361 type = lib.types.str;
362 default = "localhost";
363 description = lib.mdDoc ''
364 The address of the SMTP server Discourse should use to
365 send email.
366 '';
367 };
368
369 port = lib.mkOption {
370 type = lib.types.port;
371 default = 25;
372 description = lib.mdDoc ''
373 The port of the SMTP server Discourse should use to
374 send email.
375 '';
376 };
377
378 username = lib.mkOption {
379 type = with lib.types; nullOr str;
380 default = null;
381 description = lib.mdDoc ''
382 The username of the SMTP server.
383 '';
384 };
385
386 passwordFile = lib.mkOption {
387 type = lib.types.nullOr lib.types.path;
388 default = null;
389 description = lib.mdDoc ''
390 A file containing the password of the SMTP server account.
391
392 This should be a string, not a nix path, since nix paths
393 are copied into the world-readable nix store.
394 '';
395 };
396
397 domain = lib.mkOption {
398 type = lib.types.str;
399 default = cfg.hostname;
400 defaultText = lib.literalExpression "config.${opt.hostname}";
401 description = lib.mdDoc ''
402 HELO domain to use for outgoing mail.
403 '';
404 };
405
406 authentication = lib.mkOption {
407 type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]);
408 default = null;
409 description = lib.mdDoc ''
410 Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
411 '';
412 };
413
414 enableStartTLSAuto = lib.mkOption {
415 type = lib.types.bool;
416 default = true;
417 description = lib.mdDoc ''
418 Whether to try to use StartTLS.
419 '';
420 };
421
422 opensslVerifyMode = lib.mkOption {
423 type = lib.types.str;
424 default = "peer";
425 description = lib.mdDoc ''
426 How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
427 '';
428 };
429
430 forceTLS = lib.mkOption {
431 type = lib.types.bool;
432 default = false;
433 description = lib.mdDoc ''
434 Force implicit TLS as per RFC 8314 3.3.
435 '';
436 };
437 };
438
439 incoming = {
440 enable = lib.mkOption {
441 type = lib.types.bool;
442 default = false;
443 description = lib.mdDoc ''
444 Whether to set up Postfix to receive incoming mail.
445 '';
446 };
447
448 replyEmailAddress = lib.mkOption {
449 type = lib.types.str;
450 default = "%{reply_key}@${cfg.hostname}";
451 defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"'';
452 description = lib.mdDoc ''
453 Template for reply by email incoming email address, for
454 example: %{reply_key}@reply.example.com or
455 replies+%{reply_key}@example.com
456 '';
457 };
458
459 mailReceiverPackage = lib.mkOption {
460 type = lib.types.package;
461 default = pkgs.discourse-mail-receiver;
462 defaultText = lib.literalExpression "pkgs.discourse-mail-receiver";
463 description = lib.mdDoc ''
464 The discourse-mail-receiver package to use.
465 '';
466 };
467
468 apiKeyFile = lib.mkOption {
469 type = lib.types.nullOr lib.types.path;
470 default = null;
471 description = lib.mdDoc ''
472 A file containing the Discourse API key used to add
473 posts and messages from mail. If left at its default
474 value `null`, one will be automatically
475 generated.
476
477 This should be a string, not a nix path, since nix paths
478 are copied into the world-readable nix store.
479 '';
480 };
481 };
482 };
483
484 plugins = lib.mkOption {
485 type = lib.types.listOf lib.types.package;
486 default = [];
487 example = lib.literalExpression ''
488 with config.services.discourse.package.plugins; [
489 discourse-canned-replies
490 discourse-github
491 ];
492 '';
493 description = lib.mdDoc ''
494 Plugins to install as part of Discourse, expressed as a list of derivations.
495 '';
496 };
497
498 sidekiqProcesses = lib.mkOption {
499 type = lib.types.int;
500 default = 1;
501 description = lib.mdDoc ''
502 How many Sidekiq processes should be spawned.
503 '';
504 };
505
506 unicornTimeout = lib.mkOption {
507 type = lib.types.int;
508 default = 30;
509 description = lib.mdDoc ''
510 Time in seconds before a request to Unicorn times out.
511
512 This can be raised if the system Discourse is running on is
513 too slow to handle many requests within 30 seconds.
514 '';
515 };
516 };
517 };
518
519 config = lib.mkIf cfg.enable {
520 assertions = [
521 {
522 assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null);
523 message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!";
524 }
525 {
526 assertion = cfg.hostname != "";
527 message = "Could not automatically determine hostname, set service.discourse.hostname manually.";
528 }
529 {
530 assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion);
531 message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. "
532 + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. "
533 + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL.";
534 }
535 ];
536
537
538 # Default config values are from `config/discourse_defaults.conf`
539 # upstream.
540 services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) {
541 db_pool = cfg.database.pool;
542 db_timeout = 5000;
543 db_connect_timeout = 5;
544 db_socket = null;
545 db_host = cfg.database.host;
546 db_backup_host = null;
547 db_port = null;
548 db_backup_port = 5432;
549 db_name = cfg.database.name;
550 db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username;
551 db_password = cfg.database.passwordFile;
552 db_prepared_statements = false;
553 db_replica_host = null;
554 db_replica_port = null;
555 db_advisory_locks = true;
556
557 inherit (cfg) hostname;
558 backup_hostname = null;
559
560 smtp_address = cfg.mail.outgoing.serverAddress;
561 smtp_port = cfg.mail.outgoing.port;
562 smtp_domain = cfg.mail.outgoing.domain;
563 smtp_user_name = cfg.mail.outgoing.username;
564 smtp_password = cfg.mail.outgoing.passwordFile;
565 smtp_authentication = cfg.mail.outgoing.authentication;
566 smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto;
567 smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode;
568 smtp_force_tls = cfg.mail.outgoing.forceTLS;
569
570 load_mini_profiler = true;
571 mini_profiler_snapshots_period = 0;
572 mini_profiler_snapshots_transport_url = null;
573 mini_profiler_snapshots_transport_auth_key = null;
574
575 cdn_url = null;
576 cdn_origin_hostname = null;
577 developer_emails = null;
578
579 redis_host = cfg.redis.host;
580 redis_port = 6379;
581 redis_replica_host = null;
582 redis_replica_port = 6379;
583 redis_db = cfg.redis.dbNumber;
584 redis_password = cfg.redis.passwordFile;
585 redis_skip_client_commands = false;
586 redis_use_ssl = cfg.redis.useSSL;
587
588 message_bus_redis_enabled = false;
589 message_bus_redis_host = "localhost";
590 message_bus_redis_port = 6379;
591 message_bus_redis_replica_host = null;
592 message_bus_redis_replica_port = 6379;
593 message_bus_redis_db = 0;
594 message_bus_redis_password = null;
595 message_bus_redis_skip_client_commands = false;
596
597 enable_cors = false;
598 cors_origin = "";
599 serve_static_assets = false;
600 sidekiq_workers = 5;
601 connection_reaper_age = 30;
602 connection_reaper_interval = 30;
603 relative_url_root = null;
604 message_bus_max_backlog_size = 100;
605 message_bus_clear_every = 50;
606 secret_key_base = cfg.secretKeyBaseFile;
607 fallback_assets_path = null;
608
609 s3_bucket = null;
610 s3_region = null;
611 s3_access_key_id = null;
612 s3_secret_access_key = null;
613 s3_use_iam_profile = null;
614 s3_cdn_url = null;
615 s3_endpoint = null;
616 s3_http_continue_timeout = null;
617 s3_install_cors_rule = null;
618
619 max_user_api_reqs_per_minute = 20;
620 max_user_api_reqs_per_day = 2880;
621 max_admin_api_reqs_per_minute = 60;
622 max_reqs_per_ip_per_minute = 200;
623 max_reqs_per_ip_per_10_seconds = 50;
624 max_asset_reqs_per_ip_per_10_seconds = 200;
625 max_reqs_per_ip_mode = "block";
626 max_reqs_rate_limit_on_private = false;
627 skip_per_ip_rate_limit_trust_level = 1;
628 force_anonymous_min_queue_seconds = 1;
629 force_anonymous_min_per_10_seconds = 3;
630 background_requests_max_queue_length = 0.5;
631 reject_message_bus_queue_seconds = 0.1;
632 disable_search_queue_threshold = 1;
633 max_old_rebakes_per_15_minutes = 300;
634 max_logster_logs = 1000;
635 refresh_maxmind_db_during_precompile_days = 2;
636 maxmind_backup_path = null;
637 maxmind_license_key = null;
638 enable_performance_http_headers = false;
639 enable_js_error_reporting = true;
640 mini_scheduler_workers = 5;
641 compress_anon_cache = false;
642 anon_cache_store_threshold = 2;
643 allowed_theme_repos = null;
644 enable_email_sync_demon = false;
645 max_digests_enqueued_per_30_mins_per_site = 10000;
646 cluster_name = null;
647 multisite_config_path = "config/multisite.yml";
648 enable_long_polling = null;
649 long_polling_interval = null;
650 };
651
652 services.redis.servers.discourse =
653 lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) {
654 enable = true;
655 bind = cfg.redis.host;
656 port = cfg.backendSettings.redis_port;
657 };
658
659 services.postgresql = lib.mkIf databaseActuallyCreateLocally {
660 enable = true;
661 ensureUsers = [{ name = "discourse"; }];
662 };
663
664 # The postgresql module doesn't currently support concepts like
665 # objects owners and extensions; for now we tack on what's needed
666 # here.
667 systemd.services.discourse-postgresql =
668 let
669 pgsql = config.services.postgresql;
670 in
671 lib.mkIf databaseActuallyCreateLocally {
672 after = [ "postgresql.service" ];
673 bindsTo = [ "postgresql.service" ];
674 wantedBy = [ "discourse.service" ];
675 partOf = [ "discourse.service" ];
676 path = [
677 pgsql.package
678 ];
679 script = ''
680 set -o errexit -o pipefail -o nounset -o errtrace
681 shopt -s inherit_errexit
682
683 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"'
684 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm"
685 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore"
686 '';
687
688 serviceConfig = {
689 User = pgsql.superUser;
690 Type = "oneshot";
691 RemainAfterExit = true;
692 };
693 };
694
695 systemd.services.discourse = {
696 wantedBy = [ "multi-user.target" ];
697 after = [
698 "redis-discourse.service"
699 "postgresql.service"
700 "discourse-postgresql.service"
701 ];
702 bindsTo = [
703 "redis-discourse.service"
704 ] ++ lib.optionals (cfg.database.host == null) [
705 "postgresql.service"
706 "discourse-postgresql.service"
707 ];
708 path = cfg.package.runtimeDeps ++ [
709 postgresqlPackage
710 pkgs.replace-secret
711 cfg.package.rake
712 ];
713 environment = cfg.package.runtimeEnv // {
714 UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout;
715 UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses;
716 MALLOC_ARENA_MAX = "2";
717 };
718
719 preStart =
720 let
721 discourseKeyValue = lib.generators.toKeyValue {
722 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " {
723 mkValueString = v: with builtins;
724 if isInt v then toString v
725 else if isString v then ''"${v}"''
726 else if true == v then "true"
727 else if false == v then "false"
728 else if null == v then ""
729 else if isFloat v then lib.strings.floatToString v
730 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
731 };
732 };
733
734 discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings);
735
736 mkSecretReplacement = file:
737 lib.optionalString (file != null) ''
738 replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf
739 '';
740
741 mkAdmin = ''
742 export ADMIN_EMAIL="${cfg.admin.email}"
743 export ADMIN_NAME="${cfg.admin.fullName}"
744 export ADMIN_USERNAME="${cfg.admin.username}"
745 ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})"
746 export ADMIN_PASSWORD
747 discourse-rake admin:create_noninteractively
748 '';
749
750 in ''
751 set -o errexit -o pipefail -o nounset -o errtrace
752 shopt -s inherit_errexit
753
754 umask u=rwx,g=rx,o=
755
756 rm -rf /var/lib/discourse/tmp/*
757
758 cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/
759 cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/
760 ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads
761 ln -sf /var/lib/discourse/backups /run/discourse/public/backups
762
763 (
764 umask u=rwx,g=,o=
765
766 ${utils.genJqSecretsReplacementSnippet
767 cfg.siteSettings
768 "/run/discourse/config/nixos_site_settings.json"
769 }
770 install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf
771 ${mkSecretReplacement cfg.database.passwordFile}
772 ${mkSecretReplacement cfg.mail.outgoing.passwordFile}
773 ${mkSecretReplacement cfg.redis.passwordFile}
774 ${mkSecretReplacement cfg.secretKeyBaseFile}
775 chmod 0400 /run/discourse/config/discourse.conf
776 )
777
778 discourse-rake db:migrate >>/var/log/discourse/db_migration.log
779 chmod -R u+w /var/lib/discourse/tmp/
780
781 ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin}
782
783 discourse-rake themes:update
784 discourse-rake uploads:regenerate_missing_optimized
785 '';
786
787 serviceConfig = {
788 Type = "simple";
789 User = "discourse";
790 Group = "discourse";
791 RuntimeDirectory = map (p: "discourse/" + p) [
792 "config"
793 "home"
794 "assets/javascripts/plugins"
795 "public"
796 "sockets"
797 ];
798 RuntimeDirectoryMode = "0750";
799 StateDirectory = map (p: "discourse/" + p) [
800 "uploads"
801 "backups"
802 "tmp"
803 ];
804 StateDirectoryMode = "0750";
805 LogsDirectory = "discourse";
806 TimeoutSec = "infinity";
807 Restart = "on-failure";
808 WorkingDirectory = "${cfg.package}/share/discourse";
809
810 RemoveIPC = true;
811 PrivateTmp = true;
812 NoNewPrivileges = true;
813 RestrictSUIDSGID = true;
814 ProtectSystem = "strict";
815 ProtectHome = "read-only";
816
817 ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb";
818 };
819 };
820
821 services.nginx = lib.mkIf cfg.nginx.enable {
822 enable = true;
823 additionalModules = [ pkgs.nginxModules.brotli ];
824
825 recommendedTlsSettings = true;
826 recommendedOptimisation = true;
827 recommendedGzipSettings = true;
828 recommendedProxySettings = true;
829
830 upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {};
831
832 appendHttpConfig = ''
833 # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
834 # levels means it is a 2 deep hierarchy cause we can have lots of files
835 # max_size limits the size of the cache
836 proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m;
837
838 # see: https://meta.discourse.org/t/x/74060
839 proxy_buffer_size 8k;
840 '';
841
842 virtualHosts.${cfg.hostname} = {
843 inherit (cfg) sslCertificate sslCertificateKey enableACME;
844 forceSSL = lib.mkDefault tlsEnabled;
845
846 root = "${cfg.package}/share/discourse/public";
847
848 locations =
849 let
850 proxy = { extraConfig ? "" }: {
851 proxyPass = "http://discourse";
852 extraConfig = extraConfig + ''
853 proxy_set_header X-Request-Start "t=''${msec}";
854 '';
855 };
856 cache = time: ''
857 expires ${time};
858 add_header Cache-Control public,immutable;
859 '';
860 cache_1y = cache "1y";
861 cache_1d = cache "1d";
862 in
863 {
864 "/".tryFiles = "$uri @discourse";
865 "@discourse" = proxy {};
866 "^~ /backups/".extraConfig = ''
867 internal;
868 '';
869 "/favicon.ico" = {
870 return = "204";
871 extraConfig = ''
872 access_log off;
873 log_not_found off;
874 '';
875 };
876 "~ ^/uploads/short-url/" = proxy {};
877 "~ ^/secure-media-uploads/" = proxy {};
878 "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + ''
879 add_header Access-Control-Allow-Origin *;
880 '';
881 "/srv/status" = proxy {
882 extraConfig = ''
883 access_log off;
884 log_not_found off;
885 '';
886 };
887 "~ ^/javascripts/".extraConfig = cache_1d;
888 "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + ''
889 # asset pipeline enables this
890 brotli_static on;
891 gzip_static on;
892 '';
893 "~ ^/plugins/".extraConfig = cache_1y;
894 "~ /images/emoji/".extraConfig = cache_1y;
895 "~ ^/uploads/" = proxy {
896 extraConfig = cache_1y + ''
897 proxy_set_header X-Sendfile-Type X-Accel-Redirect;
898 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
899
900 # custom CSS
901 location ~ /stylesheet-cache/ {
902 try_files $uri =404;
903 }
904 # this allows us to bypass rails
905 location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
906 try_files $uri =404;
907 }
908 # SVG needs an extra header attached
909 location ~* \.(svg)$ {
910 }
911 # thumbnails & optimized images
912 location ~ /_?optimized/ {
913 try_files $uri =404;
914 }
915 '';
916 };
917 "~ ^/admin/backups/" = proxy {
918 extraConfig = ''
919 proxy_set_header X-Sendfile-Type X-Accel-Redirect;
920 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
921 '';
922 };
923 "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy {
924 extraConfig = ''
925 # if Set-Cookie is in the response nothing gets cached
926 # this is double bad cause we are not passing last modified in
927 proxy_ignore_headers "Set-Cookie";
928 proxy_hide_header "Set-Cookie";
929 proxy_hide_header "X-Discourse-Username";
930 proxy_hide_header "X-Runtime";
931
932 # note x-accel-redirect can not be used with proxy_cache
933 proxy_cache discourse;
934 proxy_cache_key "$scheme,$host,$request_uri";
935 proxy_cache_valid 200 301 302 7d;
936 '';
937 };
938 "/message-bus/" = proxy {
939 extraConfig = ''
940 proxy_http_version 1.1;
941 proxy_buffering off;
942 '';
943 };
944 "/downloads/".extraConfig = ''
945 internal;
946 alias ${cfg.package}/share/discourse/public/;
947 '';
948 };
949 };
950 };
951
952 systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable (
953 let
954 mail-receiver-environment = {
955 MAIL_DOMAIN = cfg.hostname;
956 DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
957 DISCOURSE_API_KEY = "@api-key@";
958 DISCOURSE_API_USERNAME = "system";
959 };
960 mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment;
961 in
962 {
963 before = [ "postfix.service" ];
964 after = [ "discourse.service" ];
965 wantedBy = [ "discourse.service" ];
966 partOf = [ "discourse.service" ];
967 path = [
968 cfg.package.rake
969 pkgs.jq
970 ];
971 preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) ''
972 set -o errexit -o pipefail -o nounset -o errtrace
973 shopt -s inherit_errexit
974
975 if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then
976 discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key
977 fi
978 '';
979 script =
980 let
981 apiKeyPath =
982 if cfg.mail.incoming.apiKeyFile == null then
983 "/var/lib/discourse-mail-receiver/api_key"
984 else
985 cfg.mail.incoming.apiKeyFile;
986 in ''
987 set -o errexit -o pipefail -o nounset -o errtrace
988 shopt -s inherit_errexit
989
990 api_key=$(<'${apiKeyPath}')
991 export api_key
992
993 jq <${mail-receiver-json} \
994 '.DISCOURSE_API_KEY = $ENV.api_key' \
995 >'/run/discourse-mail-receiver/mail-receiver-environment.json'
996 '';
997
998 serviceConfig = {
999 Type = "oneshot";
1000 RemainAfterExit = true;
1001 RuntimeDirectory = "discourse-mail-receiver";
1002 RuntimeDirectoryMode = "0700";
1003 StateDirectory = "discourse-mail-receiver";
1004 User = "discourse";
1005 Group = "discourse";
1006 };
1007 });
1008
1009 services.discourse.siteSettings = {
1010 required = {
1011 notification_email = cfg.mail.notificationEmailAddress;
1012 contact_email = cfg.mail.contactEmailAddress;
1013 };
1014 email = {
1015 manual_polling_enabled = cfg.mail.incoming.enable;
1016 reply_by_email_enabled = cfg.mail.incoming.enable;
1017 reply_by_email_address = cfg.mail.incoming.replyEmailAddress;
1018 };
1019 };
1020
1021 services.postfix = lib.mkIf cfg.mail.incoming.enable {
1022 enable = true;
1023 sslCert = if cfg.sslCertificate != null then cfg.sslCertificate else "";
1024 sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else "";
1025
1026 origin = cfg.hostname;
1027 relayDomains = [ cfg.hostname ];
1028 config = {
1029 smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy";
1030 append_dot_mydomain = lib.mkDefault false;
1031 compatibility_level = "2";
1032 smtputf8_enable = false;
1033 smtpd_banner = lib.mkDefault "ESMTP server";
1034 myhostname = lib.mkDefault cfg.hostname;
1035 mydestination = lib.mkDefault "localhost";
1036 };
1037 transport = ''
1038 ${cfg.hostname} discourse-mail-receiver:
1039 '';
1040 masterConfig = {
1041 "discourse-mail-receiver" = {
1042 type = "unix";
1043 privileged = true;
1044 chroot = false;
1045 command = "pipe";
1046 args = [
1047 "user=discourse"
1048 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail"
1049 "\${recipient}"
1050 ];
1051 };
1052 "discourse-policy" = {
1053 type = "unix";
1054 privileged = true;
1055 chroot = false;
1056 command = "spawn";
1057 args = [
1058 "user=discourse"
1059 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection"
1060 ];
1061 };
1062 };
1063 };
1064
1065 users.users = {
1066 discourse = {
1067 group = "discourse";
1068 isSystemUser = true;
1069 };
1070 } // (lib.optionalAttrs cfg.nginx.enable {
1071 ${config.services.nginx.user}.extraGroups = [ "discourse" ];
1072 });
1073
1074 users.groups = {
1075 discourse = {};
1076 };
1077
1078 environment.systemPackages = [
1079 cfg.package.rake
1080 ];
1081 };
1082
1083 meta.doc = ./discourse.xml;
1084 meta.maintainers = [ lib.maintainers.talyz ];
1085}