1 { config, lib, pkgs, ...}:
4 defaultUser = "outline";
5 cfg = config.services.outline;
6 inherit (lib) mkRemovedOptionModule;
10 (mkRemovedOptionModule [ "services" "outline" "sequelizeArguments" ] "Database migration are run agains configurated database by outline directly")
12 # See here for a reference of all the options:
13 # https://github.com/outline/outline/blob/v0.67.0/.env.sample
14 # https://github.com/outline/outline/blob/v0.67.0/app.json
15 # https://github.com/outline/outline/blob/v0.67.0/server/env.ts
16 # https://github.com/outline/outline/blob/v0.67.0/shared/types.ts
17 # The order is kept the same here to make updating easier.
18 options.services.outline = {
19 enable = lib.mkEnableOption "outline";
21 package = lib.mkOption {
22 default = pkgs.outline;
23 defaultText = lib.literalExpression "pkgs.outline";
24 type = lib.types.package;
25 example = lib.literalExpression ''
26 pkgs.outline.overrideAttrs (super: {
27 # Ignore the domain part in emails that come from OIDC. This is might
28 # be helpful if you want multiple users with different email providers
29 # to still land in the same team. Note that this effectively makes
30 # Outline a single-team instance.
32 sed -i 's/const domain = parts\.length && parts\[1\];/const domain = "example.com";/g' plugins/oidc/server/auth/oidc.ts
36 description = "Outline package to use.";
41 default = defaultUser;
43 User under which the service should run. If this is the default value,
44 the user will be created, with the specified group as the primary
49 group = lib.mkOption {
51 default = defaultUser;
53 Group under which the service should run. If this is the default value,
54 the group will be created.
62 secretKeyFile = lib.mkOption {
64 default = "/var/lib/outline/secret_key";
66 File path that contains the application secret key. It must be 32
67 bytes long and hex-encoded. If the file does not exist, a new key will
68 be generated and saved here.
72 utilsSecretFile = lib.mkOption {
74 default = "/var/lib/outline/utils_secret";
76 File path that contains the utility secret key. If the file does not
77 exist, a new key will be generated and saved here.
81 databaseUrl = lib.mkOption {
85 URI to use for the main PostgreSQL database. If this needs to include
86 credentials that shouldn't be world-readable in the Nix store, set an
87 environment file on the systemd service and override the
88 `DATABASE_URL` entry. Pass the string
89 `local` to setup a database on the local server.
93 redisUrl = lib.mkOption {
97 Connection to a redis server. If this needs to include credentials
98 that shouldn't be world-readable in the Nix store, set an environment
99 file on the systemd service and override the
100 `REDIS_URL` entry. Pass the string
101 `local` to setup a local Redis database.
105 publicUrl = lib.mkOption {
106 type = lib.types.str;
107 default = "http://localhost:3000";
108 description = "The fully qualified, publicly accessible URL";
111 port = lib.mkOption {
112 type = lib.types.port;
114 description = "Listening port.";
117 storage = lib.mkOption {
119 To support uploading of images for avatars and document attachments an
120 s3-compatible storage can be provided. AWS S3 is recommended for
121 redundancy however if you want to keep all file storage local an
122 alternative such as [minio](https://github.com/minio/minio)
124 Local filesystem storage can also be used.
126 A more detailed guide on setting up storage is available
127 [here](https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7).
129 example = lib.literalExpression ''
132 secretKeyFile = "/somewhere";
133 uploadBucketUrl = "https://minio.example.com";
134 uploadBucketName = "outline";
135 region = "us-east-1";
138 type = lib.types.submodule {
140 storageType = lib.mkOption {
141 type = lib.types.enum [ "local" "s3" ];
142 description = "File storage type, it can be local or s3.";
145 localRootDir = lib.mkOption {
146 type = lib.types.str;
148 If `storageType` is `local`, this sets the parent directory
149 under which all attachments/images go.
151 default = "/var/lib/outline/data";
153 accessKey = lib.mkOption {
154 type = lib.types.str;
155 description = "S3 access key.";
157 secretKeyFile = lib.mkOption {
158 type = lib.types.path;
159 description = "File path that contains the S3 secret key.";
161 region = lib.mkOption {
162 type = lib.types.str;
163 default = "xx-xxxx-x";
164 description = "AWS S3 region name.";
166 uploadBucketUrl = lib.mkOption {
167 type = lib.types.str;
169 URL endpoint of an S3-compatible API where uploads should be
173 uploadBucketName = lib.mkOption {
174 type = lib.types.str;
175 description = "Name of the bucket where uploads should be stored.";
177 uploadMaxSize = lib.mkOption {
178 type = lib.types.int;
180 description = "Maxmium file size for uploads.";
182 forcePathStyle = lib.mkOption {
183 type = lib.types.bool;
185 description = "Force S3 path style.";
188 type = lib.types.str;
190 description = "ACL setting.";
200 slackAuthentication = lib.mkOption {
202 To configure Slack auth, you'll need to create an Application at
203 https://api.slack.com/apps
205 When configuring the Client ID, add a redirect URL under "OAuth & Permissions"
206 to `https://[publicUrl]/auth/slack.callback`.
209 type = lib.types.nullOr (lib.types.submodule {
211 clientId = lib.mkOption {
212 type = lib.types.str;
213 description = "Authentication key.";
215 secretFile = lib.mkOption {
216 type = lib.types.str;
217 description = "File path containing the authentication secret.";
223 googleAuthentication = lib.mkOption {
225 To configure Google auth, you'll need to create an OAuth Client ID at
226 https://console.cloud.google.com/apis/credentials
228 When configuring the Client ID, add an Authorized redirect URI to
229 `https://[publicUrl]/auth/google.callback`.
232 type = lib.types.nullOr (lib.types.submodule {
234 clientId = lib.mkOption {
235 type = lib.types.str;
236 description = "Authentication client identifier.";
238 clientSecretFile = lib.mkOption {
239 type = lib.types.str;
240 description = "File path containing the authentication secret.";
246 azureAuthentication = lib.mkOption {
248 To configure Microsoft/Azure auth, you'll need to create an OAuth
250 [the guide](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4)
251 for details on setting up your Azure App.
254 type = lib.types.nullOr (lib.types.submodule {
256 clientId = lib.mkOption {
257 type = lib.types.str;
258 description = "Authentication client identifier.";
260 clientSecretFile = lib.mkOption {
261 type = lib.types.str;
262 description = "File path containing the authentication secret.";
264 resourceAppId = lib.mkOption {
265 type = lib.types.str;
266 description = "Authentication application resource ID.";
272 oidcAuthentication = lib.mkOption {
274 To configure generic OIDC auth, you'll need some kind of identity
275 provider. See the documentation for whichever IdP you use to fill out
276 all the fields. The redirect URL is
277 `https://[publicUrl]/auth/oidc.callback`.
280 type = lib.types.nullOr (lib.types.submodule {
282 clientId = lib.mkOption {
283 type = lib.types.str;
284 description = "Authentication client identifier.";
286 clientSecretFile = lib.mkOption {
287 type = lib.types.str;
288 description = "File path containing the authentication secret.";
290 authUrl = lib.mkOption {
291 type = lib.types.str;
292 description = "OIDC authentication URL endpoint.";
294 tokenUrl = lib.mkOption {
295 type = lib.types.str;
296 description = "OIDC token URL endpoint.";
298 userinfoUrl = lib.mkOption {
299 type = lib.types.str;
300 description = "OIDC userinfo URL endpoint.";
302 usernameClaim = lib.mkOption {
303 type = lib.types.str;
305 Specify which claims to derive user information from. Supports any
306 valid JSON path with the JWT payload
308 default = "preferred_username";
310 displayName = lib.mkOption {
311 type = lib.types.str;
312 description = "Display name for OIDC authentication.";
315 scopes = lib.mkOption {
316 type = lib.types.listOf lib.types.str;
317 description = "OpenID authentication scopes.";
318 default = [ "openid" "profile" "email" ];
325 # Optional configuration
328 sslKeyFile = lib.mkOption {
329 type = lib.types.nullOr lib.types.str;
332 File path that contains the Base64-encoded private key for HTTPS
333 termination. This is only required if you do not use an external reverse
335 [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4).
338 sslCertFile = lib.mkOption {
339 type = lib.types.nullOr lib.types.str;
342 File path that contains the Base64-encoded certificate for HTTPS
343 termination. This is only required if you do not use an external reverse
345 [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4).
349 cdnUrl = lib.mkOption {
350 type = lib.types.str;
353 If using a Cloudfront/Cloudflare distribution or similar it can be set
354 using this option. This will cause paths to JavaScript files,
355 stylesheets and images to be updated to the hostname defined here. In
356 your CDN configuration the origin server should be set to public URL.
360 forceHttps = lib.mkOption {
361 type = lib.types.bool;
364 Auto-redirect to HTTPS in production. The default is
365 `true` but you may set this to `false`
366 if you can be sure that SSL is terminated at an external loadbalancer.
370 enableUpdateCheck = lib.mkOption {
371 type = lib.types.bool;
374 Have the installation check for updates by sending anonymized statistics
379 concurrency = lib.mkOption {
380 type = lib.types.int;
383 How many processes should be spawned. For a rough estimate, divide your
384 server's available memory by 512.
388 maximumImportSize = lib.mkOption {
389 type = lib.types.int;
392 The maximum size of document imports. Overriding this could be required
393 if you have especially large Word documents with embedded imagery.
397 debugOutput = lib.mkOption {
398 type = lib.types.nullOr (lib.types.enum [ "http" ]);
400 description = "Set this to `http` log HTTP requests.";
403 slackIntegration = lib.mkOption {
405 For a complete Slack integration with search and posting to channels
406 this configuration is also needed. See here for details:
407 https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
410 type = lib.types.nullOr (lib.types.submodule {
412 verificationTokenFile = lib.mkOption {
413 type = lib.types.str;
414 description = "File path containing the verification token.";
416 appId = lib.mkOption {
417 type = lib.types.str;
418 description = "Application ID.";
420 messageActions = lib.mkOption {
421 type = lib.types.bool;
423 description = "Whether to enable message actions.";
429 googleAnalyticsId = lib.mkOption {
430 type = lib.types.nullOr lib.types.str;
433 Optionally enable Google Analytics to track page views in the knowledge
438 sentryDsn = lib.mkOption {
439 type = lib.types.nullOr lib.types.str;
442 Optionally enable [Sentry](https://sentry.io/) to
443 track errors and performance.
447 sentryTunnel = lib.mkOption {
448 type = lib.types.nullOr lib.types.str;
452 [Sentry proxy tunnel](https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
453 for bypassing ad blockers in the UI.
457 logo = lib.mkOption {
458 type = lib.types.nullOr lib.types.str;
461 Custom logo displayed on the authentication screen. This will be scaled
466 smtp = lib.mkOption {
468 To support sending outgoing transactional emails such as
469 "document updated" or "you've been invited" you'll need to provide
470 authentication for an SMTP server.
473 type = lib.types.nullOr (lib.types.submodule {
475 host = lib.mkOption {
476 type = lib.types.str;
477 description = "Host name or IP address of the SMTP server.";
479 port = lib.mkOption {
480 type = lib.types.port;
481 description = "TCP port of the SMTP server.";
483 username = lib.mkOption {
484 type = lib.types.str;
485 description = "Username to authenticate with.";
487 passwordFile = lib.mkOption {
488 type = lib.types.str;
490 File path containing the password to authenticate with.
493 fromEmail = lib.mkOption {
494 type = lib.types.str;
495 description = "Sender email in outgoing mail.";
497 replyEmail = lib.mkOption {
498 type = lib.types.str;
499 description = "Reply address in outgoing mail.";
501 tlsCiphers = lib.mkOption {
502 type = lib.types.str;
504 description = "Override SMTP cipher configuration.";
506 secure = lib.mkOption {
507 type = lib.types.bool;
509 description = "Use a secure SMTP connection.";
515 defaultLanguage = lib.mkOption {
516 type = lib.types.enum [
539 The default interface language. See
540 [translate.getoutline.com](https://translate.getoutline.com/)
541 for a list of available language codes and their rough percentage
546 rateLimiter.enable = lib.mkEnableOption "rate limiter for the application web server";
547 rateLimiter.requests = lib.mkOption {
548 type = lib.types.int;
550 description = "Maximum number of requests in a throttling window.";
552 rateLimiter.durationWindow = lib.mkOption {
553 type = lib.types.int;
555 description = "Length of a throttling window.";
559 config = lib.mkIf cfg.enable {
560 users.users = lib.optionalAttrs (cfg.user == defaultUser) {
567 users.groups = lib.optionalAttrs (cfg.group == defaultUser) {
568 ${defaultUser} = { };
571 systemd.tmpfiles.rules = [
572 "f ${cfg.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -"
573 "f ${cfg.utilsSecretFile} 0600 ${cfg.user} ${cfg.group} -"
574 (if (cfg.storage.storageType == "s3") then
575 "f ${cfg.storage.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -"
577 "d ${cfg.storage.localRootDir} 0700 ${cfg.user} ${cfg.group} - -")
580 services.postgresql = lib.mkIf (cfg.databaseUrl == "local") {
584 ensureDBOwnership = true;
586 ensureDatabases = [ "outline" ];
589 # Outline is unable to create the uuid-ossp extension when using postgresql 12, in later version this
590 # extension can be created without superuser permission. This services therefor this extension before
591 # outline starts and postgresql 12 is using on the host.
593 # Can be removed after postgresql 12 is dropped from nixos.
594 systemd.services.outline-postgresql =
596 pgsql = config.services.postgresql;
598 lib.mkIf (cfg.databaseUrl == "local" && pgsql.package == pkgs.postgresql_12) {
599 after = [ "postgresql.service" ];
600 bindsTo = [ "postgresql.service" ];
601 wantedBy = [ "outline.service" ];
602 partOf = [ "outline.service" ];
607 set -o errexit -o pipefail -o nounset -o errtrace
608 shopt -s inherit_errexit
610 psql outline -tAc 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
614 User = pgsql.superUser;
616 RemainAfterExit = true;
620 services.redis.servers.outline = lib.mkIf (cfg.redisUrl == "local") {
622 user = config.services.outline.user;
623 port = 0; # Disable the TCP listener
626 systemd.services.outline = let
627 localRedisUrl = "redis+unix:///run/redis-outline/redis.sock";
628 localPostgresqlUrl = "postgres://localhost/outline?host=/run/postgresql";
630 description = "Outline wiki and knowledge base";
631 wantedBy = [ "multi-user.target" ];
632 after = [ "networking.target" ]
633 ++ lib.optional (cfg.databaseUrl == "local") "postgresql.service"
634 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
635 requires = lib.optional (cfg.databaseUrl == "local") "postgresql.service"
636 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
638 pkgs.openssl # Required by the preStart script
642 environment = lib.mkMerge [
644 NODE_ENV = "production";
646 REDIS_URL = if cfg.redisUrl == "local" then localRedisUrl else cfg.redisUrl;
648 PORT = builtins.toString cfg.port;
650 CDN_URL = cfg.cdnUrl;
651 FORCE_HTTPS = builtins.toString cfg.forceHttps;
652 ENABLE_UPDATES = builtins.toString cfg.enableUpdateCheck;
653 WEB_CONCURRENCY = builtins.toString cfg.concurrency;
654 MAXIMUM_IMPORT_SIZE = builtins.toString cfg.maximumImportSize;
655 DEBUG = cfg.debugOutput;
656 GOOGLE_ANALYTICS_ID = lib.optionalString (cfg.googleAnalyticsId != null) cfg.googleAnalyticsId;
657 SENTRY_DSN = lib.optionalString (cfg.sentryDsn != null) cfg.sentryDsn;
658 SENTRY_TUNNEL = lib.optionalString (cfg.sentryTunnel != null) cfg.sentryTunnel;
659 TEAM_LOGO = lib.optionalString (cfg.logo != null) cfg.logo;
660 DEFAULT_LANGUAGE = cfg.defaultLanguage;
662 RATE_LIMITER_ENABLED = builtins.toString cfg.rateLimiter.enable;
663 RATE_LIMITER_REQUESTS = builtins.toString cfg.rateLimiter.requests;
664 RATE_LIMITER_DURATION_WINDOW = builtins.toString cfg.rateLimiter.durationWindow;
666 FILE_STORAGE = cfg.storage.storageType;
667 FILE_STORAGE_UPLOAD_MAX_SIZE = builtins.toString cfg.storage.uploadMaxSize;
668 FILE_STORAGE_LOCAL_ROOT_DIR = cfg.storage.localRootDir;
671 (lib.mkIf (cfg.storage.storageType == "s3") {
672 AWS_ACCESS_KEY_ID = cfg.storage.accessKey;
673 AWS_REGION = cfg.storage.region;
674 AWS_S3_UPLOAD_BUCKET_URL = cfg.storage.uploadBucketUrl;
675 AWS_S3_UPLOAD_BUCKET_NAME = cfg.storage.uploadBucketName;
676 AWS_S3_FORCE_PATH_STYLE = builtins.toString cfg.storage.forcePathStyle;
677 AWS_S3_ACL = cfg.storage.acl;
680 (lib.mkIf (cfg.slackAuthentication != null) {
681 SLACK_CLIENT_ID = cfg.slackAuthentication.clientId;
684 (lib.mkIf (cfg.googleAuthentication != null) {
685 GOOGLE_CLIENT_ID = cfg.googleAuthentication.clientId;
688 (lib.mkIf (cfg.azureAuthentication != null) {
689 AZURE_CLIENT_ID = cfg.azureAuthentication.clientId;
690 AZURE_RESOURCE_APP_ID = cfg.azureAuthentication.resourceAppId;
693 (lib.mkIf (cfg.oidcAuthentication != null) {
694 OIDC_CLIENT_ID = cfg.oidcAuthentication.clientId;
695 OIDC_AUTH_URI = cfg.oidcAuthentication.authUrl;
696 OIDC_TOKEN_URI = cfg.oidcAuthentication.tokenUrl;
697 OIDC_USERINFO_URI = cfg.oidcAuthentication.userinfoUrl;
698 OIDC_USERNAME_CLAIM = cfg.oidcAuthentication.usernameClaim;
699 OIDC_DISPLAY_NAME = cfg.oidcAuthentication.displayName;
700 OIDC_SCOPES = lib.concatStringsSep " " cfg.oidcAuthentication.scopes;
703 (lib.mkIf (cfg.slackIntegration != null) {
704 SLACK_APP_ID = cfg.slackIntegration.appId;
705 SLACK_MESSAGE_ACTIONS = builtins.toString cfg.slackIntegration.messageActions;
708 (lib.mkIf (cfg.smtp != null) {
709 SMTP_HOST = cfg.smtp.host;
710 SMTP_PORT = builtins.toString cfg.smtp.port;
711 SMTP_USERNAME = cfg.smtp.username;
712 SMTP_FROM_EMAIL = cfg.smtp.fromEmail;
713 SMTP_REPLY_EMAIL = cfg.smtp.replyEmail;
714 SMTP_TLS_CIPHERS = cfg.smtp.tlsCiphers;
715 SMTP_SECURE = builtins.toString cfg.smtp.secure;
720 if [ ! -s ${lib.escapeShellArg cfg.secretKeyFile} ]; then
721 openssl rand -hex 32 > ${lib.escapeShellArg cfg.secretKeyFile}
723 if [ ! -s ${lib.escapeShellArg cfg.utilsSecretFile} ]; then
724 openssl rand -hex 32 > ${lib.escapeShellArg cfg.utilsSecretFile}
730 export SECRET_KEY="$(head -n1 ${lib.escapeShellArg cfg.secretKeyFile})"
731 export UTILS_SECRET="$(head -n1 ${lib.escapeShellArg cfg.utilsSecretFile})"
732 ${lib.optionalString (cfg.storage.storageType == "s3") ''
733 export AWS_SECRET_ACCESS_KEY="$(head -n1 ${lib.escapeShellArg cfg.storage.secretKeyFile})"
735 ${lib.optionalString (cfg.slackAuthentication != null) ''
736 export SLACK_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.slackAuthentication.secretFile})"
738 ${lib.optionalString (cfg.googleAuthentication != null) ''
739 export GOOGLE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.googleAuthentication.clientSecretFile})"
741 ${lib.optionalString (cfg.azureAuthentication != null) ''
742 export AZURE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.azureAuthentication.clientSecretFile})"
744 ${lib.optionalString (cfg.oidcAuthentication != null) ''
745 export OIDC_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.oidcAuthentication.clientSecretFile})"
747 ${lib.optionalString (cfg.sslKeyFile != null) ''
748 export SSL_KEY="$(head -n1 ${lib.escapeShellArg cfg.sslKeyFile})"
750 ${lib.optionalString (cfg.sslCertFile != null) ''
751 export SSL_CERT="$(head -n1 ${lib.escapeShellArg cfg.sslCertFile})"
753 ${lib.optionalString (cfg.slackIntegration != null) ''
754 export SLACK_VERIFICATION_TOKEN="$(head -n1 ${lib.escapeShellArg cfg.slackIntegration.verificationTokenFile})"
756 ${lib.optionalString (cfg.smtp != null) ''
757 export SMTP_PASSWORD="$(head -n1 ${lib.escapeShellArg cfg.smtp.passwordFile})"
760 ${if (cfg.databaseUrl == "local") then ''
761 export DATABASE_URL=${lib.escapeShellArg localPostgresqlUrl}
762 export PGSSLMODE=disable
764 export DATABASE_URL=${lib.escapeShellArg cfg.databaseUrl}
767 ${cfg.package}/bin/outline-server
774 ProtectSystem = "strict";
779 StateDirectory = "outline";
780 StateDirectoryMode = "0750";
781 RuntimeDirectory = "outline";
782 RuntimeDirectoryMode = "0750";
783 # This working directory is required to find stuff like the set of
785 WorkingDirectory = "${cfg.package}/share/outline";
786 # In case this directory is not in /var/lib/outline, it needs to be made writable explicitly
787 ReadWritePaths = lib.mkIf (cfg.storage.storageType == "local") [ cfg.storage.localRootDir ];