Keycloak Setup
Operator guide for a network’s shared Keycloak realm. For what the realm contains, see the Keycloak realm reference.
Set the realm name with KEYCLOAK_REALM to match the network you are deploying
(bluedots, yellowdots, purpledots, …). Commands below use bluedots as an
example.
1. Render and import the realm
Section titled “1. Render and import the realm”The realm is imported from JSON, not clicked together in the admin console.
Signals keeps its template at infra/keycloak/realms/bluedots-realm.json; the
aggregator’s is infra/keycloak/realms/realm.json.
# both happen automatically — render-realm.sh is the Keycloak container's# entrypoint, so you only set the env vars it reads (PUBLIC_BASE_URL, ...)docker compose up -d keycloakThen apply the declarative user-profile configuration, which the import does not cover:
./infra/keycloak/init/apply-user-profile.shBecause both verticals share the realm, import it once. A second import against the same realm will not merge cleanly.
2. Set the two base URLs
Section titled “2. Set the two base URLs”Most setup failures come from confusing these:
| Variable | Points at | Must satisfy |
|---|---|---|
KEYCLOAK_BASE_URL | The browser-facing hostname | Equal to the iss claim byte-for-byte |
KEYCLOAK_INTERNAL_BASE_URL | What the API process dials for JWKS and Admin REST | Reachable from inside the container network |
In Docker these differ: the browser needs a hostname it can resolve, while the
API talks to a compose service name. If the issuer and KEYCLOAK_BASE_URL
disagree by even a trailing slash, every token is rejected.
Locally the OIDC issuer hostname must resolve, so add it to your hosts file:
echo "127.0.0.1 keycloak" | sudo tee -a /etc/hostsAlongside these, set KEYCLOAK_REALM (your network’s realm name), the UI and API client ids
(KEYCLOAK_UI_CLIENT_ID, KEYCLOAK_API_CLIENT_ID,
KEYCLOAK_API_CLIENT_SECRET) and the acceptance lists described in Identity &
Auth.
3. Create the first admin
Section titled “3. Create the first admin”There is no email-domain rule that quietly grants admin. Admin creation is an explicit operator action:
# dry run — writes nothingpnpm --filter api keycloak:create:admin --email=ops@example.org --name="Ops"
# actually createpnpm --filter api keycloak:create:admin --email=ops@example.org --name="Ops" --applyUse --phone=+919876543210 instead of --email to drive the phone OTP channel.
Either an email or a phone is required, and nothing is written without
--apply.
4. Register an integrating DPG
Section titled “4. Register an integrating DPG”To let an aggregator or voice DPG call Signals:
- Create a confidential client in the realm whose client id equals the
organisation’s
slug— this is the convention the service-account lookup relies on, not a configurable mapping. - Enable the client-credentials grant.
- Add the client id to
KEYCLOAK_SERVICE_CLIENT_IDSon the Signals API. It is empty by default, so a new DPG is refused until it is listed. - Give the client a
signals_acting_orgsgrant covering the organisations it may act as.
The DPG then exchanges its credentials for a token and calls Signals with that
token plus x-acting-org-id. See Adaptor
Onboarding.
5. Moving off localhost
Section titled “5. Moving off localhost”For each public client (signals-ui, aggregator-portal) update:
- Valid Redirect URIs
- Web Origins
and replace localhost / keycloak hostnames throughout the environment
config. See Deployment.
Migrating an existing instance
Section titled “Migrating an existing instance”Applies to instances provisioned before the GA-2026-08-18 release, and to any
instance still running the legacy path. Skip this for a fresh deployment.
This is an operator-run cutover, not a self-serve upgrade. There is no in-app migration and no automatic fallback: nothing creates a missing Keycloak identity on demand at login, so any user who is not in the realm at the moment you switch is locked out.
Migrate every user before flipping the provider:
pnpm --filter api keycloak:migrate:users # dry run — writes nothingpnpm --filter api keycloak:migrate:users --apply # create the userspnpm --filter api keycloak:migrate:users --reconcile # verify every local user has a match by idRun --reconcile until it reports a 1:1 mapping. Only then set
AUTH_PROVIDER=keycloak and restart. Create at least one admin first (step 3
above), since the legacy admin-by-email-domain rule has no Keycloak equivalent.
There is no mixed mode to stage the change through — an instance is on one
provider or the other, so the switch happens for all users at once. If something
goes wrong, the switch is reversible per instance: set AUTH_PROVIDER back and
restart. Users created while on Keycloak can still sign in afterwards, because
the legacy OTP login does not require a stored credential.
Where to go next
Section titled “Where to go next”- Keycloak Realm Reference — what the imported realm contains.
- Identity & Auth — the token and acting-org model.
- API Conventions — the errors a misconfigured client will see.

