9 defaultUser = "outline";
10 cfg = config.services.outline;
11 inherit (lib) mkRemovedOptionModule;
15 (mkRemovedOptionModule [
19 ] "Database migration are run agains configurated database by outline directly")
21 # See here for a reference of all the options:
22 # https://github.com/outline/outline/blob/v0.67.0/.env.sample
23 # https://github.com/outline/outline/blob/v0.67.0/app.json
24 # https://github.com/outline/outline/blob/v0.67.0/server/env.ts
25 # https://github.com/outline/outline/blob/v0.67.0/shared/types.ts
26 # The order is kept the same here to make updating easier.
27 options.services.outline = {
28 enable = lib.mkEnableOption "outline";
30 package = lib.mkOption {
31 default = pkgs.outline;
32 defaultText = lib.literalExpression "pkgs.outline";
33 type = lib.types.package;
34 example = lib.literalExpression ''
35 pkgs.outline.overrideAttrs (super: {
36 # Ignore the domain part in emails that come from OIDC. This is might
37 # be helpful if you want multiple users with different email providers
38 # to still land in the same team. Note that this effectively makes
39 # Outline a single-team instance.
41 sed -i 's/const domain = parts\.length && parts\[1\];/const domain = "example.com";/g' plugins/oidc/server/auth/oidc.ts
45 description = "Outline package to use.";
50 default = defaultUser;
52 User under which the service should run. If this is the default value,
53 the user will be created, with the specified group as the primary
58 group = lib.mkOption {
60 default = defaultUser;
62 Group under which the service should run. If this is the default value,
63 the group will be created.
71 secretKeyFile = lib.mkOption {
73 default = "/var/lib/outline/secret_key";
75 File path that contains the application secret key. It must be 32
76 bytes long and hex-encoded. If the file does not exist, a new key will
77 be generated and saved here.
81 utilsSecretFile = lib.mkOption {
83 default = "/var/lib/outline/utils_secret";
85 File path that contains the utility secret key. If the file does not
86 exist, a new key will be generated and saved here.
90 databaseUrl = lib.mkOption {
94 URI to use for the main PostgreSQL database. If this needs to include
95 credentials that shouldn't be world-readable in the Nix store, set an
96 environment file on the systemd service and override the
97 `DATABASE_URL` entry. Pass the string
98 `local` to setup a database on the local server.
102 redisUrl = lib.mkOption {
103 type = lib.types.str;
106 Connection to a redis server. If this needs to include credentials
107 that shouldn't be world-readable in the Nix store, set an environment
108 file on the systemd service and override the
109 `REDIS_URL` entry. Pass the string
110 `local` to setup a local Redis database.
114 publicUrl = lib.mkOption {
115 type = lib.types.str;
116 default = "http://localhost:3000";
117 description = "The fully qualified, publicly accessible URL";
120 port = lib.mkOption {
121 type = lib.types.port;
123 description = "Listening port.";
126 storage = lib.mkOption {
128 To support uploading of images for avatars and document attachments an
129 s3-compatible storage can be provided. AWS S3 is recommended for
130 redundancy however if you want to keep all file storage local an
131 alternative such as [minio](https://github.com/minio/minio)
133 Local filesystem storage can also be used.
135 A more detailed guide on setting up storage is available
136 [here](https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7).
138 example = lib.literalExpression ''
141 secretKeyFile = "/somewhere";
142 uploadBucketUrl = "https://minio.example.com";
143 uploadBucketName = "outline";
144 region = "us-east-1";
147 type = lib.types.submodule {
149 storageType = lib.mkOption {
150 type = lib.types.enum [
154 description = "File storage type, it can be local or s3.";
157 localRootDir = lib.mkOption {
158 type = lib.types.str;
160 If `storageType` is `local`, this sets the parent directory
161 under which all attachments/images go.
163 default = "/var/lib/outline/data";
165 accessKey = lib.mkOption {
166 type = lib.types.str;
167 description = "S3 access key.";
169 secretKeyFile = lib.mkOption {
170 type = lib.types.path;
171 description = "File path that contains the S3 secret key.";
173 region = lib.mkOption {
174 type = lib.types.str;
175 default = "xx-xxxx-x";
176 description = "AWS S3 region name.";
178 uploadBucketUrl = lib.mkOption {
179 type = lib.types.str;
181 URL endpoint of an S3-compatible API where uploads should be
185 uploadBucketName = lib.mkOption {
186 type = lib.types.str;
187 description = "Name of the bucket where uploads should be stored.";
189 uploadMaxSize = lib.mkOption {
190 type = lib.types.int;
192 description = "Maxmium file size for uploads.";
194 forcePathStyle = lib.mkOption {
195 type = lib.types.bool;
197 description = "Force S3 path style.";
200 type = lib.types.str;
202 description = "ACL setting.";
212 slackAuthentication = lib.mkOption {
214 To configure Slack auth, you'll need to create an Application at
215 https://api.slack.com/apps
217 When configuring the Client ID, add a redirect URL under "OAuth & Permissions"
218 to `https://[publicUrl]/auth/slack.callback`.
221 type = lib.types.nullOr (
222 lib.types.submodule {
224 clientId = lib.mkOption {
225 type = lib.types.str;
226 description = "Authentication key.";
228 secretFile = lib.mkOption {
229 type = lib.types.str;
230 description = "File path containing the authentication secret.";
237 googleAuthentication = lib.mkOption {
239 To configure Google auth, you'll need to create an OAuth Client ID at
240 https://console.cloud.google.com/apis/credentials
242 When configuring the Client ID, add an Authorized redirect URI to
243 `https://[publicUrl]/auth/google.callback`.
246 type = lib.types.nullOr (
247 lib.types.submodule {
249 clientId = lib.mkOption {
250 type = lib.types.str;
251 description = "Authentication client identifier.";
253 clientSecretFile = lib.mkOption {
254 type = lib.types.str;
255 description = "File path containing the authentication secret.";
262 azureAuthentication = lib.mkOption {
264 To configure Microsoft/Azure auth, you'll need to create an OAuth
266 [the guide](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4)
267 for details on setting up your Azure App.
270 type = lib.types.nullOr (
271 lib.types.submodule {
273 clientId = lib.mkOption {
274 type = lib.types.str;
275 description = "Authentication client identifier.";
277 clientSecretFile = lib.mkOption {
278 type = lib.types.str;
279 description = "File path containing the authentication secret.";
281 resourceAppId = lib.mkOption {
282 type = lib.types.str;
283 description = "Authentication application resource ID.";
290 oidcAuthentication = lib.mkOption {
292 To configure generic OIDC auth, you'll need some kind of identity
293 provider. See the documentation for whichever IdP you use to fill out
294 all the fields. The redirect URL is
295 `https://[publicUrl]/auth/oidc.callback`.
298 type = lib.types.nullOr (
299 lib.types.submodule {
301 clientId = lib.mkOption {
302 type = lib.types.str;
303 description = "Authentication client identifier.";
305 clientSecretFile = lib.mkOption {
306 type = lib.types.str;
307 description = "File path containing the authentication secret.";
309 authUrl = lib.mkOption {
310 type = lib.types.str;
311 description = "OIDC authentication URL endpoint.";
313 tokenUrl = lib.mkOption {
314 type = lib.types.str;
315 description = "OIDC token URL endpoint.";
317 userinfoUrl = lib.mkOption {
318 type = lib.types.str;
319 description = "OIDC userinfo URL endpoint.";
321 usernameClaim = lib.mkOption {
322 type = lib.types.str;
324 Specify which claims to derive user information from. Supports any
325 valid JSON path with the JWT payload
327 default = "preferred_username";
329 displayName = lib.mkOption {
330 type = lib.types.str;
331 description = "Display name for OIDC authentication.";
334 scopes = lib.mkOption {
335 type = lib.types.listOf lib.types.str;
336 description = "OpenID authentication scopes.";
349 # Optional configuration
352 sslKeyFile = lib.mkOption {
353 type = lib.types.nullOr lib.types.str;
356 File path that contains the Base64-encoded private key for HTTPS
357 termination. This is only required if you do not use an external reverse
359 [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4).
362 sslCertFile = lib.mkOption {
363 type = lib.types.nullOr lib.types.str;
366 File path that contains the Base64-encoded certificate for HTTPS
367 termination. This is only required if you do not use an external reverse
369 [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4).
373 cdnUrl = lib.mkOption {
374 type = lib.types.str;
377 If using a Cloudfront/Cloudflare distribution or similar it can be set
378 using this option. This will cause paths to JavaScript files,
379 stylesheets and images to be updated to the hostname defined here. In
380 your CDN configuration the origin server should be set to public URL.
384 forceHttps = lib.mkOption {
385 type = lib.types.bool;
388 Auto-redirect to HTTPS in production. The default is
389 `true` but you may set this to `false`
390 if you can be sure that SSL is terminated at an external loadbalancer.
394 enableUpdateCheck = lib.mkOption {
395 type = lib.types.bool;
398 Have the installation check for updates by sending anonymized statistics
403 concurrency = lib.mkOption {
404 type = lib.types.int;
407 How many processes should be spawned. For a rough estimate, divide your
408 server's available memory by 512.
412 maximumImportSize = lib.mkOption {
413 type = lib.types.int;
416 The maximum size of document imports. Overriding this could be required
417 if you have especially large Word documents with embedded imagery.
421 debugOutput = lib.mkOption {
422 type = lib.types.nullOr (lib.types.enum [ "http" ]);
424 description = "Set this to `http` log HTTP requests.";
427 slackIntegration = lib.mkOption {
429 For a complete Slack integration with search and posting to channels
430 this configuration is also needed. See here for details:
431 https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
434 type = lib.types.nullOr (
435 lib.types.submodule {
437 verificationTokenFile = lib.mkOption {
438 type = lib.types.str;
439 description = "File path containing the verification token.";
441 appId = lib.mkOption {
442 type = lib.types.str;
443 description = "Application ID.";
445 messageActions = lib.mkOption {
446 type = lib.types.bool;
448 description = "Whether to enable message actions.";
455 googleAnalyticsId = lib.mkOption {
456 type = lib.types.nullOr lib.types.str;
459 Optionally enable Google Analytics to track page views in the knowledge
464 sentryDsn = lib.mkOption {
465 type = lib.types.nullOr lib.types.str;
468 Optionally enable [Sentry](https://sentry.io/) to
469 track errors and performance.
473 sentryTunnel = lib.mkOption {
474 type = lib.types.nullOr lib.types.str;
478 [Sentry proxy tunnel](https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
479 for bypassing ad blockers in the UI.
483 logo = lib.mkOption {
484 type = lib.types.nullOr lib.types.str;
487 Custom logo displayed on the authentication screen. This will be scaled
492 smtp = lib.mkOption {
494 To support sending outgoing transactional emails such as
495 "document updated" or "you've been invited" you'll need to provide
496 authentication for an SMTP server.
499 type = lib.types.nullOr (
500 lib.types.submodule {
502 host = lib.mkOption {
503 type = lib.types.str;
504 description = "Host name or IP address of the SMTP server.";
506 port = lib.mkOption {
507 type = lib.types.port;
508 description = "TCP port of the SMTP server.";
510 username = lib.mkOption {
511 type = lib.types.str;
512 description = "Username to authenticate with.";
514 passwordFile = lib.mkOption {
515 type = lib.types.str;
517 File path containing the password to authenticate with.
520 fromEmail = lib.mkOption {
521 type = lib.types.str;
522 description = "Sender email in outgoing mail.";
524 replyEmail = lib.mkOption {
525 type = lib.types.str;
526 description = "Reply address in outgoing mail.";
528 tlsCiphers = lib.mkOption {
529 type = lib.types.str;
531 description = "Override SMTP cipher configuration.";
533 secure = lib.mkOption {
534 type = lib.types.bool;
536 description = "Use a secure SMTP connection.";
543 defaultLanguage = lib.mkOption {
544 type = lib.types.enum [
567 The default interface language. See
568 [translate.getoutline.com](https://translate.getoutline.com/)
569 for a list of available language codes and their rough percentage
574 rateLimiter.enable = lib.mkEnableOption "rate limiter for the application web server";
575 rateLimiter.requests = lib.mkOption {
576 type = lib.types.int;
578 description = "Maximum number of requests in a throttling window.";
580 rateLimiter.durationWindow = lib.mkOption {
581 type = lib.types.int;
583 description = "Length of a throttling window.";
587 config = lib.mkIf cfg.enable {
588 users.users = lib.optionalAttrs (cfg.user == defaultUser) {
595 users.groups = lib.optionalAttrs (cfg.group == defaultUser) {
596 ${defaultUser} = { };
599 systemd.tmpfiles.rules = [
600 "f ${cfg.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -"
601 "f ${cfg.utilsSecretFile} 0600 ${cfg.user} ${cfg.group} -"
603 if (cfg.storage.storageType == "s3") then
604 "f ${cfg.storage.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -"
606 "d ${cfg.storage.localRootDir} 0700 ${cfg.user} ${cfg.group} - -"
610 services.postgresql = lib.mkIf (cfg.databaseUrl == "local") {
615 ensureDBOwnership = true;
618 ensureDatabases = [ "outline" ];
621 services.redis.servers.outline = lib.mkIf (cfg.redisUrl == "local") {
623 user = config.services.outline.user;
624 port = 0; # Disable the TCP listener
627 systemd.services.outline =
629 localRedisUrl = "redis+unix:///run/redis-outline/redis.sock";
630 localPostgresqlUrl = "postgres://localhost/outline?host=/run/postgresql";
633 description = "Outline wiki and knowledge base";
634 wantedBy = [ "multi-user.target" ];
636 [ "networking.target" ]
637 ++ lib.optional (cfg.databaseUrl == "local") "postgresql.service"
638 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
640 lib.optional (cfg.databaseUrl == "local") "postgresql.service"
641 ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service";
643 pkgs.openssl # Required by the preStart script
646 environment = lib.mkMerge [
648 NODE_ENV = "production";
650 REDIS_URL = if cfg.redisUrl == "local" then localRedisUrl else cfg.redisUrl;
652 PORT = builtins.toString cfg.port;
654 CDN_URL = cfg.cdnUrl;
655 FORCE_HTTPS = builtins.toString cfg.forceHttps;
656 ENABLE_UPDATES = builtins.toString cfg.enableUpdateCheck;
657 WEB_CONCURRENCY = builtins.toString cfg.concurrency;
658 MAXIMUM_IMPORT_SIZE = builtins.toString cfg.maximumImportSize;
659 DEBUG = cfg.debugOutput;
660 GOOGLE_ANALYTICS_ID = lib.optionalString (cfg.googleAnalyticsId != null) cfg.googleAnalyticsId;
661 SENTRY_DSN = lib.optionalString (cfg.sentryDsn != null) cfg.sentryDsn;
662 SENTRY_TUNNEL = lib.optionalString (cfg.sentryTunnel != null) cfg.sentryTunnel;
663 TEAM_LOGO = lib.optionalString (cfg.logo != null) cfg.logo;
664 DEFAULT_LANGUAGE = cfg.defaultLanguage;
666 RATE_LIMITER_ENABLED = builtins.toString cfg.rateLimiter.enable;
667 RATE_LIMITER_REQUESTS = builtins.toString cfg.rateLimiter.requests;
668 RATE_LIMITER_DURATION_WINDOW = builtins.toString cfg.rateLimiter.durationWindow;
670 FILE_STORAGE = cfg.storage.storageType;
671 FILE_STORAGE_UPLOAD_MAX_SIZE = builtins.toString cfg.storage.uploadMaxSize;
672 FILE_STORAGE_LOCAL_ROOT_DIR = cfg.storage.localRootDir;
675 (lib.mkIf (cfg.storage.storageType == "s3") {
676 AWS_ACCESS_KEY_ID = cfg.storage.accessKey;
677 AWS_REGION = cfg.storage.region;
678 AWS_S3_UPLOAD_BUCKET_URL = cfg.storage.uploadBucketUrl;
679 AWS_S3_UPLOAD_BUCKET_NAME = cfg.storage.uploadBucketName;
680 AWS_S3_FORCE_PATH_STYLE = builtins.toString cfg.storage.forcePathStyle;
681 AWS_S3_ACL = cfg.storage.acl;
684 (lib.mkIf (cfg.slackAuthentication != null) {
685 SLACK_CLIENT_ID = cfg.slackAuthentication.clientId;
688 (lib.mkIf (cfg.googleAuthentication != null) {
689 GOOGLE_CLIENT_ID = cfg.googleAuthentication.clientId;
692 (lib.mkIf (cfg.azureAuthentication != null) {
693 AZURE_CLIENT_ID = cfg.azureAuthentication.clientId;
694 AZURE_RESOURCE_APP_ID = cfg.azureAuthentication.resourceAppId;
697 (lib.mkIf (cfg.oidcAuthentication != null) {
698 OIDC_CLIENT_ID = cfg.oidcAuthentication.clientId;
699 OIDC_AUTH_URI = cfg.oidcAuthentication.authUrl;
700 OIDC_TOKEN_URI = cfg.oidcAuthentication.tokenUrl;
701 OIDC_USERINFO_URI = cfg.oidcAuthentication.userinfoUrl;
702 OIDC_USERNAME_CLAIM = cfg.oidcAuthentication.usernameClaim;
703 OIDC_DISPLAY_NAME = cfg.oidcAuthentication.displayName;
704 OIDC_SCOPES = lib.concatStringsSep " " cfg.oidcAuthentication.scopes;
707 (lib.mkIf (cfg.slackIntegration != null) {
708 SLACK_APP_ID = cfg.slackIntegration.appId;
709 SLACK_MESSAGE_ACTIONS = builtins.toString cfg.slackIntegration.messageActions;
712 (lib.mkIf (cfg.smtp != null) {
713 SMTP_HOST = cfg.smtp.host;
714 SMTP_PORT = builtins.toString cfg.smtp.port;
715 SMTP_USERNAME = cfg.smtp.username;
716 SMTP_FROM_EMAIL = cfg.smtp.fromEmail;
717 SMTP_REPLY_EMAIL = cfg.smtp.replyEmail;
718 SMTP_TLS_CIPHERS = cfg.smtp.tlsCiphers;
719 SMTP_SECURE = builtins.toString cfg.smtp.secure;
724 if [ ! -s ${lib.escapeShellArg cfg.secretKeyFile} ]; then
725 openssl rand -hex 32 > ${lib.escapeShellArg cfg.secretKeyFile}
727 if [ ! -s ${lib.escapeShellArg cfg.utilsSecretFile} ]; then
728 openssl rand -hex 32 > ${lib.escapeShellArg cfg.utilsSecretFile}
734 export SECRET_KEY="$(head -n1 ${lib.escapeShellArg cfg.secretKeyFile})"
735 export UTILS_SECRET="$(head -n1 ${lib.escapeShellArg cfg.utilsSecretFile})"
736 ${lib.optionalString (cfg.storage.storageType == "s3") ''
737 export AWS_SECRET_ACCESS_KEY="$(head -n1 ${lib.escapeShellArg cfg.storage.secretKeyFile})"
739 ${lib.optionalString (cfg.slackAuthentication != null) ''
740 export SLACK_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.slackAuthentication.secretFile})"
742 ${lib.optionalString (cfg.googleAuthentication != null) ''
743 export GOOGLE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.googleAuthentication.clientSecretFile})"
745 ${lib.optionalString (cfg.azureAuthentication != null) ''
746 export AZURE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.azureAuthentication.clientSecretFile})"
748 ${lib.optionalString (cfg.oidcAuthentication != null) ''
749 export OIDC_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.oidcAuthentication.clientSecretFile})"
751 ${lib.optionalString (cfg.sslKeyFile != null) ''
752 export SSL_KEY="$(head -n1 ${lib.escapeShellArg cfg.sslKeyFile})"
754 ${lib.optionalString (cfg.sslCertFile != null) ''
755 export SSL_CERT="$(head -n1 ${lib.escapeShellArg cfg.sslCertFile})"
757 ${lib.optionalString (cfg.slackIntegration != null) ''
758 export SLACK_VERIFICATION_TOKEN="$(head -n1 ${lib.escapeShellArg cfg.slackIntegration.verificationTokenFile})"
760 ${lib.optionalString (cfg.smtp != null) ''
761 export SMTP_PASSWORD="$(head -n1 ${lib.escapeShellArg cfg.smtp.passwordFile})"
765 if (cfg.databaseUrl == "local") then
767 export DATABASE_URL=${lib.escapeShellArg localPostgresqlUrl}
768 export PGSSLMODE=disable
772 export DATABASE_URL=${lib.escapeShellArg cfg.databaseUrl}
776 ${cfg.package}/bin/outline-server
783 ProtectSystem = "strict";
788 StateDirectory = "outline";
789 StateDirectoryMode = "0750";
790 RuntimeDirectory = "outline";
791 RuntimeDirectoryMode = "0750";
792 # This working directory is required to find stuff like the set of
794 WorkingDirectory = "${cfg.package}/share/outline";
795 # In case this directory is not in /var/lib/outline, it needs to be made writable explicitly
796 ReadWritePaths = lib.mkIf (cfg.storage.storageType == "local") [ cfg.storage.localRootDir ];