{ config, options, pkgs, lib, ... }:
let
cfg = config.services.keycloak;
opt = options.services.keycloak;
inherit (lib) types mkOption concatStringsSep mapAttrsToList
escapeShellArg recursiveUpdate optionalAttrs boolToString mkOrder
sort filterAttrs concatMapStringsSep concatStrings mkIf
optionalString optionals mkDefault literalExpression hasSuffix
foldl' isAttrs filter attrNames elem literalDocBook
maintainers;
inherit (builtins) match typeOf;
in
{
options.services.keycloak =
let
inherit (types) bool str nullOr attrsOf path enum anything
package port;
in
{
enable = mkOption {
type = bool;
default = false;
example = true;
description = ''
Whether to enable the Keycloak identity and access management
server.
'';
};
bindAddress = mkOption {
type = str;
default = "\${jboss.bind.address:0.0.0.0}";
example = "127.0.0.1";
description = ''
On which address Keycloak should accept new connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
httpPort = mkOption {
type = str;
default = "\${jboss.http.port:80}";
example = "8080";
description = ''
On which port Keycloak should listen for new HTTP connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
httpsPort = mkOption {
type = str;
default = "\${jboss.https.port:443}";
example = "8443";
description = ''
On which port Keycloak should listen for new HTTPS connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
frontendUrl = mkOption {
type = str;
apply = x:
if x == "" || hasSuffix "/" x then
x
else
x + "/";
example = "keycloak.example.com/auth";
description = ''
The public URL used as base for all frontend requests. Should
normally include a trailing /auth.
See the
Hostname section of the Keycloak server installation
manual for more information.
'';
};
forceBackendUrlToFrontendUrl = mkOption {
type = bool;
default = false;
example = true;
description = ''
Whether Keycloak should force all requests to go through the
frontend URL configured in . By default,
Keycloak allows backend requests to instead use its local
hostname or IP address and may also advertise it to clients
through its OpenID Connect Discovery endpoint.
See the
Hostname section of the Keycloak server installation
manual for more information.
'';
};
sslCertificate = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/ssl_cert";
description = ''
The path to a PEM formatted certificate to use for TLS/SSL
connections.
This should be a string, not a Nix path, since Nix paths are
copied into the world-readable Nix store.
'';
};
sslCertificateKey = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/ssl_key";
description = ''
The path to a PEM formatted private key to use for TLS/SSL
connections.
This should be a string, not a Nix path, since Nix paths are
copied into the world-readable Nix store.
'';
};
database = {
type = mkOption {
type = enum [ "mysql" "postgresql" ];
default = "postgresql";
example = "mysql";
description = ''
The type of database Keycloak should connect to.
'';
};
host = mkOption {
type = str;
default = "localhost";
description = ''
Hostname of the database to connect to.
'';
};
port =
let
dbPorts = {
postgresql = 5432;
mysql = 3306;
};
in
mkOption {
type = port;
default = dbPorts.${cfg.database.type};
defaultText = literalDocBook "default port of selected database";
description = ''
Port of the database to connect to.
'';
};
useSSL = mkOption {
type = bool;
default = cfg.database.host != "localhost";
defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
description = ''
Whether the database connection should be secured by SSL /
TLS.
'';
};
caCert = mkOption {
type = nullOr path;
default = null;
description = ''
The SSL / TLS CA certificate that verifies the identity of the
database server.
Required when PostgreSQL is used and SSL is turned on.
For MySQL, if left at null, the default
Java keystore is used, which should suffice if the server
certificate is issued by an official CA.
'';
};
createLocally = mkOption {
type = bool;
default = true;
description = ''
Whether a database should be automatically created on the
local host. Set this to false if you plan on provisioning a
local database yourself. This has no effect if
services.keycloak.database.host is customized.
'';
};
username = mkOption {
type = str;
default = "keycloak";
description = ''
Username to use when connecting to an external or manually
provisioned database; has no effect when a local database is
automatically provisioned.
To use this with a local database, set to
false and create the database and user
manually. The database should be called
keycloak.
'';
};
passwordFile = mkOption {
type = path;
example = "/run/keys/db_password";
description = ''
File containing the database password.
This should be a string, not a Nix path, since Nix paths are
copied into the world-readable Nix store.
'';
};
};
package = mkOption {
type = package;
default = pkgs.keycloak;
defaultText = literalExpression "pkgs.keycloak";
description = ''
Keycloak package to use.
'';
};
initialAdminPassword = mkOption {
type = str;
default = "changeme";
description = ''
Initial password set for the admin
user. The password is not stored safely and should be changed
immediately in the admin panel.
'';
};
themes = mkOption {
type = attrsOf package;
default = { };
description = ''
Additional theme packages for Keycloak. Each theme is linked into
subdirectory with a corresponding attribute name.
Theme packages consist of several subdirectories which provide
different theme types: for example, account,
login etc. After adding a theme to this option you
can select it by its name in Keycloak administration console.
'';
};
extraConfig = mkOption {
type = attrsOf anything;
default = { };
example = literalExpression ''
{
"subsystem=keycloak-server" = {
"spi=hostname" = {
"provider=default" = null;
"provider=fixed" = {
enabled = true;
properties.hostname = "keycloak.example.com";
};
default-provider = "fixed";
};
};
}
'';
description = ''
Additional Keycloak configuration options to set in
standalone.xml.
Options are expressed as a Nix attribute set which matches the
structure of the jboss-cli configuration. The configuration is
effectively overlayed on top of the default configuration
shipped with Keycloak. To remove existing nodes and undefine
attributes from the default configuration, set them to
null.
The example configuration does the equivalent of the following
script, which removes the hostname provider
default, adds the deprecated hostname
provider fixed and defines it the default:
/subsystem=keycloak-server/spi=hostname/provider=default:remove()
/subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
/subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
You can discover available options by using the jboss-cli.sh
program and by referring to the Keycloak
Server Installation and Configuration Guide.
'';
};
};
config =
let
# We only want to create a database if we're actually going to connect to it.
databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
createLocalMySQL = databaseActuallyCreateLocally && cfg.database.type == "mysql";
mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
'';
# Both theme and theme type directories need to be actual directories in one hierarchy to pass Keycloak checks.
themesBundle = pkgs.runCommand "keycloak-themes" { } ''
linkTheme() {
theme="$1"
name="$2"
mkdir "$out/$name"
for typeDir in "$theme"/*; do
if [ -d "$typeDir" ]; then
type="$(basename "$typeDir")"
mkdir "$out/$name/$type"
for file in "$typeDir"/*; do
ln -sn "$file" "$out/$name/$type/$(basename "$file")"
done
fi
done
}
mkdir -p "$out"
for theme in ${cfg.package}/themes/*; do
if [ -d "$theme" ]; then
linkTheme "$theme" "$(basename "$theme")"
fi
done
${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
'';
keycloakConfig' = foldl' recursiveUpdate
{
"interface=public".inet-address = cfg.bindAddress;
"socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort;
"subsystem=keycloak-server" = {
"spi=hostname"."provider=default" = {
enabled = true;
properties = {
inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl;
};
};
"theme=defaults".dir = toString themesBundle;
};
"subsystem=datasources"."data-source=KeycloakDS" = {
max-pool-size = "20";
user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
password = "@db-password@";
};
} [
(optionalAttrs (cfg.database.type == "postgresql") {
"subsystem=datasources" = {
"jdbc-driver=postgresql" = {
driver-module-name = "org.postgresql";
driver-name = "postgresql";
driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource";
};
"data-source=KeycloakDS" = {
connection-url = "jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
driver-name = "postgresql";
"connection-properties=ssl".value = boolToString cfg.database.useSSL;
} // (optionalAttrs (cfg.database.caCert != null) {
"connection-properties=sslrootcert".value = cfg.database.caCert;
"connection-properties=sslmode".value = "verify-ca";
});
};
})
(optionalAttrs (cfg.database.type == "mysql") {
"subsystem=datasources" = {
"jdbc-driver=mysql" = {
driver-module-name = "com.mysql";
driver-name = "mysql";
driver-class-name = "com.mysql.jdbc.Driver";
};
"data-source=KeycloakDS" = {
connection-url = "jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
driver-name = "mysql";
"connection-properties=useSSL".value = boolToString cfg.database.useSSL;
"connection-properties=requireSSL".value = boolToString cfg.database.useSSL;
"connection-properties=verifyServerCertificate".value = boolToString cfg.database.useSSL;
"connection-properties=characterEncoding".value = "UTF-8";
valid-connection-checker-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker";
validate-on-match = true;
exception-sorter-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter";
} // (optionalAttrs (cfg.database.caCert != null) {
"connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}";
"connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword";
});
};
})
(optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
"socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort;
"subsystem=elytron" = mkOrder 900 {
"key-store=httpsKS" = mkOrder 900 {
path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
credential-reference.clear-text = "notsosecretpassword";
type = "JKS";
};
"key-manager=httpsKM" = mkOrder 901 {
key-store = "httpsKS";
credential-reference.clear-text = "notsosecretpassword";
};
"server-ssl-context=httpsSSC" = mkOrder 902 {
key-manager = "httpsKM";
};
};
"subsystem=undertow" = mkOrder 901 {
"server=default-server"."https-listener=https".ssl-context = "httpsSSC";
};
})
cfg.extraConfig
];
/* Produces a JBoss CLI script that creates paths and sets
attributes matching those described by `attrs`. When the
script is run, the existing settings are effectively overlayed
by those from `attrs`. Existing attributes can be unset by
defining them `null`.
JBoss paths and attributes / maps are distinguished by their
name, where paths follow a `key=value` scheme.
Example:
mkJbossScript {
"subsystem=keycloak-server"."spi=hostname" = {
"provider=fixed" = null;
"provider=default" = {
enabled = true;
properties = {
inherit frontendUrl;
forceBackendUrlToFrontendUrl = false;
};
};
};
}
=> ''
if (outcome != success) of /:read-resource()
/:add()
end-if
if (outcome != success) of /subsystem=keycloak-server:read-resource()
/subsystem=keycloak-server:add()
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname:read-resource()
/subsystem=keycloak-server/spi=hostname:add()
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=default:read-resource()
/subsystem=keycloak-server/spi=hostname/provider=default:add(enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" })
end-if
if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
end-if
if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
end-if
if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=fixed:read-resource()
/subsystem=keycloak-server/spi=hostname/provider=fixed:remove()
end-if
''
*/
mkJbossScript = attrs:
let
/* From a JBoss path and an attrset, produces a JBoss CLI
snippet that writes the corresponding attributes starting
at `path`. Recurses down into subattrsets as necessary,
producing the variable name from its full path in the
attrset.
Example:
writeAttributes "/subsystem=keycloak-server/spi=hostname/provider=default" {
enabled = true;
properties = {
forceBackendUrlToFrontendUrl = false;
frontendUrl = "https://keycloak.example.com/auth";
};
}
=> ''
if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
end-if
if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
end-if
if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
end-if
''
*/
writeAttributes = path: set:
let
# JBoss expressions like `${var}` need to be prefixed
# with `expression` to evaluate.
prefixExpression = string:
let
matchResult = match ''"\$\{.*}"'' string;
in
if matchResult != null then
"expression " + string
else
string;
writeAttribute = attribute: value:
let
type = typeOf value;
in
if type == "set" then
let
names = attrNames value;
in
foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names
else if value == null then ''
if (outcome == success) of ${path}:read-attribute(name="${attribute}")
${path}:undefine-attribute(name="${attribute}")
end-if
''
else if elem type [ "string" "path" "bool" ] then
let
value' = if type == "bool" then boolToString value else ''"${value}"'';
in
''
if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}")
${path}:write-attribute(name=${attribute}, value=${value'})
end-if
''
else throw "Unsupported type '${type}' for path '${path}'!";
in
concatStrings
(mapAttrsToList
(attribute: value: (writeAttribute attribute value))
set);
/* Produces an argument list for the JBoss `add()` function,
which adds a JBoss path and takes as its arguments the
required subpaths and attributes.
Example:
makeArgList {
enabled = true;
properties = {
forceBackendUrlToFrontendUrl = false;
frontendUrl = "https://keycloak.example.com/auth";
};
}
=> ''
enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" }
''
*/
makeArgList = set:
let
makeArg = attribute: value:
let
type = typeOf value;
in
if type == "set" then
"${attribute} = { " + (makeArgList value) + " }"
else if elem type [ "string" "path" "bool" ] then
"${attribute} = ${if type == "bool" then boolToString value else ''"${value}"''}"
else if value == null then
""
else
throw "Unsupported type '${type}' for attribute '${attribute}'!";
in
concatStringsSep ", " (mapAttrsToList makeArg set);
/* Recurses into the `nodeValue` attrset. Only subattrsets that
are JBoss paths, i.e. follows the `key=value` format, are recursed
into - the rest are considered JBoss attributes / maps.
*/
recurse = nodePath: nodeValue:
let
nodeContent =
if isAttrs nodeValue && nodeValue._type or "" == "order" then
nodeValue.content
else
nodeValue;
isPath = name:
let
value = nodeContent.${name};
in
if (match ".*([=]).*" name) == [ "=" ] then
if isAttrs value || value == null then
true
else
throw "Parsing path '${concatStringsSep "." (nodePath ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
else
false;
jbossPath = "/" + concatStringsSep "/" nodePath;
children = if !isAttrs nodeContent then { } else nodeContent;
subPaths = filter isPath (attrNames children);
getPriority = name:
let
value = children.${name};
in
if value._type or "" == "order" then value.priority else 1000;
orderedSubPaths = sort (a: b: getPriority a < getPriority b) subPaths;
jbossAttrs = filterAttrs (name: _: !(isPath name)) children;
text =
if nodeContent != null then
''
if (outcome != success) of ${jbossPath}:read-resource()
${jbossPath}:add(${makeArgList jbossAttrs})
end-if
'' + writeAttributes jbossPath jbossAttrs
else
''
if (outcome == success) of ${jbossPath}:read-resource()
${jbossPath}:remove()
end-if
'';
in
text + concatMapStringsSep "\n" (name: recurse (nodePath ++ [ name ]) children.${name}) orderedSubPaths;
in
recurse [ ] attrs;
jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig');
keycloakConfig = pkgs.runCommand "keycloak-config"
{
nativeBuildInputs = [ cfg.package ];
}
''
export JBOSS_BASE_DIR="$(pwd -P)";
export JBOSS_MODULEPATH="${cfg.package}/modules";
export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
cp -r ${cfg.package}/standalone/configuration .
chmod -R u+rwX ./configuration
mkdir -p {deployments,ssl}
standalone.sh&
attempt=1
max_attempts=30
while ! jboss-cli.sh --connect ':read-attribute(name=server-state)'; do
if [[ "$attempt" == "$max_attempts" ]]; then
echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2
exit 1
fi
echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)"
sleep 1
(( attempt++ ))
done
jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
cp configuration/standalone.xml $out
'';
in
mkIf cfg.enable
{
assertions = [
{
assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
}
];
environment.systemPackages = [ cfg.package ];
systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
after = [ "postgresql.service" ];
before = [ "keycloak.service" ];
bindsTo = [ "postgresql.service" ];
path = [ config.services.postgresql.package ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
Group = "postgres";
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
create_role="$(mktemp)"
trap 'rm -f "$create_role"' ERR EXIT
echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$(<'${cfg.database.passwordFile}')' CREATEDB" > "$create_role"
psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
'';
};
systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
after = [ "mysql.service" ];
before = [ "keycloak.service" ];
bindsTo = [ "mysql.service" ];
path = [ config.services.mysql.package ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.mysql.user;
Group = config.services.mysql.group;
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
db_password="$(<'${cfg.database.passwordFile}')"
( echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
echo "CREATE DATABASE keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
) | mysql -N
'';
};
systemd.services.keycloak =
let
databaseServices =
if createLocalPostgreSQL then [
"keycloakPostgreSQLInit.service"
"postgresql.service"
]
else if createLocalMySQL then [
"keycloakMySQLInit.service"
"mysql.service"
]
else [ ];
in
{
after = databaseServices;
bindsTo = databaseServices;
wantedBy = [ "multi-user.target" ];
path = with pkgs; [
cfg.package
openssl
replace-secret
];
environment = {
JBOSS_LOG_DIR = "/var/log/keycloak";
JBOSS_BASE_DIR = "/run/keycloak";
JBOSS_MODULEPATH = "${cfg.package}/modules";
};
serviceConfig = {
LoadCredential = [
"db_password:${cfg.database.passwordFile}"
] ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
"ssl_cert:${cfg.sslCertificate}"
"ssl_key:${cfg.sslCertificateKey}"
];
User = "keycloak";
Group = "keycloak";
DynamicUser = true;
RuntimeDirectory = map (p: "keycloak/" + p) [
"configuration"
"deployments"
"data"
"ssl"
"log"
"tmp"
];
RuntimeDirectoryMode = 0700;
LogsDirectory = "keycloak";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
umask u=rwx,g=,o=
install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
replace-secret '@db-password@' "$CREDENTIALS_DIRECTORY/db_password" /run/keycloak/configuration/standalone.xml
export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
'' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
pushd /run/keycloak/ssl/
cat "$CREDENTIALS_DIRECTORY/ssl_cert" <(echo) \
"$CREDENTIALS_DIRECTORY/ssl_key" <(echo) \
/etc/ssl/certs/ca-certificates.crt \
> allcerts.pem
openssl pkcs12 -export -in "$CREDENTIALS_DIRECTORY/ssl_cert" -inkey "$CREDENTIALS_DIRECTORY/ssl_key" -chain \
-name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
-CAfile allcerts.pem -passout pass:notsosecretpassword
popd
'' + ''
${cfg.package}/bin/standalone.sh
'';
};
services.postgresql.enable = mkDefault createLocalPostgreSQL;
services.mysql.enable = mkDefault createLocalMySQL;
services.mysql.package = mkIf createLocalMySQL pkgs.mariadb;
};
meta.doc = ./keycloak.xml;
meta.maintainers = [ maintainers.talyz ];
}