Keycloak docker image built for postgres with:
- Default env variable values to assume running behind a reverse proxy sending
X-Forwarded-*headers (env variables can be changed see keycloak documentation) - Enables metrics and health endpoints by default
- OpenRemote theme embedded and set as default (login and email templates only)
- Request path to
/auth(like older versions of Keycloak to simplify usage behind a reverse proxy) - Listener to configure roles of self-registered users. Roles are set using
KEYCLOAK_SELF_REGISTERED_USER_ROLESenvironment variable, the JSON structure is
{
"realmRoles" : [ "restricted_user" ],
"clientRoles" : [
{
"client": "openremote",
"roles": [
"read:assets",
"write:attributes"
]
}
]
}
When assigning to the environment variable, it must be enclosed in double-quotes, properly escaped. This can be done e.g. by piping to jq -c | sed 's/"/\\"/g', which would result in "{\"realmRoles\":[\"restricted_user\"],\"clientRoles\":[{\"client\":\"openremote\",\"roles\":[\"read:assets\",\"write:attributes\"]}]}" The listener is not enabled by default. In Keycloak, in the Realm settings - Events - Event listeners admin screen, self-register-user-configure should be added to the list.
The jar built by :theme provides one Keycloak theme named openremote, with two types:
- login — built from
theme/ui, a Keycloakify project. The pages are TypeScript and Lit, rendering@openremote/or-vaadin-componentsstyled by@openremote/theme. rspack bundles it and Keycloakify wraps the bundle in the FreeMarker templates Keycloak serves. - email — plain FreeMarker in
theme/src/main/resources/theme/openremote/email, unaffected by the above.
The login theme needs Keycloak 26 or newer, which is what this image's ARG VERSION builds. theme/ui/package.json says the same thing — Keycloakify's keycloakVersionTargets has "22-to-25": false — and on an older server it does not degrade, it breaks in ways that look like theme bugs:
- Registration, update-profile and the identity-provider profile review render no fields at all beyond the passwords. Keycloak only drives these from the declarative user profile once it is always on, in 26; 22-to-25 keeps it behind a feature flag and, with the flag off, serves a fixed-field
register.ftlwith noprofilein the model. The theme logs this to the browser console rather than leaving you to guess. - Before 24, Keycloak fails to load the theme entirely and serves its own pages: the theme's
parent=keycloak.v2(seetheme/build.gradle) does not exist there.
So deploy the image this repo builds. Dropping the jar into an older openremote/keycloak is the one combination to avoid.
Only the pages under theme/ui/src/pages are ours. Keycloakify generates a template for every login page, so the packaging step drops the ones we do not implement and Keycloak serves those from its own theme instead (via the built theme's parent=) — see theme/ui/scripts/write-implemented-pages.mjs.
./gradlew installDist # -> build/image/openremote-theme-provider.jar
# Build the image the way CI does
docker buildx inspect or-builder >/dev/null 2>&1 || docker buildx create --name or-builder --driver docker-container
docker buildx build --builder or-builder --load -t openremote/keycloak:dev .Use the docker-container builder rather than a plain docker build: with the default builder the same Dockerfile comes out around 200 MB larger, because that builder stores a copy of Keycloak's whole library directory that this one recognizes as unchanged. CI uses the same kind of builder (docker/setup-buildx-action).
Gradle drives the whole thing, so this needs Node 20+ and JDK 21 on the PATH, the pair CI builds with. It also needs Apache Maven: Keycloakify shells out to it and refuses to start without it, even though the jar it produces that way is discarded in favor of Gradle's.
Upgrading the design system is a version bump in theme/ui/package.json — there is no vendoring step and nothing to keep in sync.
The fast loop needs neither Keycloak nor a container:
cd theme/ui
yarn install
yarn start # http://localhost:5173A menu down the left side lists every login page — those with an implementation first, then those inherited from Keycloak. Page data comes from Keycloakify's mocks, so every page has realistic values, and rspack live-reloads on save. Page, color scheme and brand overrides all live in the URL, so the state survives a reload:
http://localhost:5173/?page=login-config-totp.ftl
http://localhost:5173/?page=login.ftl&theme=dark
Adding a page means dropping a file in theme/ui/src/pages that exports pageId and render. Nothing else: the page registry, the dev server menu and the packaging step all derive from that.
Build the jar and mount it into a stock Keycloak — no need to build this repo's image:
./gradlew :theme:jar
# The Keycloak this image is built on, read from the Dockerfile so the two cannot drift
KC_VERSION=$(sed -n 's/^ARG VERSION=//p' Dockerfile)
docker run --rm -p 8081:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$PWD/theme/build/libs/openremote-theme-provider.jar:/opt/keycloak/providers/or-theme.jar:ro" \
"keycloak/keycloak:$KC_VERSION" start-dev --spi-theme-cache-themes=false --spi-theme-static-max-age=-1Then set the realm's login theme to openremote (Realm settings → Themes). Set only the login theme: --spi-theme-default=openremote makes Keycloak look for an admin theme of that name too, which this theme does not provide, and the admin console then fails to load.
There is no bind-mount hot reload for the login pages any more, because they are a compiled bundle rather than templates — use the dev server above to iterate and this to verify.
Pages that are awkward to reach by driving a flow can be deep-linked with application-initiated actions, appending kc_action to the auth request: CONFIGURE_TOTP, UPDATE_PASSWORD, UPDATE_PROFILE, UPDATE_EMAIL, VERIFY_EMAIL.
To read Keycloak's own templates and message bundle for reference — which is the fastest way to answer "what does Keycloak do here?" — take them from Maven rather than from a container:
KC_VERSION=$(sed -n 's/^ARG VERSION=//p' Dockerfile)
curl -O "https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/$KC_VERSION/keycloak-themes-$KC_VERSION.jar"
unzip -q "keycloak-themes-$KC_VERSION.jar" 'theme/base/login/*' 'theme/keycloak.v2/login/*'From a running container it is docker cp ID:/opt/keycloak/lib/lib/main/org.keycloak.keycloak-themes-${VERSION}.jar ./, which has the advantage of being unambiguously the version you are running.
What to exercise before shipping a change to the theme. The pages carry no automated tests today; this list is written to be mechanical enough to become a Playwright suite against a throwaway Keycloak, which is the intended next step.
Unless a case says otherwise, every one of them should also be checked for the four things that cut across all of them — see Cross-cutting at the end.
Realm setup. One realm with: registration on, "Forgot password" on, "Remember me" on, "Email as username" off, "Login with email" on, internationalization on with at least English and one non-Latin-script language, and one identity provider configured. A second realm with all of those off is what catches links and controls that render when they should not.
| Page | How |
|---|---|
login, register, login-reset-password |
Their own URLs |
login-config-totp, login-update-password, login-update-profile, update-email, login-verify-email |
kc_action=CONFIGURE_TOTP / UPDATE_PASSWORD / UPDATE_PROFILE / UPDATE_EMAIL / VERIFY_EMAIL on the auth request |
login-otp, select-authenticator |
Set up one (or two) second factors, then log in again |
idp-review-user-profile, login-idp-link-confirm, login-idp-link-email |
See First broker login below |
login-page-expired |
Open the login page, wait past the realm's login timeout (Realm settings → Sessions), submit |
info |
End of the Forgot-password flow |
error |
Any auth request naming a client that does not exist |
- Valid credentials sign in and land back on the application.
- Wrong password re-renders the page with the error against the field, and the username still filled in.
- Submitting empty is refused by the fields themselves, with no request sent.
- "Forgot password?" and "New user? Register" go to the right pages — and are absent on the realm that has those features off.
- The username label reads "Username or email" with login-with-email on, "Username" without it.
- One button per identity provider, and clicking one reaches the provider.
- A registration that succeeds creates the account and continues the flow.
- Each field renders its own error; a global error appears once, in the alert, and not twice.
- Mismatched passwords are reported on the confirmation field.
- With terms required, the checkbox blocks submission until ticked.
- Add a custom attribute to the realm's user profile as each of: select, radio group, multiselect, textarea, read-only, and multivalued. Each renders as that control, and the value posted survives a round trip — a multivalued attribute must keep all its values.
- With internationalization on,
localeis a hidden field carrying the current language, not a visible text box.
- Submitting a known username returns to the login page with the success message "You should receive an email shortly with further instructions." (Keycloak's
ResetCredentialEmailforks the flow back to login; it does not showinfo.) - Submitting an unknown one says the same thing (Keycloak does not leak account existence).
- "Back to login" returns without a stray
«.
- The QR code scans with a real authenticator app, and its quiet zone looks the same on a realm whose name is short and one whose name is long.
- "Unable to scan?" shows the secret key and the policy details, and the link back returns to the code. Both say "barcode" where the thing on screen is a QR code — that is Keycloak's own wording, matched by the design, and deliberately not overridden here.
- A wrong code re-renders with the error and the alert is not also shown.
- The device name is optional on the first credential and required once one exists.
- Cancel, with every field empty, actually cancels. This is the case that regressed: the fields are
required, so withoutformnovalidatethe browser blocks the submit and pops a validation bubble instead. Same check onlogin-update-passwordandlogin-update-profile.
- A correct code signs in; a wrong one reports the error.
- With two registered devices the picker appears, and the device you choose is the one Keycloak validates against — pick the second one and use its code. Getting this wrong is silent: Keycloak falls back to a default credential and the code appears simply to be rejected.
Configure an identity provider, then:
- Review profile — with Review Profile set to "on", the first sign-in through the provider shows
idp-review-user-profilewith the profile fields pre-filled from the provider, and submitting creates the account. - Account already exists — sign in through the provider with an email that already belongs to a local account.
login-idp-link-confirmappears, with the "User with email … already exists" message in the alert. "Review profile" and "Add to existing account" must each do what they say — they are one form distinguished only bysubmitAction, so a broken button silently does the other thing. - Verify by email — with the flow's link action set to Email,
login-idp-link-emailappears naming the provider, the account and the realm, and the two "Click here" links re-send the mail and continue the flow.
- Leave the login page open past the login timeout and submit. Both links work: "restart" starts a fresh flow, "continue" resumes one completed in another tab.
Run these against at least login, register and login-config-totp — the short page, the tall one, and the one with a non-text element in it.
- Branding. A realm with its own
manager_config.jsonshows its logo, application title, brand color and favicon, with no flash of stock OpenRemote branding first. A realm with no entry falls back cleanly. A config using the legacy--or-app-color4brands correctly. - Dark mode. Every page in
prefers-color-scheme: dark, including the alert colors and the 2FA QR code, which needs a light backdrop to stay scannable. - Translations. Switch to a non-English language: the whole page changes, including anything this theme adds. Nothing shows a literal
«. Check one right-to-left language for layout. - Narrow screens. 320px wide: nothing clipped, and the language switcher does not sit on top of the logo.
- Inherited pages. Spot-check a page this theme does not implement, e.g.
select-authenticatororterms— those come from Keycloak's own theme and must still work.
Logo, application title and brand color are read at runtime from the manager's manager_config.json, via the public GET /api/{realm}/configuration/manager endpoint. A custom project therefore only needs its own manager config — no changes to this theme. Legacy configs are handled too: styles strings written for the Manager's shadow DOM (:host > *) are rewritten to :root, and the legacy --or-app-color4 is mapped onto --or-color-primary. An explicit --or-color-* in the config takes precedence.
The manager is fetched same-origin by default, which is correct in production: Keycloak needs a fixed hostname, and that hostname is also one of the manager's domains (the proxy serves the manager at / and Keycloak at /auth), so the login page always renders somewhere a manager answers. Because branding is keyed on realm rather than domain, it does not matter which manager domain the user originally came from.
Set OR_MANAGER_URL on the Keycloak container only when Keycloak is served from an origin with no manager behind it — including a split-origin development setup:
docker run ... -e OR_MANAGER_URL=http://127.0.0.1:8080 openremote/keycloak:developIt is read at render time from the theme's theme.properties, so it takes effect on restart with no rebuild. Such a setup also needs the Keycloak origin added to OR_WEBSERVER_ALLOWED_ORIGINS on the manager, since production CORS is not * (in dev mode the manager already allows all origins).
Pages address Keycloak's own message keys, and Keycloakify ships Keycloak's bundle for ~30 languages, so enabling internationalization on a realm translates the theme.
The wording is Keycloak's, including in English. This theme does not keep a house-style copy deck: an override can only be written in one language at a time, so it would show on an English page and nowhere else, and the theme would own auth copy it has no way to translate. A string that reads wrong reads wrong in every language, and the fix belongs in Keycloak.
theme/ui/src/i18n.ts therefore holds only two things, both explained there: the handful of keys Keycloak ships no message for in any language, and the stripping of the literal « that Keycloak prefixes to its back-links, which the stylesheet already draws as a chevron.