Skip to content

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.passwordFile is 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 upstream services.keycloak module 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's redirectUris/secretFile or 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 secretFile gets an openssl rand secret echoed into the keycloak-configure journal (once, at creation time). Prefer secretFile for 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-configure oneshot runs as nobody:nogroup yet cats initialAdminPasswordFile (and every passwordFile/secretFile) directly. If your secret manager writes those files 0400 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 mode 0040 and a shared owning group, then run the reconciler under a dedicated user in that group (override the unit's User/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 set openFirewall = 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.type is currently an enum of one.

  • Legacy bootstrap env vars. Admin bootstrap here uses KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD. Keycloak 26.0 renamed those to KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD and kept the old names as deprecated aliases — nixpkgs' own services.keycloak already sets the new ones (for its initialAdminPassword path, which this module does not use). If your pinned Keycloak ever drops the deprecated aliases, change the environment key and the env-file key written by keycloak-admin-setup.

  • Port below 1024 needs a loopback bind. An assertion rejects port < 1024 unless bindAddress = "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-configure oneshot runs as nobody:nogroup with a 5-minute TimeoutStartSec, after keycloak.service and postgresql.service.
  • It polls /realms/master until the API answers valid JSON, then obtains an admin token via the admin-cli client (password grant).
  • It calls refresh_token before each client and user to dodge token expiry on large configs.
  • Client JSON is assembled with lib.escapeShellArg and the secret merged in via jq --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 ];
    }
  ]);
}