keycloak-declarative-realms¶
Modules
A NixOS module that wraps the upstream services.keycloak and provisions
realms, OIDC clients, and users through the admin REST API on first boot — so a
whole Keycloak identity setup lives in your Nix config instead of being clicked
into the admin UI by hand.
The problem¶
Keycloak has no first-class declarative-config surface for realms/clients/users
that fits cleanly into a NixOS deployment. You bring the server up, then someone
logs into the admin console and creates everything manually — which is exactly
the state you don't want tracked outside of your configuration. This module
closes that gap with a keycloak-configure oneshot that logs in as the admin,
waits for the API to be ready, and creates each object idempotently.
The two traps this solves¶
1. The admin password must land in an EnvironmentFile before the service, and a preStart hook is too late¶
Keycloak reads its initial admin credentials from KEYCLOAK_ADMIN /
KEYCLOAK_ADMIN_PASSWORD environment variables. To keep the password out of the
Nix store and off the unit, we feed it via EnvironmentFile=/run/keycloak/admin-env.
The non-obvious part: systemd resolves EnvironmentFile before it runs
ExecStartPre. So you cannot write that file from a preStart script on
keycloak.service — by the time the script runs, systemd has already tried (and
failed) to read the file. The fix is a separate oneshot,
keycloak-admin-setup.service, ordered before = [ "keycloak.service" ], that
reads initialAdminPasswordFile and writes a 0600 keycloak:keycloak env file.
keycloak.service requires + after it. The password never enters the store
or the unit text.
That oneshot runs as User = "keycloak", not root. RuntimeDirectory =
"keycloak" creates /run/keycloak already owned by that user (no manual
mkdir/chown needed), and the password itself is read through
LoadCredential = "admin-password:${initialAdminPasswordFile}" — systemd's PID1
(still root at that point) reads the source file on the unit's behalf and hands
it over via $CREDENTIALS_DIRECTORY, so the fix works regardless of whether
initialAdminPasswordFile is readable by anyone but root. Nothing in this unit
needs elevated privilege.
2. The configure script must talk to http://localhost — not the public hostname¶
The server runs plain HTTP (http-enabled = true, hostname-strict{,-https} =
false); TLS is a fronting reverse-proxy's job. But Keycloak's built-in master
realm defaults to sslRequired = external, which rejects non-HTTPS requests
coming from any non-local address. The reconciliation script therefore hits
http://localhost:<port> explicitly — a loopback address is exempt from the SSL
requirement, so admin login over plain HTTP works only from the box itself.
Usage¶
Import the module and enable it:
{
imports = [ ./modules/keycloak-declarative-realms ];
services.keycloak-declarative = {
enable = true;
hostname = "auth.example.com"; # canonical URL (proxy terminates TLS)
port = 8080; # plain-HTTP listen port
bindAddress = "127.0.0.1"; # only the local proxy needs to reach it
initialAdminPasswordFile = "/run/secrets/keycloak-admin-password";
# Local PostgreSQL is provisioned by default over the unix socket (peer auth).
# database.passwordFile is required regardless — see Caveats.
database.passwordFile = "/run/secrets/keycloak-db-password";
realms.myorg = {
displayName = "My Organization";
clients.my-app = {
redirectUris = [ "https://app.example.com/oauth/callback" ];
secretFile = "/run/secrets/oauth-client-secret"; # else a random one is generated
};
users.alice = {
email = "alice@example.com";
firstName = "Alice";
lastName = "Example";
passwordFile = "/run/secrets/alice-password";
};
};
};
}
All secrets are passed as paths to files (*File options) so nothing
sensitive is interpolated into the Nix store. Point them at whatever secret
manager you use (agenix, sops-nix, /run/secrets, …).
Key options¶
| Option | Default | Purpose |
|---|---|---|
hostname |
localhost |
Canonical hostname / URL for the server |
port |
8080 |
Plain-HTTP listen port |
bindAddress |
127.0.0.1 |
Interface to bind (0.0.0.0 for all) |
openFirewall |
false |
Open port in the firewall (only with TLS in front) |
adminUser |
admin |
Bootstrap admin username |
initialAdminPasswordFile |
(required) | File holding the initial admin password |
database.type |
postgresql |
Enum of one (see Caveats) |
database.createLocally |
true |
Provision a local PostgreSQL DB + role |
database.useSocket |
true |
Unix-socket peer auth vs. TCP scram-sha-256 |
database.host |
/run/postgresql |
Socket dir, or a host/IP for TCP (asserted to be the socket dir when useSocket) |
database.port |
5432 |
Ignored on the socket path |
database.name |
keycloak |
Database name |
database.user |
keycloak |
Database role |
database.passwordFile |
null |
DB password file (always required — see Caveats) |
realms.<name>.displayName |
(required) | Human-readable realm name |
realms.<name>.clients.<id> |
{} |
OIDC clients (redirectUris, secretFile) |
realms.<name>.users.<name> |
{} |
Users (email, firstName, lastName, passwordFile) |
configurationAttempts |
60 |
Readiness-poll attempts before giving up |
configurationRetryDelay |
2 |
Seconds between poll attempts |
Caveats¶
-
database.passwordFileis always required, even on the default socket/peer-auth path where Keycloak connects as its own OS user and never actually uses a network password. The upstreamservices.keycloakmodule insists on one; the assertion here exists purely "for NixOS keycloak module compatibility." -
Reconciliation is create-or-skip for clients and users. Only realms are updated in place, and only their
displayName(via GET-modify-PUT so other realm settings are preserved). Changing a client'sredirectUris/secretFileor a user's password after first creation will not be picked up by a redeploy — edit it in the admin UI, or delete the object to force recreation on the next run. -
A client with no
secretFilegets anopenssl randsecret echoed into thekeycloak-configurejournal (once, at creation time). PrefersecretFilefor any client whose secret is referenced elsewhere. -
Provisioned users get permanent passwords (
temporary: false) and verified emails, so they log in immediately with no reset prompt. Adjust if you want a forced first-login reset. -
The
keycloak-configureoneshot runs asnobody:nogroupyetcatsinitialAdminPasswordFile(and everypasswordFile/secretFile) directly. If your secret manager writes those files0400 root:root(the agenix/sops default), the unprivileged reconciler cannot read them and configuration fails. Fix this by granting group read to a group the reconciler is in — e.g. give the secret files mode0040and a shared owning group, then run the reconciler under a dedicated user in that group (override the unit'sUser/Group). Do not make the Keycloak admin password or client/user secrets world-readable: on a multi-user box any local account could then read them and take over every realm. World-readable is a last resort only, and never for the admin password. -
Plain HTTP — front it with TLS. The listener (admin console + admin-cli password-grant token endpoint) is cleartext HTTP. Keep
bindAddress = "127.0.0.1"and put a TLS-terminating reverse proxy in front; the firewall port stays closed unless you setopenFirewall = true. Binding to a non-loopback address and opening the port without a TLS proxy exposes admin credentials to LAN sniffing/brute-force — a full-realm compromise vector. -
PostgreSQL only.
database.typeis currently an enum of one. -
Legacy bootstrap env vars. Admin bootstrap here uses
KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD. Keycloak 26.0 renamed those toKC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORDand kept the old names as deprecated aliases — nixpkgs' ownservices.keycloakalready sets the new ones (for itsinitialAdminPasswordpath, which this module does not use). If your pinned Keycloak ever drops the deprecated aliases, change theenvironmentkey and the env-file key written bykeycloak-admin-setup. -
Port below 1024 needs a loopback bind. An assertion rejects
port < 1024unlessbindAddress = "127.0.0.1"("Ports below 1024 require root privileges. Use a higher port or bind to localhost only").
How it works (internals worth knowing)¶
- The
keycloak-configureoneshot runs asnobody:nogroupwith a 5-minuteTimeoutStartSec, afterkeycloak.serviceandpostgresql.service. - It polls
/realms/masteruntil the API answers valid JSON, then obtains an admin token via theadmin-cliclient (password grant). - It calls
refresh_tokenbefore each client and user to dodge token expiry on large configs. - Client JSON is assembled with
lib.escapeShellArgand the secret merged in viajq --arg(never string interpolation) to avoid shell-quoting hazards with arbitrary secret contents.
Source¶
modules/keycloak-declarative-realms/default.nix
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.keycloak-declarative;
in
{
options.services.keycloak-declarative = {
enable = mkEnableOption "declarative Keycloak configuration with automated realm and client setup";
hostname = mkOption {
type = types.str;
default = "localhost";
description = ''
Hostname where Keycloak will be accessible.
This is used for both binding and as the canonical URL for the service.
'';
example = "auth.example.com";
};
port = mkOption {
type = types.port;
default = 8080;
description = ''
Port number where Keycloak will listen for HTTP connections.
Note: This module currently only supports HTTP mode.
'';
};
bindAddress = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
IP address that Keycloak should bind to.
Use "0.0.0.0" to listen on all interfaces.
WARNING: this module serves Keycloak over plain HTTP (including the
admin console and the admin-cli password-grant token endpoint). Binding
to a non-loopback address exposes those credentials in cleartext. Only
bind beyond 127.0.0.1 behind a TLS-terminating reverse proxy — never
reach the port directly from another host.
'';
example = "0.0.0.0";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Whether to open `port` in the firewall.
Left closed by default: with the safe `bindAddress = "127.0.0.1"` only a
local reverse proxy needs the port, so no hole is required. Enable this
only when you deliberately bind to a non-loopback address AND front the
service with TLS — the listener is plain HTTP.
'';
};
database = {
type = mkOption {
type = types.enum [ "postgresql" ];
default = "postgresql";
description = "Database type to use";
};
host = mkOption {
type = types.str;
default = "/run/postgresql";
description = ''
Database host address.
- Use "/run/postgresql" for Unix socket connections (recommended for local databases)
- Use hostname or IP address for TCP connections to remote databases
'';
example = "localhost";
};
port = mkOption {
type = types.port;
default = 5432;
description = ''
Database port number.
This is ignored when using Unix socket connections.
'';
};
name = mkOption {
type = types.str;
default = "keycloak";
description = "Database name";
};
user = mkOption {
type = types.str;
default = "keycloak";
description = "Database user";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing the database password.
Not required when using Unix socket connections with peer authentication.
The file should contain only the password with no trailing newline.
'';
example = "/run/secrets/keycloak-db-password";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = ''
Whether to automatically create and manage a local PostgreSQL database.
When enabled, this will configure PostgreSQL with the necessary database and user.
'';
};
useSocket = mkOption {
type = types.bool;
default = true;
description = ''
Whether to use Unix socket connections instead of TCP.
This is more secure and efficient for local database connections.
'';
};
};
adminUser = mkOption {
type = types.str;
default = "admin";
description = ''
Username for the Keycloak administrative user.
This user will have full access to all realms and configuration.
'';
};
initialAdminPasswordFile = mkOption {
type = types.str;
description = ''
Path to a file containing the initial admin password.
This password will be used for the first login and should be changed afterwards.
The file should contain only the password with no trailing newline.
'';
example = "/run/secrets/keycloak-admin-password";
};
realms = mkOption {
type = types.attrsOf (
types.submodule {
options = {
displayName = mkOption {
type = types.str;
description = ''
Human-readable display name for the realm.
This is shown in the Keycloak UI and login pages.
'';
example = "My Organization";
};
clients = mkOption {
type = types.attrsOf (
types.submodule {
options = {
redirectUris = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
List of valid redirect URIs for OAuth/OIDC flows.
These are the URLs where Keycloak can redirect after authentication.
Use "*" for development only - always specify exact URIs in production.
'';
example = [
"https://app.example.com/oauth/callback"
"https://app.example.com/logout"
];
};
secretFile = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Path to a file containing the client secret.
If not provided, a random secret will be generated and printed to the journal.
The file should contain only the secret with no trailing newline.
'';
example = "/run/secrets/oauth-client-secret";
};
};
}
);
default = { };
description = ''
OpenID Connect (OIDC) clients configuration for this realm.
Each client represents an application that can authenticate users.
'';
};
users = mkOption {
type = types.attrsOf (
types.submodule {
options = {
email = mkOption {
type = types.str;
description = ''
Email address for the user.
This will be marked as verified by default.
'';
example = "user@example.com";
};
firstName = mkOption {
type = types.str;
default = "";
description = "User first name";
};
lastName = mkOption {
type = types.str;
default = "";
description = "User last name";
};
passwordFile = mkOption {
type = types.str;
description = ''
Path to a file containing the user's password.
The password will be set as permanent (not temporary).
The file should contain only the password with no trailing newline.
'';
example = "/run/secrets/user-password";
};
};
}
);
default = { };
description = ''
User accounts to create in this realm.
Users will be created with verified emails and permanent passwords.
'';
};
};
}
);
default = { };
description = ''
Keycloak realms to create and configure.
Each realm is an isolated namespace for users, clients, and settings.
'';
};
configurationAttempts = mkOption {
type = types.int;
default = 60;
description = ''
Number of attempts to wait for Keycloak to be ready before configuration.
Each attempt waits `configurationRetryDelay` seconds.
'';
};
configurationRetryDelay = mkOption {
type = types.int;
default = 2;
description = ''
Delay in seconds between configuration retry attempts.
'';
};
};
config = mkIf cfg.enable (mkMerge [
(mkIf (cfg.database.type == "postgresql" && cfg.database.createLocally) {
services.postgresql = {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensureDBOwnership = true;
}
];
};
})
{
assertions = [
{
assertion = cfg.database.type == "postgresql" || !cfg.database.createLocally;
message = "Only PostgreSQL is supported for local database creation";
}
{
assertion = cfg.database.useSocket -> cfg.database.host == "/run/postgresql";
message = "When using socket connections, database.host must be /run/postgresql";
}
{
assertion = !cfg.database.useSocket -> cfg.database.passwordFile != null;
message = "Database password file is required when not using socket connections";
}
{
assertion = cfg.database.passwordFile != null;
message = "Database password file is required for NixOS keycloak module compatibility";
}
{
assertion = cfg.port >= 1024 || cfg.bindAddress == "127.0.0.1";
message = "Ports below 1024 require root privileges. Use a higher port or bind to localhost only";
}
];
services.postgresql = mkIf (cfg.database.createLocally && cfg.database.type == "postgresql") {
authentication = mkIf (!cfg.database.useSocket) ''
host ${cfg.database.name} ${cfg.database.user} 127.0.0.1/32 scram-sha-256
'';
};
services.keycloak = {
enable = true;
settings = {
hostname = cfg.hostname;
http-host = cfg.bindAddress;
http-port = cfg.port;
http-enabled = true;
hostname-strict = false;
hostname-strict-https = false;
};
database = {
type = "postgresql";
host = cfg.database.host;
port = cfg.database.port;
name = cfg.database.name;
username = cfg.database.user;
passwordFile = cfg.database.passwordFile;
};
};
# The admin password must exist in an EnvironmentFile before keycloak.service
# starts. systemd resolves EnvironmentFile *before* running ExecStartPre, so a
# preStart hook is too late — a separate `before=` oneshot is required.
systemd.services.keycloak = {
requires = [ "keycloak-admin-setup.service" ];
after = [ "keycloak-admin-setup.service" ];
serviceConfig = {
EnvironmentFile = "/run/keycloak/admin-env";
};
environment = {
KEYCLOAK_ADMIN = cfg.adminUser;
};
};
# Runs as the keycloak user, not root: RuntimeDirectory already creates
# /run/keycloak owned by it, and LoadCredential lets PID1 (still root)
# read initialAdminPasswordFile on the unit's behalf and hand it over
# through $CREDENTIALS_DIRECTORY regardless of that file's own
# permissions -- so nothing here needs a privileged chown/cat.
systemd.services.keycloak-admin-setup = {
description = "Prepare Keycloak admin credentials";
before = [ "keycloak.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "keycloak";
Group = "keycloak";
RuntimeDirectory = "keycloak";
RuntimeDirectoryMode = "0750";
LoadCredential = "admin-password:${cfg.initialAdminPasswordFile}";
};
script = ''
ADMIN_PW=$(cat "$CREDENTIALS_DIRECTORY/admin-password")
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$ADMIN_PW" > /run/keycloak/admin-env
chmod 600 /run/keycloak/admin-env
'';
};
systemd.tmpfiles.rules = [
"d /var/lib/keycloak 0750 keycloak keycloak -"
];
users.users.keycloak = {
isSystemUser = true;
group = "keycloak";
home = "/var/lib/keycloak";
};
users.groups.keycloak = { };
systemd.services.keycloak-configure =
let
# Must talk to localhost: the `master` realm defaults to
# sslRequired=external, which rejects plain-HTTP requests from any
# non-local address. A loopback URL is exempt.
keycloakUrl = "http://localhost:${toString cfg.port}";
configScript = pkgs.writeShellScript "keycloak-config" ''
set -euo pipefail
ADMIN_PASS=$(cat ${cfg.initialAdminPasswordFile})
refresh_token() {
local token_response
token_response=$(curl -s -X POST "${keycloakUrl}/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${cfg.adminUser}" \
-d "password=$ADMIN_PASS" \
-d "grant_type=password" \
-d "client_id=admin-cli" 2>&1)
TOKEN=$(echo "$token_response" | ${pkgs.jq}/bin/jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$TOKEN" ]; then
return 0
fi
return 1
}
echo "Waiting for Keycloak admin API to be ready..."
TOKEN=""
for i in {1..${toString cfg.configurationAttempts}}; do
if ! REALM_CHECK=$(curl -s -f ${keycloakUrl}/realms/master 2>&1); then
echo "Attempt $i/${toString cfg.configurationAttempts}: Keycloak not responding yet..."
sleep ${toString cfg.configurationRetryDelay}
continue
fi
if ! echo "$REALM_CHECK" | ${pkgs.jq}/bin/jq -e '.realm' > /dev/null 2>&1; then
echo "Attempt $i/${toString cfg.configurationAttempts}: Keycloak responding but JSON invalid..."
sleep ${toString cfg.configurationRetryDelay}
continue
fi
if refresh_token; then
echo "Successfully obtained admin token on attempt $i"
break
fi
TOKEN_RESPONSE=$(curl -s -X POST "${keycloakUrl}/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${cfg.adminUser}" \
-d "password=$ADMIN_PASS" \
-d "grant_type=password" \
-d "client_id=admin-cli" 2>&1)
TOKEN_ERROR=$(echo "$TOKEN_RESPONSE" | ${pkgs.jq}/bin/jq -r '.error_description // .error // empty' 2>/dev/null)
if [ -n "$TOKEN_ERROR" ]; then
echo "Attempt $i/${toString cfg.configurationAttempts}: Token error: $TOKEN_ERROR"
else
echo "Attempt $i/${toString cfg.configurationAttempts}: Failed to get admin token, retrying..."
fi
sleep ${toString cfg.configurationRetryDelay}
done
if [ -z "$TOKEN" ]; then
echo "ERROR: Failed to get admin token after ${toString cfg.configurationAttempts} attempts"
echo "Server not available. Configure failed."
exit 1
fi
${concatStringsSep "\n" (
mapAttrsToList (realmName: realmConfig: ''
echo "Checking realm ${realmName}..."
if ! curl -s -H "Authorization: Bearer $TOKEN" \
"${keycloakUrl}/admin/realms/${realmName}" | jq -e '.realm' > /dev/null 2>&1; then
echo "Creating realm ${realmName}..."
curl -s -X POST "${keycloakUrl}/admin/realms" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '${
builtins.toJSON {
realm = realmName;
displayName = realmConfig.displayName;
enabled = true;
}
}'
echo "Successfully created realm ${realmName}"
else
echo "Realm ${realmName} already exists, updating displayName..."
CURRENT_REALM=$(curl -s -H "Authorization: Bearer $TOKEN" "${keycloakUrl}/admin/realms/${realmName}")
UPDATED_REALM=$(echo "$CURRENT_REALM" | ${pkgs.jq}/bin/jq --arg dn '${realmConfig.displayName}' '.displayName = $dn')
curl -s -X PUT "${keycloakUrl}/admin/realms/${realmName}" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$UPDATED_REALM"
echo "Successfully updated realm ${realmName}"
fi
${concatStringsSep "\n" (
mapAttrsToList (clientId: clientConfig: ''
refresh_token || true
if ! curl -s -H "Authorization: Bearer $TOKEN" \
"${keycloakUrl}/admin/realms/${realmName}/clients" | \
jq -e ".[] | select(.clientId == \"${clientId}\")" > /dev/null; then
echo "Creating client ${clientId} in realm ${realmName}..."
${lib.optionalString (clientConfig.secretFile != null) ''
CLIENT_SECRET=$(cat ${clientConfig.secretFile})
''}
${lib.optionalString (clientConfig.secretFile == null) ''
CLIENT_SECRET=$(openssl rand -base64 32)
echo "Generated client secret for ${clientId}: $CLIENT_SECRET"
''}
CLIENT_JSON=${
lib.escapeShellArg (
builtins.toJSON {
clientId = clientId;
enabled = true;
protocol = "openid-connect";
publicClient = false;
redirectUris = clientConfig.redirectUris;
standardFlowEnabled = true;
directAccessGrantsEnabled = true;
serviceAccountsEnabled = true;
}
)
}
CLIENT_JSON=$(echo "$CLIENT_JSON" | ${pkgs.jq}/bin/jq --arg secret "$CLIENT_SECRET" '. + {secret: $secret}')
curl -s -X POST "${keycloakUrl}/admin/realms/${realmName}/clients" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$CLIENT_JSON"
echo "Successfully created client ${clientId}"
else
echo "Client ${clientId} already exists in realm ${realmName}, skipping..."
fi
'') realmConfig.clients
)}
${concatStringsSep "\n" (
mapAttrsToList (username: userConfig: ''
refresh_token || true
if ! curl -s -H "Authorization: Bearer $TOKEN" \
"${keycloakUrl}/admin/realms/${realmName}/users?username=${username}" | \
jq -e ".[] | select(.username == \"${username}\")" > /dev/null; then
echo "Creating user ${username} in realm ${realmName}..."
USER_PASSWORD=$(cat ${userConfig.passwordFile})
USER_CREATE_RESPONSE=$(curl -s -i -X POST "${keycloakUrl}/admin/realms/${realmName}/users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '${
builtins.toJSON {
username = username;
email = userConfig.email;
firstName = userConfig.firstName;
lastName = userConfig.lastName;
enabled = true;
emailVerified = true;
}
}')
USER_ID=$(echo "$USER_CREATE_RESPONSE" | grep -i '^location:' | grep -o '[^/]*$' | tr -d '\r\n') || true
if [ -n "$USER_ID" ]; then
PASSWORD_JSON=$(${pkgs.jq}/bin/jq -n --arg pw "$USER_PASSWORD" '{type:"password",value:$pw,temporary:false}')
curl -s -X PUT "${keycloakUrl}/admin/realms/${realmName}/users/$USER_ID/reset-password" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$PASSWORD_JSON"
echo "Successfully created user ${username}"
else
echo "Warning: Failed to get user ID for ${username}"
fi
else
echo "User ${username} already exists in realm ${realmName}, skipping..."
fi
'') realmConfig.users
)}
'') cfg.realms
)}
echo "Keycloak configuration completed"
'';
in
{
description = "Configure Keycloak realms and clients";
after = [
"keycloak.service"
"postgresql.service"
];
wants = [ "keycloak.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "nobody";
Group = "nogroup";
TimeoutStartSec = "5m";
};
path = [
pkgs.curl
pkgs.jq
pkgs.openssl
];
script = "${configScript}";
};
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
}
]);
}