Keycloakify Pipeline: Build, Test, Deploy
Is the REST-only assumption correct?
Mostly yes — for login, you theme it; for everything else, use the Admin REST API.
Keycloak’s login-time flows are rendered server-side from Freemarker templates: login, registration, password reset, MFA/OTP, required actions, consent, identity-provider linking, update-profile, error, account console. The browser must land on Keycloak to complete the OIDC Authorization Code flow so Keycloak can set its session cookie, run brute-force protection, apply authenticator SPIs, honor required actions, and mint tokens. There is no REST endpoint that reproduces that flow end-to-end with the same security guarantees.
The one exception is the Resource Owner Password Credentials grant (/token with grant_type=password). Avoid it:
- Deprecated in OAuth 2.1.
- Bypasses MFA, required actions, consent, social login, SSO, and step-up auth.
- Forces your app to handle raw credentials.
So the split you’re describing is the idiomatic one:
- Login surface → Keycloakify theme (JAR deployed into Keycloak).
- User/realm/group/role management → Admin REST API (
/admin/realms/...) with a service-account client usingrealm-managementroles.
What Keycloakify actually produces
Keycloakify is a build tool. You write React + your own CSS/Tailwind; it transpiles each page into the Freemarker template Keycloak expects, bundles the JS/CSS, and packages everything as a Keycloak theme JAR.
The JAR is the only deployable artifact. Keycloak loads it from its providers/ directory at startup; the theme is then selectable per realm (Login theme, Account theme, Email theme).
flowchart LR
A[React/TS source<br/>src/login, src/account] --> B[keycloakify build]
B --> C[keycloak-theme.jar]
C --> D[Keycloak providers/ dir]
D --> E[Realm config selects theme]
E --> F[End users see custom login]
Repo layout
keycloak-theme/
├── src/
│ ├── login/ # KcPage.tsx + per-page overrides
│ ├── account/ # optional, only if customizing account console
│ ├── main.tsx # dev entrypoint
│ └── kc.gen.ts # generated: messages, page props
├── public/
├── vite.config.ts # @keycloakify/vite-plugin
├── package.json
└── .storybook/ # per-page stories
One repo, one JAR. Keep it separate from your app repo — themes version independently from Keycloak and from the frontends consuming it.
Local dev loop
Two modes, use both:
- Storybook — fastest. Each page (
Login,Register,LoginOtp,LoginResetPassword, …) gets a story with mockkcContext. Iterate on styles without touching Keycloak. npx keycloakify start-keycloak— spins up a real Keycloak container with your theme mounted and hot-reloaded. Use this to verify actual Freemarker rendering, i18n lookups, and flows you can’t fake in Storybook (real form POST, error states, required actions).
flowchart TD
Dev[Edit Login.tsx] --> SB{Which mode?}
SB -->|Visual / fast| Story[Storybook HMR]
SB -->|Flow / integration| KC[keycloakify start-keycloak]
KC --> Browser[localhost:8080 real Keycloak]
Story --> Commit
Browser --> Commit[git commit]
Testing strategy
Pyramid, bottom to top:
| Layer | Tool | What it catches |
|---|---|---|
| Unit / visual | Storybook + Chromatic (or Playwright snapshots of stories) | Component regressions, per-page variants (error, loading, social IdPs, remember-me, password policies) |
| Integration | Playwright against start-keycloak container | Real form submission, redirects, session cookie, i18n, CSRF tokens rendered correctly |
| E2E | Playwright against a staging Keycloak with a test realm | Full auth code flow from your actual SPA, MFA, password reset email link, IdP broker redirects |
| Smoke (post-deploy) | Playwright, headless, against prod realm with a synthetic user | Theme JAR loaded, no 500s on login page, asset URLs resolve behind CDN/reverse proxy |
Key per-page variants to story/test — easy to forget:
- Login: with/without social IdPs, “remember me”, reset-password link, register link, account lockout message, invalid credentials, realm disabled.
- Register: each configured required field, password policy violations, terms acceptance.
- LoginOtp / LoginRecoveryAuthnCodeConfig: TOTP setup, backup codes.
- LoginUpdatePassword / LoginUpdateProfile: required-action flows.
- Error: expired code, cookie not set, identity-provider linking failures.
- Email templates: if you theme these, render them with a tool like
mjmlor the Keycloakify email preview and snapshot.
Keycloakify ships mock kcContext fixtures for every page — use them as story args rather than hand-building.
CI pipeline
flowchart LR
PR[Pull request] --> Lint[lint + typecheck]
Lint --> Unit[Storybook build + visual diff]
Unit --> Build[keycloakify build -> JAR]
Build --> IT[Playwright vs start-keycloak]
IT --> Artifact[Publish JAR + SHA + changelog]
Artifact --> Image[Build Keycloak image<br/>with theme baked in]
Image --> ECR[Push to ECR with git SHA tag]
ECR --> Deploy[Deploy to staging]
Deploy --> Smoke[Playwright smoke tests]
Smoke --> Promote[Manual approval -> prod]
GitHub Actions sketch:
pnpm install --frozen-lockfilepnpm typecheck && pnpm lintpnpm build-storybook→ visual diff via Chromatic or@playwright/testagainst stories.pnpm keycloakify build→ producesdist_keycloak/keycloak-theme-<version>.jar.- Integration:
npx keycloakify start-keycloak --port 8080in background,pnpm playwright test. - On merge to
main: build Docker imageFROM quay.io/keycloak/keycloak:26.xwith the JAR copied into/opt/keycloak/providers/, thenkc.sh buildbaked at image-build time so startup is fast. - Push to ECR with both
:sha-<commit>and:stagingtags.
Deployment
The “deploy” step is two independent things, decoupled on purpose:
- Ship the theme bits (the JAR) to wherever Keycloak can load it.
- Point a realm at the theme (realm-level config:
loginTheme,accountTheme,emailTheme).
Shipping the JAR — three patterns
| Pattern | How | When to use |
|---|---|---|
| Baked image (recommended) | Dockerfile copies JAR into /opt/keycloak/providers/, runs kc.sh build. Image tagged by theme SHA. | You control the Keycloak deployment. Immutable, atomic rollback, one artifact per deploy. |
| Sidecar / init container | JAR pulled from S3/ECR by init container, volume-mounted into Keycloak pod. | You don’t want to rebuild Keycloak image for every theme tweak. Requires Keycloak restart to pick up. |
| Managed Keycloak (Cloud-IAM, Phase Two, Red Hat SSO) | Upload theme via their UI/API. | You don’t run Keycloak yourself. |
For an AWS-native setup: baked image → ECR → ECS Fargate (or EKS) service → rolling deploy. CDK manages the ECS service; the theme repo’s CI updates the image tag in a parameter/SSM, and a CDK pipeline picks it up. Or use an ECS task definition update directly from GH Actions.
Important: Keycloak only picks up new theme JARs on startup, and kc.sh build must run if you’re on optimized/production mode. Baking into the image sidesteps both issues — a new image = a new rollout.
Pointing the realm at the theme
Two ways, both automatable:
- Terraform
mrparkers/keycloakprovider —keycloak_realmresource haslogin_theme,account_theme,email_themefields. Run in the same pipeline that deploys the image, after the image is live. keycloak-config-cli— declarative YAML/JSON config applied at deploy time against the Admin API.
Don’t set theme via the admin UI in prod — it drifts.
Rollback
Because the theme name is a string ref, rollback is two cheap options:
- Image rollback — redeploy previous ECS task definition. Theme name unchanged, JAR version reverts.
- Theme switch — if you version theme names (
mycorp-v3,mycorp-v4) and bake both into the image, flip the realm’sloginThemeback via Terraform. Slower, but zero container churn.
I’d pick image rollback for speed and keep a single theme name.
Can it be fully automated?
Yes, end-to-end:
- Build + test + JAR + image + ECR push → GitHub Actions on merge to main.
- Image deploy → ECS service update (CDK pipeline or direct
aws ecs update-service). - Realm theme ref → Terraform apply in the same pipeline, only needed when you rename the theme or add a new realm.
- Post-deploy verification → Playwright smoke hitting the login URL with a synthetic user.
The one place to keep manual: promotion from staging to prod. Login is a blast-radius-of-everyone surface — a broken login locks out your whole user base, including admins. Gate prod on a manual approval in the pipeline, and keep the previous image tag pinned for one-click rollback.
Two pipelines: theme team vs auth team
When the auth team owns the Keycloak instance, the theme repo becomes a library producer and the auth team’s pipeline is the consumer. The JAR is the contract.
flowchart TB
subgraph ThemeRepo["Theme repo — your team"]
A[PR + tests] --> B[keycloakify build]
B --> C[keycloak-theme-x.y.z.jar]
C --> D[Publish to shared registry<br/>S3 / GH Releases / CodeArtifact / ECR OCI]
end
subgraph AuthRepo["Keycloak platform repo — auth team"]
E[Dockerfile<br/>FROM keycloak:26.x] --> F[Pulls theme JAR<br/>pinned version]
F --> G[COPY into providers/<br/>+ kc.sh build]
G --> H[Push Keycloak image to ECR]
H --> I[Deploy to their ECS/EKS]
end
D -.consumed by.-> F
ThemeBump[Theme release: v1.4.0] -.PR to bump ref.-> AuthRepo
What to publish from the theme pipeline
Pick one artifact store and stick to it. Options, in order of how well they fit an AWS-native org:
- CodeArtifact (Maven repo) — Keycloak themes are JARs; this is the native Java way and supports version ranges, IAM auth, replication. Best fit if the auth team already treats Keycloak providers as Maven deps.
- ECR as an OCI artifact —
oras pushthe JAR. Same auth model as their Keycloak image. Good if the auth team’s Dockerfile uses a multi-stageCOPY --from=...pattern. - S3 bucket with versioned paths — simplest.
s3://acme-keycloak-themes/login/1.4.0/keycloak-theme.jar. Cross-account read via bucket policy. Good enough for most orgs. - GitHub Releases — fine if both teams are in GitHub and no air-gap concerns.
Publish with :sha-<commit>, :1.4.0, and :latest-stable tags. Never have the auth team consume latest — always a pinned version.
The contract between the pipelines
Make these explicit so you don’t break each other:
| Contract | Owner | Broken when |
|---|---|---|
Theme name in JAR (META-INF/keycloak-themes.json) | Theme team | Renaming the theme → auth team’s realm config still points at old name → login unstyled. |
| Keycloak version compatibility range | Both | Auth team upgrades Keycloak major faster than theme bumps Keycloakify. |
Page coverage (which pageIds are implemented) | Theme team | Auth team adds an authenticator that renders a new page → theme falls back to unstyled Freemarker default. (See next section.) |
| Asset path assumptions (CDN, reverse proxy headers) | Auth team | They change KC_HOSTNAME or proxy setup → theme’s CSS/JS 404s. |
| i18n keys required | Theme team | Theme expects ${msg('selectTenant')} but auth team’s new authenticator doesn’t register the message bundle. |
Put the contract in a COMPATIBILITY.md in the theme repo. Each theme release lists: min/max Keycloak version, Keycloakify version, list of pages covered.
Promotion flow across both pipelines
sequenceDiagram
participant TT as Theme team
participant TR as Theme registry
participant AT as Auth team
participant KC as Keycloak (prod)
TT->>TR: Release 1.4.0 (JAR + changelog)
TT->>AT: Open PR bumping version ref
AT->>AT: CI builds new Keycloak image
AT->>AT: Deploy to auth-team staging
AT->>TT: Request smoke test on staging
TT->>AT: Sign-off (Playwright against their staging)
AT->>KC: Promote to prod (manual approval)
Two pipelines, one promotion gate. The theme team doesn’t push to prod Keycloak directly — they hand an artifact to the auth team and sign off on the staging rollout.
Testing across the seam
- Theme repo CI still runs
keycloakify start-keycloakin isolation — fast signal. - Add a contract test: auth team’s pipeline, on PR, runs a minimal Playwright suite (provided as an npm package by the theme team) against their staging Keycloak. Catches “their Keycloak config broke our theme.”
- Theme team maintains a reference realm export (
realm-export.json) that Keycloakify’sstart-keycloakimports. It mirrors the auth team’s prod realm shape: same authenticator flows, same IdPs enabled, same required actions. Update this whenever the auth team changes flow config.
Dev ergonomics when you don’t own Keycloak
You still iterate locally with start-keycloak — it spins up a disposable container with your theme. You do not need access to the auth team’s Keycloak to develop. The only thing you need from them is an up-to-date realm export that reflects their flow config (for the reference realm above).
Custom login steps (new authenticators)
Yes, Keycloakify supports this — but the work splits cleanly between the two teams, and the coordination point is the Freemarker template name.
How Keycloak extends login flows
Keycloak’s authentication flow is a sequence of authenticators. Stock ones: “Username Password Form”, “OTP Form”, “Cookie”, etc. Adding a new step (e.g., “select a tenant”, “accept updated terms”, “answer security question”) means the auth team writes a custom Authenticator SPI in Java:
- A class implementing
org.keycloak.authentication.Authenticator— decides whether to prompt, renders a Freemarker template, processes the POST. - A
AuthenticatorFactorythat registers it with an ID. - Packaged as its own JAR, deployed to Keycloak’s
providers/(same place as the theme JAR, different artifact). - Configured into the flow via Admin UI or Terraform (
keycloak_authentication_flow+keycloak_authentication_execution).
The JWT is only minted after every authenticator in the browser flow returns SUCCESS. Until then, the user is on an intermediate page backed by an authenticator — which means you need a themed page for each one.
Where Keycloakify fits
The authenticator renders a Freemarker template, e.g., select-tenant.ftl. Keycloakify lets you register that template as a custom page — out-of-the-box Keycloakify only styles the base set (login.ftl, register.ftl, login-otp.ftl, …), but custom pages hook into the same theming pipeline:
- Add the template name to
KcContextExtensionPerPageso TypeScript knows thepageId. - Declare the typed shape of whatever the authenticator puts on
kcContext(custom form fields, dropdown options, errors). - Write a React component branded
pageId: "select-tenant.ftl"and route it in yourKcPage.tsxswitch. - Rebuild the theme JAR — Keycloakify emits a matching Freemarker template the authenticator will find.
Sketch (shape matches what Keycloakify docs specify):
// KcContext.ts — theme repo
export type KcContextExtensionPerPage = {
"select-tenant.ftl": {
auth: { attemptedUsername: string };
url: { loginAction: string; loginRestartFlowUrl: string };
tenants: Array<{ id: string; displayName: string }>;
};
};
// pages/SelectTenant.tsx
export default function SelectTenant(
props: PageProps<Extract<KcContext, { pageId: "select-tenant.ftl" }>, I18n>
) {
const { kcContext, i18n, Template } = props;
const { url, tenants } = kcContext;
return (
<Template {...props} headerNode={i18n.msg("selectTenantTitle")}>
<form action={url.loginAction} method="post">
{/* render tenants, submit selection */}
</form>
</Template>
);
}
Who does what
| Step | Team | Artifact |
|---|---|---|
| Write authenticator Java SPI | Auth team | tenant-selector-spi-x.y.z.jar |
Register authenticator in flow (Terraform/keycloak-config-cli) | Auth team | Realm config |
Define template name contract (select-tenant.ftl) | Both — agreed in a design doc | — |
Provide mock kcContext fixture for local dev | Auth team → theme team | JSON fixture + Storybook story |
| Implement themed React page | Theme team | Updated theme JAR |
i18n keys (selectTenantTitle, etc.) | Theme team (messages) + auth team (references in Java) | Both JARs |
| Playwright E2E covering the new step | Shared — theme team writes, runs in both pipelines | — |
The auth team must give the theme team:
- The template filename (
select-tenant.ftl). - The shape of the data the authenticator will put on
FormMessage/AuthenticationFlowContext.form()— every field you’ll read fromkcContext. - A mock JSON fixture matching that shape so the theme team can build against Storybook without needing the SPI JAR deployed.
- A staging Keycloak with the authenticator already wired into a test flow, so Playwright can exercise the real path.
Integration timing
The risk here is version skew: auth team ships the SPI JAR before theme has the page, or vice versa. Mitigations:
- Fallback template: auth team can ship a minimal default
select-tenant.ftlin their SPI JAR so Keycloak doesn’t 500 if the theme hasn’t caught up. Keycloak resolves theme templates first, but falls back to provider-bundled ones. - Feature flag the authenticator: keep it out of the active flow until both JARs are in prod. Toggle via Terraform realm config.
- Same-release coordination: for flows where fallback is ugly, cut both releases together — theme v1.5.0 + SPI v0.3.0 in the same change window.
Things that are not possible this way
- Fully custom non-Keycloak UI for the extra step — e.g., hosting the tenant selector in your own SPA and then resuming the Keycloak flow. Technically doable via
ACTION_TOKENand custom required actions, but it’s a lot of machinery and loses Keycloak’s CSRF/session protections mid-flow. Don’t go here unless you have a strong reason. - Client-side-only steps — anything that must influence token claims has to run server-side in the authenticator. JS in the Keycloakify page can enhance UX but can’t be trusted for authz decisions.
Gotchas
- Asset URLs behind CDN/reverse proxy: Keycloak emits asset paths based on
KC_HOSTNAME/KC_PROXY_HEADERS. Get these wrong and your theme’s CSS/JS 404s only in staging/prod. Test with production-equivalent ingress in at least one pre-prod env. kc.sh buildin optimized mode: if you run Keycloak with--optimized, you must rebuild after adding a provider JAR. Bake it in.- Freemarker escape: Keycloakify handles this, but any string you render from
kcContextinsidedangerouslySetInnerHTMLis a footgun. Don’t. - i18n: message bundles live in
src/login/i18n.ts. Missing keys fall back to Keycloak defaults, which look out of place. Add a test that fails on missing keys for your supported locales. - Version coupling: Keycloakify releases track Keycloak majors. Upgrading Keycloak → upgrade Keycloakify → regenerate
kc.gen.ts→ re-test every page. Don’t skip majors. - Account console v2/v3: Keycloak 24+ ships a React-based account console. If you’re customizing that, it’s a different Keycloakify entrypoint than login — check which version your Keycloak runs.