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