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 services.redis.servers.outline = lib.mkIf (cfg.redisUrl == "local") {
591 user = config.services.outline.user;
592 port = 0; # Disable the TCP listener
595 systemd.services.outline = let
596 localRedisUrl = "redis+unix:///run/redis-outline/redis.sock";
597 localPostgresqlUrl = "postgres://localhost/outline?host=/run/postgresql";
599 description = "Outline wiki and knowledge base";
600 wantedBy = [ "multi-user.target" ];
601 after = [ "networking.target" ]
602 ++ lib.optional (cfg.databaseUrl == "local") "postgresql.service"
603 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
604 requires = lib.optional (cfg.databaseUrl == "local") "postgresql.service"
605 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
607 pkgs.openssl # Required by the preStart script
611 environment = lib.mkMerge [
613 NODE_ENV = "production";
615 REDIS_URL = if cfg.redisUrl == "local" then localRedisUrl else cfg.redisUrl;
617 PORT = builtins.toString cfg.port;
619 CDN_URL = cfg.cdnUrl;
620 FORCE_HTTPS = builtins.toString cfg.forceHttps;
621 ENABLE_UPDATES = builtins.toString cfg.enableUpdateCheck;
622 WEB_CONCURRENCY = builtins.toString cfg.concurrency;
623 MAXIMUM_IMPORT_SIZE = builtins.toString cfg.maximumImportSize;
624 DEBUG = cfg.debugOutput;
625 GOOGLE_ANALYTICS_ID = lib.optionalString (cfg.googleAnalyticsId != null) cfg.googleAnalyticsId;
626 SENTRY_DSN = lib.optionalString (cfg.sentryDsn != null) cfg.sentryDsn;
627 SENTRY_TUNNEL = lib.optionalString (cfg.sentryTunnel != null) cfg.sentryTunnel;
628 TEAM_LOGO = lib.optionalString (cfg.logo != null) cfg.logo;
629 DEFAULT_LANGUAGE = cfg.defaultLanguage;
631 RATE_LIMITER_ENABLED = builtins.toString cfg.rateLimiter.enable;
632 RATE_LIMITER_REQUESTS = builtins.toString cfg.rateLimiter.requests;
633 RATE_LIMITER_DURATION_WINDOW = builtins.toString cfg.rateLimiter.durationWindow;
635 FILE_STORAGE = cfg.storage.storageType;
636 FILE_STORAGE_UPLOAD_MAX_SIZE = builtins.toString cfg.storage.uploadMaxSize;
637 FILE_STORAGE_LOCAL_ROOT_DIR = cfg.storage.localRootDir;
640 (lib.mkIf (cfg.storage.storageType == "s3") {
641 AWS_ACCESS_KEY_ID = cfg.storage.accessKey;
642 AWS_REGION = cfg.storage.region;
643 AWS_S3_UPLOAD_BUCKET_URL = cfg.storage.uploadBucketUrl;
644 AWS_S3_UPLOAD_BUCKET_NAME = cfg.storage.uploadBucketName;
645 AWS_S3_FORCE_PATH_STYLE = builtins.toString cfg.storage.forcePathStyle;
646 AWS_S3_ACL = cfg.storage.acl;
649 (lib.mkIf (cfg.slackAuthentication != null) {
650 SLACK_CLIENT_ID = cfg.slackAuthentication.clientId;
653 (lib.mkIf (cfg.googleAuthentication != null) {
654 GOOGLE_CLIENT_ID = cfg.googleAuthentication.clientId;
657 (lib.mkIf (cfg.azureAuthentication != null) {
658 AZURE_CLIENT_ID = cfg.azureAuthentication.clientId;
659 AZURE_RESOURCE_APP_ID = cfg.azureAuthentication.resourceAppId;
662 (lib.mkIf (cfg.oidcAuthentication != null) {
663 OIDC_CLIENT_ID = cfg.oidcAuthentication.clientId;
664 OIDC_AUTH_URI = cfg.oidcAuthentication.authUrl;
665 OIDC_TOKEN_URI = cfg.oidcAuthentication.tokenUrl;
666 OIDC_USERINFO_URI = cfg.oidcAuthentication.userinfoUrl;
667 OIDC_USERNAME_CLAIM = cfg.oidcAuthentication.usernameClaim;
668 OIDC_DISPLAY_NAME = cfg.oidcAuthentication.displayName;
669 OIDC_SCOPES = lib.concatStringsSep " " cfg.oidcAuthentication.scopes;
672 (lib.mkIf (cfg.slackIntegration != null) {
673 SLACK_APP_ID = cfg.slackIntegration.appId;
674 SLACK_MESSAGE_ACTIONS = builtins.toString cfg.slackIntegration.messageActions;
677 (lib.mkIf (cfg.smtp != null) {
678 SMTP_HOST = cfg.smtp.host;
679 SMTP_PORT = builtins.toString cfg.smtp.port;
680 SMTP_USERNAME = cfg.smtp.username;
681 SMTP_FROM_EMAIL = cfg.smtp.fromEmail;
682 SMTP_REPLY_EMAIL = cfg.smtp.replyEmail;
683 SMTP_TLS_CIPHERS = cfg.smtp.tlsCiphers;
684 SMTP_SECURE = builtins.toString cfg.smtp.secure;
689 if [ ! -s ${lib.escapeShellArg cfg.secretKeyFile} ]; then
690 openssl rand -hex 32 > ${lib.escapeShellArg cfg.secretKeyFile}
692 if [ ! -s ${lib.escapeShellArg cfg.utilsSecretFile} ]; then
693 openssl rand -hex 32 > ${lib.escapeShellArg cfg.utilsSecretFile}
699 export SECRET_KEY="$(head -n1 ${lib.escapeShellArg cfg.secretKeyFile})"
700 export UTILS_SECRET="$(head -n1 ${lib.escapeShellArg cfg.utilsSecretFile})"
701 ${lib.optionalString (cfg.storage.storageType == "s3") ''
702 export AWS_SECRET_ACCESS_KEY="$(head -n1 ${lib.escapeShellArg cfg.storage.secretKeyFile})"
704 ${lib.optionalString (cfg.slackAuthentication != null) ''
705 export SLACK_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.slackAuthentication.secretFile})"
707 ${lib.optionalString (cfg.googleAuthentication != null) ''
708 export GOOGLE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.googleAuthentication.clientSecretFile})"
710 ${lib.optionalString (cfg.azureAuthentication != null) ''
711 export AZURE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.azureAuthentication.clientSecretFile})"
713 ${lib.optionalString (cfg.oidcAuthentication != null) ''
714 export OIDC_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.oidcAuthentication.clientSecretFile})"
716 ${lib.optionalString (cfg.sslKeyFile != null) ''
717 export SSL_KEY="$(head -n1 ${lib.escapeShellArg cfg.sslKeyFile})"
719 ${lib.optionalString (cfg.sslCertFile != null) ''
720 export SSL_CERT="$(head -n1 ${lib.escapeShellArg cfg.sslCertFile})"
722 ${lib.optionalString (cfg.slackIntegration != null) ''
723 export SLACK_VERIFICATION_TOKEN="$(head -n1 ${lib.escapeShellArg cfg.slackIntegration.verificationTokenFile})"
725 ${lib.optionalString (cfg.smtp != null) ''
726 export SMTP_PASSWORD="$(head -n1 ${lib.escapeShellArg cfg.smtp.passwordFile})"
729 ${if (cfg.databaseUrl == "local") then ''
730 export DATABASE_URL=${lib.escapeShellArg localPostgresqlUrl}
731 export PGSSLMODE=disable
733 export DATABASE_URL=${lib.escapeShellArg cfg.databaseUrl}
736 ${cfg.package}/bin/outline-server
743 ProtectSystem = "strict";
748 StateDirectory = "outline";
749 StateDirectoryMode = "0750";
750 RuntimeDirectory = "outline";
751 RuntimeDirectoryMode = "0750";
752 # This working directory is required to find stuff like the set of
754 WorkingDirectory = "${cfg.package}/share/outline";
755 # In case this directory is not in /var/lib/outline, it needs to be made writable explicitly
756 ReadWritePaths = lib.mkIf (cfg.storage.storageType == "local") [ cfg.storage.localRootDir ];