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 {
253 default = "changeme";
255 Initial password set for the `admin`
256 user. The password is not stored safely and should be changed
257 immediately in the admin panel.
262 type = attrsOf package;
265 Additional theme packages for Keycloak. Each theme is linked into
266 subdirectory with a corresponding attribute name.
268 Theme packages consist of several subdirectories which provide
269 different theme types: for example, `account`,
270 `login` etc. After adding a theme to this option you
271 can select it by its name in Keycloak administration console.
275 settings = mkOption {
276 type = lib.types.submodule {
277 freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ]));
280 http-host = mkOption {
283 example = "127.0.0.1";
285 On which address Keycloak should accept new connections.
289 http-port = mkOption {
294 On which port Keycloak should listen for new HTTP connections.
298 https-port = mkOption {
303 On which port Keycloak should listen for new HTTPS connections.
307 http-relative-path = mkOption {
311 apply = x: if !(hasPrefix "/") x then "/" + x else x;
313 The path relative to `/` for serving
317 In versions of Keycloak using Wildfly (<17),
318 this defaulted to `/auth`. If
319 upgrading from the Wildfly version of Keycloak,
320 i.e. a NixOS version before 22.05, you'll likely
321 want to set this to `/auth` to
322 keep compatibility with your clients.
324 See <https://www.keycloak.org/migration/migrating-to-quarkus>
325 for more information on migrating from Wildfly to Quarkus.
330 hostname = mkOption {
332 example = "keycloak.example.com";
334 The hostname part of the public URL used as base for
335 all frontend requests.
337 See <https://www.keycloak.org/server/hostname>
338 for more information about hostname configuration.
342 hostname-backchannel-dynamic = mkOption {
347 Enables dynamic resolving of backchannel URLs,
348 including hostname, scheme, port and context path.
350 See <https://www.keycloak.org/server/hostname>
351 for more information about hostname configuration.
356 type = enum [ "edge" "reencrypt" "passthrough" "none" ];
360 The proxy address forwarding mode if the server is
361 behind a reverse proxy.
364 Enables communication through HTTP between the
367 Requires communication through HTTPS between the
370 Enables communication through HTTP or HTTPS between
371 the proxy and Keycloak.
373 See <https://www.keycloak.org/server/reverseproxy> for more information.
379 example = literalExpression ''
381 hostname = "keycloak.example.com";
383 https-key-store-file = "/path/to/file";
384 https-key-store-password = { _secret = "/run/keys/store_password"; };
389 Configuration options corresponding to parameters set in
390 {file}`conf/keycloak.conf`.
392 Most available options are documented at <https://www.keycloak.org/server/all-config>.
394 Options containing secret data should be set to an attribute
395 set containing the attribute `_secret` - a
396 string pointing to a file containing the value the option
397 should be set to. See the example to get a better picture of
398 this: in the resulting
399 {file}`conf/keycloak.conf` file, the
400 `https-key-store-password` key will be set
401 to the contents of the
402 {file}`/run/keys/store_password` file.
409 # We only want to create a database if we're actually going to
411 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
412 createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
413 createLocalMySQL = databaseActuallyCreateLocally && elem cfg.database.type [ "mysql" "mariadb" ];
415 mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
416 ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
419 # Both theme and theme type directories need to be actual
420 # directories in one hierarchy to pass Keycloak checks.
421 themesBundle = pkgs.runCommand "keycloak-themes" { } ''
427 for typeDir in "$theme"/*; do
428 if [ -d "$typeDir" ]; then
429 type="$(basename "$typeDir")"
430 mkdir "$out/$name/$type"
431 for file in "$typeDir"/*; do
432 ln -sn "$file" "$out/$name/$type/$(basename "$file")"
439 for theme in ${keycloakBuild}/themes/*; do
440 if [ -d "$theme" ]; then
441 linkTheme "$theme" "$(basename "$theme")"
445 ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
448 keycloakConfig = lib.generators.toKeyValue {
449 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
451 if isInt v then toString v
452 else if isString v then v
453 else if true == v then "true"
454 else if false == v then "false"
455 else if isSecret v then hashString "sha256" v._secret
456 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
460 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
461 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{ } null])) cfg.settings;
462 confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
463 keycloakBuild = cfg.package.override {
465 plugins = cfg.package.enabledPlugins ++ cfg.plugins ++
466 (with cfg.package.plugins; [quarkus-systemd-notify quarkus-systemd-notify-deployment]);
473 assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
474 message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
477 assertion = createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
478 message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
481 assertion = cfg.settings.hostname != null || ! cfg.settings.hostname-strict or true;
482 message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
485 assertion = cfg.settings.hostname-url or null == null;
487 The option `services.keycloak.settings.hostname-url' has been removed.
488 Set `services.keycloak.settings.hostname' instead.
489 See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
493 assertion = cfg.settings.hostname-strict-backchannel or null == null;
495 The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
496 Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
497 See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
502 environment.systemPackages = [ keycloakBuild ];
504 services.keycloak.settings =
506 postgresParams = concatStringsSep "&" (
507 optionals cfg.database.useSSL [
509 ] ++ optionals (cfg.database.caCert != null) [
510 "sslrootcert=${cfg.database.caCert}"
514 mariadbParams = concatStringsSep "&" ([
515 "characterEncoding=UTF-8"
516 ] ++ optionals cfg.database.useSSL [
519 "verifyServerCertificate=true"
520 ] ++ optionals (cfg.database.caCert != null) [
521 "trustCertificateKeyStoreUrl=file:${mySqlCaKeystore}"
522 "trustCertificateKeyStorePassword=notsosecretpassword"
524 dbProps = if cfg.database.type == "postgresql" then postgresParams else mariadbParams;
528 db = if cfg.database.type == "postgresql" then "postgres" else cfg.database.type;
529 db-username = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
530 db-password._secret = cfg.database.passwordFile;
531 db-url-host = cfg.database.host;
532 db-url-port = toString cfg.database.port;
533 db-url-database = if databaseActuallyCreateLocally then "keycloak" else cfg.database.name;
534 db-url-properties = prefixUnlessEmpty "?" dbProps;
537 (mkIf (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
538 https-certificate-file = "/run/keycloak/ssl/ssl_cert";
539 https-certificate-key-file = "/run/keycloak/ssl/ssl_key";
543 systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
544 after = [ "postgresql.service" ];
545 before = [ "keycloak.service" ];
546 bindsTo = [ "postgresql.service" ];
547 path = [ config.services.postgresql.package ];
550 RemainAfterExit = true;
553 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
556 set -o errexit -o pipefail -o nounset -o errtrace
557 shopt -s inherit_errexit
559 create_role="$(mktemp)"
560 trap 'rm -f "$create_role"' EXIT
562 # Read the password from the credentials directory and
563 # escape any single quotes by adding additional single
564 # quotes after them, following the rules laid out here:
565 # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
566 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
567 db_password="''${db_password//\'/\'\'}"
569 echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
570 psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
571 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
575 systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
576 after = [ "mysql.service" ];
577 before = [ "keycloak.service" ];
578 bindsTo = [ "mysql.service" ];
579 path = [ config.services.mysql.package ];
582 RemainAfterExit = true;
583 User = config.services.mysql.user;
584 Group = config.services.mysql.group;
585 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
588 set -o errexit -o pipefail -o nounset -o errtrace
589 shopt -s inherit_errexit
591 # Read the password from the credentials directory and
592 # escape any single quotes by adding additional single
593 # quotes after them, following the rules laid out here:
594 # https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
595 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
596 db_password="''${db_password//\'/\'\'}"
598 ( echo "SET sql_mode = 'NO_BACKSLASH_ESCAPES';"
599 echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
600 echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
601 echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
606 systemd.services.keycloak =
609 if createLocalPostgreSQL then [
610 "keycloakPostgreSQLInit.service"
613 else if createLocalMySQL then [
614 "keycloakMySQLInit.service"
618 secretPaths = catAttrs "_secret" (collect isSecret cfg.settings);
619 mkSecretReplacement = file: ''
620 replace-secret ${hashString "sha256" file} $CREDENTIALS_DIRECTORY/${baseNameOf file} /run/keycloak/conf/keycloak.conf
622 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
625 after = databaseServices;
626 bindsTo = databaseServices;
627 wantedBy = [ "multi-user.target" ];
634 KC_HOME_DIR = "/run/keycloak";
635 KC_CONF_DIR = "/run/keycloak/conf";
639 map (p: "${baseNameOf p}:${p}") secretPaths
640 ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
641 "ssl_cert:${cfg.sslCertificate}"
642 "ssl_key:${cfg.sslCertificateKey}"
647 RuntimeDirectory = "keycloak";
648 RuntimeDirectoryMode = "0700";
649 AmbientCapabilities = "CAP_NET_BIND_SERVICE";
650 Type = "notify"; # Requires quarkus-systemd-notify plugin
651 NotifyAccess = "all";
654 set -o errexit -o pipefail -o nounset -o errtrace
655 shopt -s inherit_errexit
659 ln -s ${themesBundle} /run/keycloak/themes
660 ln -s ${keycloakBuild}/providers /run/keycloak/
662 install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
664 ${secretReplacements}
666 # Escape any backslashes in the db parameters, since
667 # they're otherwise unexpectedly read as escape
669 sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
671 '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
672 mkdir -p /run/keycloak/ssl
673 cp $CREDENTIALS_DIRECTORY/ssl_{cert,key} /run/keycloak/ssl/
675 export KEYCLOAK_ADMIN=admin
676 export KEYCLOAK_ADMIN_PASSWORD=${escapeShellArg cfg.initialAdminPassword}
677 kc.sh --verbose start --optimized
681 services.postgresql.enable = mkDefault createLocalPostgreSQL;
682 services.mysql.enable = mkDefault createLocalMySQL;
683 services.mysql.package =
685 dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
687 mkIf createLocalMySQL (mkDefault dbPkg);
690 meta.doc = ./keycloak.md;
691 meta.maintainers = [ maintainers.talyz ];