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 s3_asset_cdn_url = null;
619
620 max_user_api_reqs_per_minute = 20;
621 max_user_api_reqs_per_day = 2880;
622 max_admin_api_reqs_per_minute = 60;
623 max_reqs_per_ip_per_minute = 200;
624 max_reqs_per_ip_per_10_seconds = 50;
625 max_asset_reqs_per_ip_per_10_seconds = 200;
626 max_reqs_per_ip_mode = "block";
627 max_reqs_rate_limit_on_private = false;
628 skip_per_ip_rate_limit_trust_level = 1;
629 force_anonymous_min_queue_seconds = 1;
630 force_anonymous_min_per_10_seconds = 3;
631 background_requests_max_queue_length = 0.5;
632 reject_message_bus_queue_seconds = 0.1;
633 disable_search_queue_threshold = 1;
634 max_old_rebakes_per_15_minutes = 300;
635 max_logster_logs = 1000;
636 refresh_maxmind_db_during_precompile_days = 2;
637 maxmind_backup_path = null;
638 maxmind_license_key = null;
639 enable_performance_http_headers = false;
640 enable_js_error_reporting = true;
641 mini_scheduler_workers = 5;
642 compress_anon_cache = false;
643 anon_cache_store_threshold = 2;
644 allowed_theme_repos = null;
645 enable_email_sync_demon = false;
646 max_digests_enqueued_per_30_mins_per_site = 10000;
647 cluster_name = null;
648 multisite_config_path = "config/multisite.yml";
649 enable_long_polling = null;
650 long_polling_interval = null;
651 preload_link_header = false;
652 redirect_avatar_requests = false;
653 pg_force_readonly_mode = false;
654 dns_query_timeout_secs = null;
655 regex_timeout_seconds = 2;
656 allow_impersonation = true;
657 };
658
659 services.redis.servers.discourse =
660 lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) {
661 enable = true;
662 bind = cfg.redis.host;
663 port = cfg.backendSettings.redis_port;
664 };
665
666 services.postgresql = lib.mkIf databaseActuallyCreateLocally {
667 enable = true;
668 ensureUsers = [{ name = "discourse"; }];
669 };
670
671 # The postgresql module doesn't currently support concepts like
672 # objects owners and extensions; for now we tack on what's needed
673 # here.
674 systemd.services.discourse-postgresql =
675 let
676 pgsql = config.services.postgresql;
677 in
678 lib.mkIf databaseActuallyCreateLocally {
679 after = [ "postgresql.service" ];
680 bindsTo = [ "postgresql.service" ];
681 wantedBy = [ "discourse.service" ];
682 partOf = [ "discourse.service" ];
683 path = [
684 pgsql.package
685 ];
686 script = ''
687 set -o errexit -o pipefail -o nounset -o errtrace
688 shopt -s inherit_errexit
689
690 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"'
691 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm"
692 psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore"
693 '';
694
695 serviceConfig = {
696 User = pgsql.superUser;
697 Type = "oneshot";
698 RemainAfterExit = true;
699 };
700 };
701
702 systemd.services.discourse = {
703 wantedBy = [ "multi-user.target" ];
704 after = [
705 "redis-discourse.service"
706 "postgresql.service"
707 "discourse-postgresql.service"
708 ];
709 bindsTo = [
710 "redis-discourse.service"
711 ] ++ lib.optionals (cfg.database.host == null) [
712 "postgresql.service"
713 "discourse-postgresql.service"
714 ];
715 path = cfg.package.runtimeDeps ++ [
716 postgresqlPackage
717 pkgs.replace-secret
718 cfg.package.rake
719 ];
720 environment = cfg.package.runtimeEnv // {
721 UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout;
722 UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses;
723 MALLOC_ARENA_MAX = "2";
724 };
725
726 preStart =
727 let
728 discourseKeyValue = lib.generators.toKeyValue {
729 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " {
730 mkValueString = v: with builtins;
731 if isInt v then toString v
732 else if isString v then ''"${v}"''
733 else if true == v then "true"
734 else if false == v then "false"
735 else if null == v then ""
736 else if isFloat v then lib.strings.floatToString v
737 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
738 };
739 };
740
741 discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings);
742
743 mkSecretReplacement = file:
744 lib.optionalString (file != null) ''
745 replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf
746 '';
747
748 mkAdmin = ''
749 export ADMIN_EMAIL="${cfg.admin.email}"
750 export ADMIN_NAME="${cfg.admin.fullName}"
751 export ADMIN_USERNAME="${cfg.admin.username}"
752 ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})"
753 export ADMIN_PASSWORD
754 discourse-rake admin:create_noninteractively
755 '';
756
757 in ''
758 set -o errexit -o pipefail -o nounset -o errtrace
759 shopt -s inherit_errexit
760
761 umask u=rwx,g=rx,o=
762
763 rm -rf /var/lib/discourse/tmp/*
764
765 cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/
766 cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/
767 ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads
768 ln -sf /var/lib/discourse/backups /run/discourse/public/backups
769
770 (
771 umask u=rwx,g=,o=
772
773 ${utils.genJqSecretsReplacementSnippet
774 cfg.siteSettings
775 "/run/discourse/config/nixos_site_settings.json"
776 }
777 install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf
778 ${mkSecretReplacement cfg.database.passwordFile}
779 ${mkSecretReplacement cfg.mail.outgoing.passwordFile}
780 ${mkSecretReplacement cfg.redis.passwordFile}
781 ${mkSecretReplacement cfg.secretKeyBaseFile}
782 chmod 0400 /run/discourse/config/discourse.conf
783 )
784
785 discourse-rake db:migrate >>/var/log/discourse/db_migration.log
786 chmod -R u+w /var/lib/discourse/tmp/
787
788 ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin}
789
790 discourse-rake themes:update
791 discourse-rake uploads:regenerate_missing_optimized
792 '';
793
794 serviceConfig = {
795 Type = "simple";
796 User = "discourse";
797 Group = "discourse";
798 RuntimeDirectory = map (p: "discourse/" + p) [
799 "config"
800 "home"
801 "assets/javascripts/plugins"
802 "public"
803 "sockets"
804 ];
805 RuntimeDirectoryMode = "0750";
806 StateDirectory = map (p: "discourse/" + p) [
807 "uploads"
808 "backups"
809 "tmp"
810 ];
811 StateDirectoryMode = "0750";
812 LogsDirectory = "discourse";
813 TimeoutSec = "infinity";
814 Restart = "on-failure";
815 WorkingDirectory = "${cfg.package}/share/discourse";
816
817 RemoveIPC = true;
818 PrivateTmp = true;
819 NoNewPrivileges = true;
820 RestrictSUIDSGID = true;
821 ProtectSystem = "strict";
822 ProtectHome = "read-only";
823
824 ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb";
825 };
826 };
827
828 services.nginx = lib.mkIf cfg.nginx.enable {
829 enable = true;
830
831 recommendedTlsSettings = true;
832 recommendedOptimisation = true;
833 recommendedBrotliSettings = true;
834 recommendedGzipSettings = true;
835 recommendedProxySettings = true;
836
837 upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {};
838
839 appendHttpConfig = ''
840 # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
841 # levels means it is a 2 deep hierarchy cause we can have lots of files
842 # max_size limits the size of the cache
843 proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m;
844
845 # see: https://meta.discourse.org/t/x/74060
846 proxy_buffer_size 8k;
847 '';
848
849 virtualHosts.${cfg.hostname} = {
850 inherit (cfg) sslCertificate sslCertificateKey enableACME;
851 forceSSL = lib.mkDefault tlsEnabled;
852
853 root = "${cfg.package}/share/discourse/public";
854
855 locations =
856 let
857 proxy = { extraConfig ? "" }: {
858 proxyPass = "http://discourse";
859 extraConfig = extraConfig + ''
860 proxy_set_header X-Request-Start "t=''${msec}";
861 '';
862 };
863 cache = time: ''
864 expires ${time};
865 add_header Cache-Control public,immutable;
866 '';
867 cache_1y = cache "1y";
868 cache_1d = cache "1d";
869 in
870 {
871 "/".tryFiles = "$uri @discourse";
872 "@discourse" = proxy {};
873 "^~ /backups/".extraConfig = ''
874 internal;
875 '';
876 "/favicon.ico" = {
877 return = "204";
878 extraConfig = ''
879 access_log off;
880 log_not_found off;
881 '';
882 };
883 "~ ^/uploads/short-url/" = proxy {};
884 "~ ^/secure-media-uploads/" = proxy {};
885 "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + ''
886 add_header Access-Control-Allow-Origin *;
887 '';
888 "/srv/status" = proxy {
889 extraConfig = ''
890 access_log off;
891 log_not_found off;
892 '';
893 };
894 "~ ^/javascripts/".extraConfig = cache_1d;
895 "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + ''
896 # asset pipeline enables this
897 brotli_static on;
898 gzip_static on;
899 '';
900 "~ ^/plugins/".extraConfig = cache_1y;
901 "~ /images/emoji/".extraConfig = cache_1y;
902 "~ ^/uploads/" = proxy {
903 extraConfig = cache_1y + ''
904 proxy_set_header X-Sendfile-Type X-Accel-Redirect;
905 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
906
907 # custom CSS
908 location ~ /stylesheet-cache/ {
909 try_files $uri =404;
910 }
911 # this allows us to bypass rails
912 location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
913 try_files $uri =404;
914 }
915 # SVG needs an extra header attached
916 location ~* \.(svg)$ {
917 }
918 # thumbnails & optimized images
919 location ~ /_?optimized/ {
920 try_files $uri =404;
921 }
922 '';
923 };
924 "~ ^/admin/backups/" = proxy {
925 extraConfig = ''
926 proxy_set_header X-Sendfile-Type X-Accel-Redirect;
927 proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
928 '';
929 };
930 "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy {
931 extraConfig = ''
932 # if Set-Cookie is in the response nothing gets cached
933 # this is double bad cause we are not passing last modified in
934 proxy_ignore_headers "Set-Cookie";
935 proxy_hide_header "Set-Cookie";
936 proxy_hide_header "X-Discourse-Username";
937 proxy_hide_header "X-Runtime";
938
939 # note x-accel-redirect can not be used with proxy_cache
940 proxy_cache discourse;
941 proxy_cache_key "$scheme,$host,$request_uri";
942 proxy_cache_valid 200 301 302 7d;
943 '';
944 };
945 "/message-bus/" = proxy {
946 extraConfig = ''
947 proxy_http_version 1.1;
948 proxy_buffering off;
949 '';
950 };
951 "/downloads/".extraConfig = ''
952 internal;
953 alias ${cfg.package}/share/discourse/public/;
954 '';
955 };
956 };
957 };
958
959 systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable (
960 let
961 mail-receiver-environment = {
962 MAIL_DOMAIN = cfg.hostname;
963 DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
964 DISCOURSE_API_KEY = "@api-key@";
965 DISCOURSE_API_USERNAME = "system";
966 };
967 mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment;
968 in
969 {
970 before = [ "postfix.service" ];
971 after = [ "discourse.service" ];
972 wantedBy = [ "discourse.service" ];
973 partOf = [ "discourse.service" ];
974 path = [
975 cfg.package.rake
976 pkgs.jq
977 ];
978 preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) ''
979 set -o errexit -o pipefail -o nounset -o errtrace
980 shopt -s inherit_errexit
981
982 if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then
983 discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key
984 fi
985 '';
986 script =
987 let
988 apiKeyPath =
989 if cfg.mail.incoming.apiKeyFile == null then
990 "/var/lib/discourse-mail-receiver/api_key"
991 else
992 cfg.mail.incoming.apiKeyFile;
993 in ''
994 set -o errexit -o pipefail -o nounset -o errtrace
995 shopt -s inherit_errexit
996
997 api_key=$(<'${apiKeyPath}')
998 export api_key
999
1000 jq <${mail-receiver-json} \
1001 '.DISCOURSE_API_KEY = $ENV.api_key' \
1002 >'/run/discourse-mail-receiver/mail-receiver-environment.json'
1003 '';
1004
1005 serviceConfig = {
1006 Type = "oneshot";
1007 RemainAfterExit = true;
1008 RuntimeDirectory = "discourse-mail-receiver";
1009 RuntimeDirectoryMode = "0700";
1010 StateDirectory = "discourse-mail-receiver";
1011 User = "discourse";
1012 Group = "discourse";
1013 };
1014 });
1015
1016 services.discourse.siteSettings = {
1017 required = {
1018 notification_email = cfg.mail.notificationEmailAddress;
1019 contact_email = cfg.mail.contactEmailAddress;
1020 };
1021 security.force_https = tlsEnabled;
1022 email = {
1023 manual_polling_enabled = cfg.mail.incoming.enable;
1024 reply_by_email_enabled = cfg.mail.incoming.enable;
1025 reply_by_email_address = cfg.mail.incoming.replyEmailAddress;
1026 };
1027 };
1028
1029 services.postfix = lib.mkIf cfg.mail.incoming.enable {
1030 enable = true;
1031 sslCert = lib.optionalString (cfg.sslCertificate != null) cfg.sslCertificate;
1032 sslKey = lib.optionalString (cfg.sslCertificateKey != null) cfg.sslCertificateKey;
1033
1034 origin = cfg.hostname;
1035 relayDomains = [ cfg.hostname ];
1036 config = {
1037 smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy";
1038 append_dot_mydomain = lib.mkDefault false;
1039 compatibility_level = "2";
1040 smtputf8_enable = false;
1041 smtpd_banner = lib.mkDefault "ESMTP server";
1042 myhostname = lib.mkDefault cfg.hostname;
1043 mydestination = lib.mkDefault "localhost";
1044 };
1045 transport = ''
1046 ${cfg.hostname} discourse-mail-receiver:
1047 '';
1048 masterConfig = {
1049 "discourse-mail-receiver" = {
1050 type = "unix";
1051 privileged = true;
1052 chroot = false;
1053 command = "pipe";
1054 args = [
1055 "user=discourse"
1056 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail"
1057 "\${recipient}"
1058 ];
1059 };
1060 "discourse-policy" = {
1061 type = "unix";
1062 privileged = true;
1063 chroot = false;
1064 command = "spawn";
1065 args = [
1066 "user=discourse"
1067 "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection"
1068 ];
1069 };
1070 };
1071 };
1072
1073 users.users = {
1074 discourse = {
1075 group = "discourse";
1076 isSystemUser = true;
1077 };
1078 } // (lib.optionalAttrs cfg.nginx.enable {
1079 ${config.services.nginx.user}.extraGroups = [ "discourse" ];
1080 });
1081
1082 users.groups = {
1083 discourse = {};
1084 };
1085
1086 environment.systemPackages = [
1087 cfg.package.rake
1088 ];
1089 };
1090
1091 meta.doc = ./discourse.md;
1092 meta.maintainers = [ lib.maintainers.talyz ];
1093}