1 { config, options, pkgs, lib, ... }:
4 cfg = config.services.keycloak;
5 opt = options.services.keycloak;
40 prefixUnlessEmpty = prefix: string: optionalString (string != "") "${prefix}${string}";
45 (mkRenamedOptionModule
46 [ "services" "keycloak" "bindAddress" ]
47 [ "services" "keycloak" "settings" "http-host" ])
48 (mkRenamedOptionModule
49 [ "services" "keycloak" "forceBackendUrlToFrontendUrl"]
50 [ "services" "keycloak" "settings" "hostname-strict-backchannel"])
51 (mkChangedOptionModule
52 [ "services" "keycloak" "httpPort" ]
53 [ "services" "keycloak" "settings" "http-port" ]
55 builtins.fromJSON config.services.keycloak.httpPort))
56 (mkChangedOptionModule
57 [ "services" "keycloak" "httpsPort" ]
58 [ "services" "keycloak" "settings" "https-port" ]
60 builtins.fromJSON config.services.keycloak.httpsPort))
61 (mkRemovedOptionModule
62 [ "services" "keycloak" "frontendUrl" ]
64 Set `services.keycloak.settings.hostname' and `services.keycloak.settings.http-relative-path' instead.
65 NOTE: You likely want to set 'http-relative-path' to '/auth' to keep compatibility with your clients.
66 See its description for more information.
68 (mkRemovedOptionModule
69 [ "services" "keycloak" "extraConfig" ]
70 "Use `services.keycloak.settings' instead.")
73 options.services.keycloak =
87 assertStringPath = optionName: value:
90 services.keycloak.${optionName}:
92 is a Nix path, but should be a string, since Nix
93 paths are copied into the world-readable Nix store.
103 Whether to enable the Keycloak identity and access management
108 sslCertificate = mkOption {
111 example = "/run/keys/ssl_cert";
112 apply = assertStringPath "sslCertificate";
114 The path to a PEM formatted certificate to use for TLS/SSL
119 sslCertificateKey = mkOption {
122 example = "/run/keys/ssl_key";
123 apply = assertStringPath "sslCertificateKey";
125 The path to a PEM formatted private key to use for TLS/SSL
130 plugins = lib.mkOption {
131 type = lib.types.listOf lib.types.path;
134 Keycloak plugin jar, ear files or derivations containing
135 them. Packaged plugins are available through
136 `pkgs.keycloak.plugins`.
142 type = enum [ "mysql" "mariadb" "postgresql" ];
143 default = "postgresql";
146 The type of database Keycloak should connect to.
152 default = "localhost";
154 Hostname of the database to connect to.
168 default = dbPorts.${cfg.database.type};
169 defaultText = literalMD "default port of selected database";
171 Port of the database to connect to.
177 default = cfg.database.host != "localhost";
178 defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
180 Whether the database connection should be secured by SSL /
189 The SSL / TLS CA certificate that verifies the identity of the
192 Required when PostgreSQL is used and SSL is turned on.
194 For MySQL, if left at `null`, the default
195 Java keystore is used, which should suffice if the server
196 certificate is issued by an official CA.
200 createLocally = mkOption {
204 Whether a database should be automatically created on the
205 local host. Set this to false if you plan on provisioning a
206 local database yourself. This has no effect if
207 services.keycloak.database.host is customized.
213 default = "keycloak";
215 Database name to use when connecting to an external or
216 manually provisioned database; has no effect when a local
217 database is automatically provisioned.
219 To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
220 `false` and create the database and user
225 username = mkOption {
227 default = "keycloak";
229 Username to use when connecting to an external or manually
230 provisioned database; has no effect when a local database is
231 automatically provisioned.
233 To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
234 `false` and create the database and user
239 passwordFile = mkOption {
241 example = "/run/keys/db_password";
242 apply = assertStringPath "passwordFile";
244 The path to a file containing the database password.
249 package = mkPackageOption pkgs "keycloak" { };
251 initialAdminPassword = mkOption {
255 Initial password set for the temporary `admin` user.
256 The password is not stored safely and should be changed
257 immediately in the admin panel.
259 See [Admin bootstrap and recovery](https://www.keycloak.org/server/bootstrap-admin-recovery) for details.
264 type = attrsOf package;
267 Additional theme packages for Keycloak. Each theme is linked into
268 subdirectory with a corresponding attribute name.
270 Theme packages consist of several subdirectories which provide
271 different theme types: for example, `account`,
272 `login` etc. After adding a theme to this option you
273 can select it by its name in Keycloak administration console.
277 settings = mkOption {
278 type = lib.types.submodule {
279 freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ]));
282 http-host = mkOption {
287 On which address Keycloak should accept new connections.
291 http-port = mkOption {
296 On which port Keycloak should listen for new HTTP connections.
300 https-port = mkOption {
305 On which port Keycloak should listen for new HTTPS connections.
309 http-relative-path = mkOption {
313 apply = x: if !(hasPrefix "/") x then "/" + x else x;
315 The path relative to `/` for serving
319 In versions of Keycloak using Wildfly (<17),
320 this defaulted to `/auth`. If
321 upgrading from the Wildfly version of Keycloak,
322 i.e. a NixOS version before 22.05, you'll likely
323 want to set this to `/auth` to
324 keep compatibility with your clients.
326 See <https://www.keycloak.org/migration/migrating-to-quarkus>
327 for more information on migrating from Wildfly to Quarkus.
332 hostname = mkOption {
334 example = "keycloak.example.com";
336 The hostname part of the public URL used as base for
337 all frontend requests.
339 See <https://www.keycloak.org/server/hostname>
340 for more information about hostname configuration.
344 hostname-backchannel-dynamic = mkOption {
349 Enables dynamic resolving of backchannel URLs,
350 including hostname, scheme, port and context path.
352 See <https://www.keycloak.org/server/hostname>
353 for more information about hostname configuration.
359 example = literalExpression ''
361 hostname = "keycloak.example.com";
362 https-key-store-file = "/path/to/file";
363 https-key-store-password = { _secret = "/run/keys/store_password"; };
368 Configuration options corresponding to parameters set in
369 {file}`conf/keycloak.conf`.
371 Most available options are documented at <https://www.keycloak.org/server/all-config>.
373 Options containing secret data should be set to an attribute
374 set containing the attribute `_secret` - a
375 string pointing to a file containing the value the option
376 should be set to. See the example to get a better picture of
377 this: in the resulting
378 {file}`conf/keycloak.conf` file, the
379 `https-key-store-password` key will be set
380 to the contents of the
381 {file}`/run/keys/store_password` file.
388 # We only want to create a database if we're actually going to
390 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
391 createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
392 createLocalMySQL = databaseActuallyCreateLocally && elem cfg.database.type [ "mysql" "mariadb" ];
394 mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
395 ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
398 # Both theme and theme type directories need to be actual
399 # directories in one hierarchy to pass Keycloak checks.
400 themesBundle = pkgs.runCommand "keycloak-themes" { } ''
406 for typeDir in "$theme"/*; do
407 if [ -d "$typeDir" ]; then
408 type="$(basename "$typeDir")"
409 mkdir "$out/$name/$type"
410 for file in "$typeDir"/*; do
411 ln -sn "$file" "$out/$name/$type/$(basename "$file")"
418 for theme in ${keycloakBuild}/themes/*; do
419 if [ -d "$theme" ]; then
420 linkTheme "$theme" "$(basename "$theme")"
424 ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
427 keycloakConfig = lib.generators.toKeyValue {
428 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
430 if isInt v then toString v
431 else if isString v then v
432 else if true == v then "true"
433 else if false == v then "false"
434 else if isSecret v then hashString "sha256" v._secret
435 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
439 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
440 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{ } null])) cfg.settings;
441 confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
442 keycloakBuild = cfg.package.override {
444 plugins = cfg.package.enabledPlugins ++ cfg.plugins ++
445 (with cfg.package.plugins; [quarkus-systemd-notify quarkus-systemd-notify-deployment]);
452 assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
453 message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
456 assertion = createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
457 message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
460 assertion = cfg.settings.hostname != null || ! cfg.settings.hostname-strict or true;
461 message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
464 assertion = cfg.settings.hostname-url or null == null;
466 The option `services.keycloak.settings.hostname-url' has been removed.
467 Set `services.keycloak.settings.hostname' instead.
468 See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
472 assertion = cfg.settings.hostname-strict-backchannel or null == null;
474 The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
475 Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
476 See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
480 assertion = cfg.settings.proxy or null == null;
482 The option `services.keycloak.settings.proxy' has been removed.
483 Set `services.keycloak.settings.proxy-headers` in combination
484 with other hostname options as needed instead.
485 See [Proxy option removed](https://www.keycloak.org/docs/latest/upgrading/index.html#proxy-option-removed)
486 for more information.
491 environment.systemPackages = [ keycloakBuild ];
493 services.keycloak.settings =
495 postgresParams = concatStringsSep "&" (
496 optionals cfg.database.useSSL [
498 ] ++ optionals (cfg.database.caCert != null) [
499 "sslrootcert=${cfg.database.caCert}"
503 mariadbParams = concatStringsSep "&" ([
504 "characterEncoding=UTF-8"
505 ] ++ optionals cfg.database.useSSL [
508 "verifyServerCertificate=true"
509 ] ++ optionals (cfg.database.caCert != null) [
510 "trustCertificateKeyStoreUrl=file:${mySqlCaKeystore}"
511 "trustCertificateKeyStorePassword=notsosecretpassword"
513 dbProps = if cfg.database.type == "postgresql" then postgresParams else mariadbParams;
517 db = if cfg.database.type == "postgresql" then "postgres" else cfg.database.type;
518 db-username = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
519 db-password._secret = cfg.database.passwordFile;
520 db-url-host = cfg.database.host;
521 db-url-port = toString cfg.database.port;
522 db-url-database = if databaseActuallyCreateLocally then "keycloak" else cfg.database.name;
523 db-url-properties = prefixUnlessEmpty "?" dbProps;
526 (mkIf (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
527 https-certificate-file = "/run/keycloak/ssl/ssl_cert";
528 https-certificate-key-file = "/run/keycloak/ssl/ssl_key";
532 systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
533 after = [ "postgresql.service" ];
534 before = [ "keycloak.service" ];
535 bindsTo = [ "postgresql.service" ];
536 path = [ config.services.postgresql.package ];
539 RemainAfterExit = true;
542 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
545 set -o errexit -o pipefail -o nounset -o errtrace
546 shopt -s inherit_errexit
548 create_role="$(mktemp)"
549 trap 'rm -f "$create_role"' EXIT
551 # Read the password from the credentials directory and
552 # escape any single quotes by adding additional single
553 # quotes after them, following the rules laid out here:
554 # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
555 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
556 db_password="''${db_password//\'/\'\'}"
558 echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
559 psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
560 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
564 systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
565 after = [ "mysql.service" ];
566 before = [ "keycloak.service" ];
567 bindsTo = [ "mysql.service" ];
568 path = [ config.services.mysql.package ];
571 RemainAfterExit = true;
572 User = config.services.mysql.user;
573 Group = config.services.mysql.group;
574 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
577 set -o errexit -o pipefail -o nounset -o errtrace
578 shopt -s inherit_errexit
580 # Read the password from the credentials directory and
581 # escape any single quotes by adding additional single
582 # quotes after them, following the rules laid out here:
583 # https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
584 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
585 db_password="''${db_password//\'/\'\'}"
587 ( echo "SET sql_mode = 'NO_BACKSLASH_ESCAPES';"
588 echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
589 echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
590 echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
595 systemd.services.keycloak =
598 if createLocalPostgreSQL then [
599 "keycloakPostgreSQLInit.service"
602 else if createLocalMySQL then [
603 "keycloakMySQLInit.service"
607 secretPaths = catAttrs "_secret" (collect isSecret cfg.settings);
608 mkSecretReplacement = file: ''
609 replace-secret ${hashString "sha256" file} $CREDENTIALS_DIRECTORY/${baseNameOf file} /run/keycloak/conf/keycloak.conf
611 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
614 after = databaseServices;
615 bindsTo = databaseServices;
616 wantedBy = [ "multi-user.target" ];
623 KC_HOME_DIR = "/run/keycloak";
624 KC_CONF_DIR = "/run/keycloak/conf";
625 } // lib.optionalAttrs (cfg.initialAdminPassword != null) {
626 KC_BOOTSTRAP_ADMIN_USERNAME = "admin";
627 KC_BOOTSTRAP_ADMIN_PASSWORD = cfg.initialAdminPassword;
631 map (p: "${baseNameOf p}:${p}") secretPaths
632 ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
633 "ssl_cert:${cfg.sslCertificate}"
634 "ssl_key:${cfg.sslCertificateKey}"
639 RuntimeDirectory = "keycloak";
640 RuntimeDirectoryMode = "0700";
641 AmbientCapabilities = "CAP_NET_BIND_SERVICE";
642 Type = "notify"; # Requires quarkus-systemd-notify plugin
643 NotifyAccess = "all";
646 set -o errexit -o pipefail -o nounset -o errtrace
647 shopt -s inherit_errexit
651 ln -s ${themesBundle} /run/keycloak/themes
652 ln -s ${keycloakBuild}/providers /run/keycloak/
653 ln -s ${keycloakBuild}/lib /run/keycloak/
655 install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
657 ${secretReplacements}
659 # Escape any backslashes in the db parameters, since
660 # they're otherwise unexpectedly read as escape
662 sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
664 '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
665 mkdir -p /run/keycloak/ssl
666 cp $CREDENTIALS_DIRECTORY/ssl_{cert,key} /run/keycloak/ssl/
668 kc.sh --verbose start --optimized
672 services.postgresql.enable = mkDefault createLocalPostgreSQL;
673 services.mysql.enable = mkDefault createLocalMySQL;
674 services.mysql.package =
676 dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
678 mkIf createLocalMySQL (mkDefault dbPkg);
681 meta.doc = ./keycloak.md;
682 meta.maintainers = [ maintainers.talyz ];