Skip to content

Keycloak

Curated name keycloak. Adds a Keycloak identity & access management server to the workbench in compose mode, started in development mode and pre-loaded with the realm you mount.

A real OAuth2 / OpenID Connect / SAML server for local development, so your app delegates login, users, roles and clients to Keycloak instead of faking auth. Language-agnostic - your app talks to it over the standard protocols.

At init, or to an existing workbench:

Terminal window
monoceros init acme --with-services=keycloak
monoceros add-service acme keycloak

A fresh Keycloak is empty. To get your realm, clients and test users without clicking through the admin UI every time, keep a realm export in your project repo and mount it into Keycloak’s import directory - it is imported at startup. This is the one thing you need to wire up.

The curated entry ships a commented volumes: scaffold; uncomment it and point it at the realm file in your cloned repo. Paths are relative to the container root (where your repos land under projects/):

acme.yml
services:
- name: keycloak
image: quay.io/keycloak/keycloak:26.7
port: 8080
httpPort: 8080
command: start-dev --import-realm --proxy-headers=xforwarded
# ...
volumes:
- projects/myapp/keycloak/realm.json:/opt/keycloak/data/import/myapp.json:ro

After monoceros apply acme, Keycloak comes up with the realm imported.

The --proxy-headers=xforwarded in the command lets Keycloak build its issuer from the request’s host and scheme, so an OIDC login works both at acme.localhost on the host (http) and over monoceros share (https) on a phone. Without it Keycloak would stamp the wrong scheme and the token exchange would fail on the https client.

Keycloak imports every *.json in the import directory. You can’t mount two directories onto the same target, so mount each realm as its own file to a distinct target name:

acme.yml
volumes:
- projects/app-a/keycloak/realm.json:/opt/keycloak/data/import/app-a.json:ro
- projects/app-b/keycloak/realm.json:/opt/keycloak/data/import/app-b.json:ro

--import-realm seeds Keycloak’s database from the file once at startup - it does not write back. Changes you make in the admin UI live in Keycloak’s (ephemeral) database, not the file. To keep a change, export the realm (admin UI -> Realm settings -> Action -> Partial export, or kc.sh export) and overwrite the committed realm.json. The database re-seeds from the file on every monoceros apply, so the committed realm stays the source of truth.

One thing to know about the export: a partial export never contains users. Clients, roles, groups, flows and the realm settings come back, so structure you clicked together is safe, but a user you created by hand is not. Users stay hand-written in the file.

The startup import only fills an empty database, so editing realm.json changes nothing by itself. monoceros apply acme recreates the service, which is what wipes the database and re-seeds it from your file. A plain monoceros stop acme and monoceros start acme keeps the database, so the old realm stays.

While the workbench is up, apply the file to the running Keycloak instead. From inside the workbench (monoceros shell acme), starting at the workspace root:

Terminal window
.monoceros/bin/keycloak-realm projects/myapp/keycloak/realm.json

Or from your host, without entering the workbench first:

Terminal window
monoceros run acme -- .monoceros/bin/keycloak-realm projects/myapp/keycloak/realm.json

monoceros run starts in the workspace root too, so the same relative paths work. The -- separates the workbench’s own flags from the command you want run inside.

The tool ships with the Keycloak service, so it is there whenever the service is. It replaces the realm from the file, which is what makes the file win: a user you created at runtime, every open session, and the generated secrets of confidential clients are gone afterwards. The realm gets new signing keys, so you log in again. It reads the connection details from the workbench environment, which means it can only ever reach this workbench’s Keycloak.

Custom themes live under /opt/keycloak/themes/<name>. Mount your theme folder there, then set loginTheme / accountTheme to <name> in the realm:

acme.yml
volumes:
- projects/myapp/keycloak/realm.json:/opt/keycloak/data/import/myapp.json:ro
- projects/myapp/keycloak/theme:/opt/keycloak/themes/myapp

In development mode themes are not cached, so edits show on reload without a restart.

Terminal window
monoceros remove-service acme keycloak

Or delete the service’s block from acme.yml by hand and re-run monoceros apply acme - the yml is the source of truth.

acme.yml
services:
- name: keycloak # host name in the container
image: quay.io/keycloak/keycloak:26.7
port: 8080 # in-container port (feeds `monoceros tunnel`)
httpPort: 8080 # the port `monoceros share` offers to other devices
env:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME} # values live in acme.env
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
restart: unless-stopped
command: start-dev --import-realm --proxy-headers=xforwarded
connectionEnv: # → the workspace env vars below
URL: http://${host}:${port}
HOST: ${host}
PORT: ${port}
USER: ${KC_BOOTSTRAP_ADMIN_USERNAME}
PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
# volumes: # uncomment + edit to mount your realm/theme
# - projects/<app>/keycloak/realm.json:/opt/keycloak/data/import/<app>.json:ro
# - projects/<app>/keycloak/theme:/opt/keycloak/themes/<app>

The admin credentials are ${VAR} references; the real values live in acme.env (dev default admin / admin), so the yml stays shareable without baking credentials in (see Workbench configuration). There is no persistent data dir - the dev database is ephemeral and re-seeds from the mounted realm on every apply.

Monoceros injects a connection env into the workspace container, prefixed with the service name:

VariableValue (defaults)
KEYCLOAK_URLhttp://keycloak:8080
KEYCLOAK_HOSTkeycloak
KEYCLOAK_PORT8080
KEYCLOAK_USERadmin
KEYCLOAK_PASSWORDadmin
KEYCLOAK_PUBLIC_URLhttp://acme-keycloak.localhost

The prefix is the service name, uppercased, so a second instance added with --as=auth2 gets AUTH2_URL, … and never collides.