Deployment
Configuration and deployment details for the celine-policies stack.
Docker Compose Stack
The docker-compose.yaml defines the full development stack:
| Service | Image | Port | Description |
|---|---|---|---|
keycloak |
Custom (from keycloak/Dockerfile) |
8080 | Identity provider with rec login and email themes |
keycloak-sync |
Same as mqtt_auth |
— | Runs bootstrap + sync on startup, then exits |
sync-users |
Same as mqtt_auth |
— | Imports example REC users with ENV=dev, then exits |
mqtt_auth |
From ./Dockerfile |
8009 | MQTT auth HTTP backend |
provisioning |
Same as mqtt_auth |
8010, not published | The only writer of participant accounts |
mosquitto |
ghcr.io/lhns/mosquitto-go-auth:3.3.0-mosquitto_2.0.22 |
1883, 1884 | MQTT broker (TCP + WebSocket) |
redis |
redis:7.2-alpine |
— | Cache backend |
oauth2-proxy |
quay.io/oauth2-proxy/oauth2-proxy:v7.11.0 |
4180 | OAuth2 reverse proxy |
mailpit |
axllent/mailpit:v1.31.1 |
1025 (SMTP), 8025 (UI) | Keycloak's outgoing mail in dev: kept, not delivered |
Startup Order
- keycloak starts first (health check on port 9000)
- keycloak-sync runs bootstrap (the platform level, from the mounted
platform.yaml) + sync, then exits. A sync that runs on every start and may be handed an incomplete file set can pass--additive, or setCELINE_KEYCLOAK_SYNC_ADDITIVE=truewhere it cannot be handed an argument: it adds and updates, removes nothing, and lists what it held back (README, "Adding without removing"). A declared removal then needs a plainsync(--no-additiveoverrides the variable for that run). - sync-users imports example users, then exits
- mqtt_auth starts after keycloak-sync and sync-users complete
- provisioning starts after keycloak-sync completes — it authenticates as
svc-provisioning, a clientsynccreates, so it cannot bootstrap the realm it authenticates against - mosquitto starts after redis is up and mqtt_auth is healthy
The provisioning service has no published port, and that is the guard
provisioning is declared with expose and not ports. It holds realm-wide Keycloak
administration, and the whole argument for that grant being acceptable is that nothing
outside the network reaches it — see
ADR-0007.
Publishing the port, or adding a route to it in the ingress, converts manage-realm into
an internet-facing credential. It would still check scopes on the token, because it
authorises by provisioning.* like every other service — but that is defence in depth, not
the control, and it is not what the grant was justified by. The corresponding guard lives
in ../celine-dev's Caddyfile, where somebody adding a route will read it.
Email: invitations and password recovery
Keycloak sends every email; the provisioning service only asks it to, through
execute-actions-email. The templates are the rec email theme
(keycloak/themes/rec/email/, it, en and es), and no password is ever generated or
put into an email. What each route sends is in the
API reference.
What a realm needs, and who owns it
Every setting below is platform level, and keycloak bootstrap is its only writer. It
reaches existing realms on every run, which the realm imports never do (--import-realm and
infra's IGNORE_EXISTING skip a realm that already exists).
| Setting | Declared in | Notes |
|---|---|---|
internationalizationEnabled: true, supportedLocales: [it, en, es], defaultLocale: it |
platform.yaml |
Must be on before the provisioning service writes locale: without it Keycloak answers 201 and drops the value. So bootstrap runs before the new provisioning image reaches a realm |
emailTheme: rec, loginTheme: rec, resetPasswordAllowed: true, actionTokenGeneratedByAdminLifespan: 604800, actionTokenGeneratedByUserLifespan: 3600 |
platform.yaml |
bootstrap refuses a theme the server does not list: Keycloak itself accepts any name and silently sends its own emails |
token and session lifespans, organizationsEnabled, adminPermissionsEnabled, the platform-admin role and its declared holders, the retired realm groups and roles, brute-force tuning |
platform.yaml |
sync-orgs and sync-users refuse a realm without Organizations; sync refuses to grant admin_permissions without fine-grained admin permissions |
bruteForceProtected |
CELINE_KEYCLOAK_BRUTE_FORCE_ENABLED |
Unset: on, and off when ENV is exactly dev |
the browser flow browser-admin-second-factor: a platform-admin signs in with a second factor (REQ-0015) |
CELINE_KEYCLOAK_ADMIN_MFA_REQUIRED |
Unset: on, and off when ENV is exactly dev. Below |
the master realm: bootstrap's own client, brute force, a second factor for master admins (REQ-0016) |
CELINE_KEYCLOAK_BOOTSTRAP_CLIENT_SECRET, and the two switches above |
Outside dev the secret is required. Below |
smtpServer |
CELINE_KEYCLOAK_SMTP_*, fed from the deployment's secret |
Below. Unset CELINE_KEYCLOAK_SMTP_HOST: left alone |
the built-in account-console client's default client scopes (web-origins acr profile roles basic email) |
bootstrap itself (ACCOUNT_CONSOLE_DEFAULT_SCOPES) |
What Keycloak gives a realm it creates. A realm imported from config/keycloak/import/realm-celine.json gets none, and the account console answers 403. Missing ones are added, none removed; no other client is touched |
supportedLocales, narrower |
a deployment overlay, bootstrap --overlay <file> |
The only key an overlay may change. It must keep defaultLocale (it) and name only it, en, es |
themes, languages, lifespans, registrationAllowed, resetPasswordAllowed, brute-force tuning |
CELINE_KEYCLOAK_PLATFORM_<KEY> |
Overrides platform.yaml and any overlay. Below |
verifyEmail is deliberately not declared, and platform.yaml refuses it: the invitation
already carries VERIFY_EMAIL, and turning it on would stop every existing account with
emailVerified: false at its next sign-in. passwordPolicy stays at Keycloak's default.
A deployment overlay has the same shape as platform.yaml:
realm_settings:
supportedLocales: [it, en]
bootstrap prints the file each changed key came from.
The realm admin and the browser login client
The realm imports used to create both. They now come from declarations, so a realm that
bootstrap created has them too.
- The operator realm admin is
bootstrap's, in every environment, whenCELINE_KEYCLOAK_REALM_ADMIN_USERNAMEis set. It is created once, withCELINE_KEYCLOAK_REALM_ADMIN_PASSWORD(a secret),_EMAIL, and_FIRST_NAME/_LAST_NAME(defaultCeline/Admin; the realm's user profile requires both, and Keycloak refuses the sign-in without them). It holds the realm roleplatform-admindirectly (ADR-0012);CELINE_KEYCLOAK_REALM_ADMIN_GROUPno longer exists. Its password is never re-sent. - Platform administrators are the realm admin plus the usernames a deployment lists in its
overlay,
platform_admin: {users: [...]}.bootstrapgrants the role to each one that exists, reports one that does not exist yet, and reports (never revokes) a holder nobody declared. It deletes the retired realm groups/admins,/managers,/editors,/viewersand realm rolesadmin,manager,editor,vieweron its first run, without--allow-destructive: every reader must already readplatform-adminfromrealm_access.roles(REQ-0011, REQ-0012). syncdeletes every mapper that writes a top-levelgroupsclaim and thegroupsclient scope (REQ-0013), on a full run;--additivekeeps and lists them.oauth2_proxyis declared inclients.yamland created bysync.syncalso owns its secret, flows, redirect URIs, web origins, default scopes and audience mappers. Set:
| Variable | Default | |
|---|---|---|
OAUTH2_PROXY_CLIENT_SECRET |
oauth2_proxy (refused outside dev) |
the secret oauth2-proxy is configured with |
CELINE_DOMAIN |
celine.localhost |
redirect URIs: sso, superset, webapp and assistant on it, each /* |
CELINE_URL_SCHEME |
http |
https in a deployment |
On a realm whose oauth2_proxy came from infra's import, the first sync replaces its
redirect URIs with those four. The webapp entry is also what the provisioning service's
invitation links return through.
The admin second factor and the master realm
Outside dev (ENV anything but dev) bootstrap turns on, on every run and from any
starting state (ADR-0013, ADR-0014):
- in the platform realm, the browser flow
browser-admin-second-factor, bound: a user holdingplatform-adminwho signs in with a password (not a passkey) gives a one-time code or a recovery code, and one who has neither enrols TOTP and recovery codes first. Nobody else is asked. A flow the realm import left, conditioned on the retired roleadmin, is repointed toplatform-admin. - in master, brute-force detection with
platform.yaml's tuning (failureFactor,waitIncrementSeconds,maxFailureWaitSeconds, …, and theirCELINE_KEYCLOAK_PLATFORM_*overrides), and the same flow without the organization step, conditioned on master's roleadmin. Every master admin without a one-time code gets the required actionCONFIGURE_TOTP, so their password alone obtains no token throughadmin-cliany more either.
Who does it. Master is changed only by bootstrap's own master client,
svc-celine-policies-bootstrap, never by an administrator's token, which the change could
cut off halfway. Each run:
- signs in to master as that client (
client_credentials, secretCELINE_KEYCLOAK_BOOTSTRAP_CLIENT_SECRET); only when that fails (first run, or the client holds another secret), as the master admin userCELINE_KEYCLOAK_ADMIN_USER/_PASSWORD; - creates or converges the client: confidential, service account only (no browser, no
password grant), the configured secret, and master's realm role
admin— the only role that may grant the admin CLI client its realm-management roles in a realm with fine-grained admin permissions (measured on 26.7.3); - signs in again as the client, checks the token is the client's own, and does everything else with it, the platform realm included.
Outside dev, without CELINE_KEYCLOAK_BOOTSTRAP_CLIENT_SECRET (or with one shorter than 32
characters) bootstrap exits 1 before it contacts Keycloak. Generate it once, keep it in
the deployment's secrets, and pass it to the bootstrap job as that variable; for example
openssl rand -hex 32. It is never printed and never written to the secrets file.
Operator procedure.
- The first run signs in as the master admin user, creates the client, and hardens master.
From then on the admin's password grant answers
invalid_grant("Account is not fully set up"): expected. Every later run signs in as the client; the admin user's credentials can stay configured, unused. - The first admin console sign-in after that run (through the port-forward the deployment
documents; master and
/adminare not exposed publicly) asks the master admin for a password, then enrols TOTP and recovery codes. Keep the recovery codes offline. Aplatform-adminof the platform realm enrols the same way at their next sign-in. - Rotating the client's secret: put the new value in the console (master, Clients,
svc-celine-policies-bootstrap, Credentials), then in the deployment's secret. A run with a secret the client does not hold falls back to the admin user, which fails once the admin has a second factor. - Recovery, when the client's secret is lost or the client deleted: Keycloak's own
kc.sh bootstrap-admin service(a temporary service client, no second factor) orkc.sh bootstrap-admin user(a temporary admin, who enrols a second factor at the first console sign-in), run against the Keycloak deployment; with it, reset the client's secret to the configured value, then delete the temporary account. A master admin who lost the authenticator is recovered by another master admin, who removes both the OTP and the recovery codes (removing only the OTP leaves the codes as a factor). - Turning it off outside dev (
CELINE_KEYCLOAK_ADMIN_MFA_REQUIRED=false,CELINE_KEYCLOAK_BRUTE_FORCE_ENABLED=false) is destructive and needs--allow-destructive. Unbinding binds Keycloak's ownbrowserflow and leaves the custom one in place; required actions already set are not removed.
In dev nothing changes: no second factor, no brute force, master not touched, and the
client is created only if CELINE_KEYCLOAK_BOOTSTRAP_CLIENT_SECRET is set.
The password grant of the platform realm (directAccessGrantsEnabled, on for
oauth2_proxy) does not run the browser flow, so a platform admin's password alone still
obtains a token there for whoever holds that client's secret.
Overriding a platform value from the environment
A deployment whose Keycloak differs from what the image assumes overrides a value instead of
mounting its own platform.yaml. The variable is CELINE_KEYCLOAK_PLATFORM_ followed by the
realm key in upper snake case; the keys it accepts are ENV_OVERRIDABLE_SETTINGS in
platform.py.
| Value | Effect |
|---|---|
| unset or empty | the declared value stands |
null |
the key is not declared: bootstrap leaves the realm's value alone, so a new realm keeps Keycloak's default. Nothing is reset |
| anything else | replaces the declared value, typed like the file: true/false, a non-negative integer, a string, or comma-separated locales |
Every check still runs on the result: a theme must be listed by the server, locales must
contain defaultLocale. A CELINE_KEYCLOAK_PLATFORM_* variable naming a key that is not
listed is refused: the features other commands require, internationalizationEnabled and the
username and email rules stay the image's.
A stock Keycloak, which ships no rec theme:
CELINE_KEYCLOAK_PLATFORM_LOGIN_THEME: "null"
CELINE_KEYCLOAK_PLATFORM_EMAIL_THEME: "null"
SMTP
| Variable | Default | |
|---|---|---|
CELINE_KEYCLOAK_SMTP_HOST |
— | Unset or empty: smtpServer is not touched |
CELINE_KEYCLOAK_SMTP_PORT |
587 |
|
CELINE_KEYCLOAK_SMTP_FROM |
— | Required with a host |
CELINE_KEYCLOAK_SMTP_FROM_DISPLAY_NAME, CELINE_KEYCLOAK_SMTP_REPLY_TO |
— | |
CELINE_KEYCLOAK_SMTP_SSL, CELINE_KEYCLOAK_SMTP_STARTTLS |
false |
|
CELINE_KEYCLOAK_SMTP_AUTH |
true when a user is set | |
CELINE_KEYCLOAK_SMTP_USER, CELINE_KEYCLOAK_SMTP_PASSWORD |
— | Both required with authentication. Pass the password from a secret |
Keycloak never returns the stored SMTP password, so bootstrap cannot compare it. Every other
field is diffed. The password is sent on every run and reported as
smtpServer.password: write-only, which is not a change: a second run still reports
no change. bootstrap never prints the password, in any environment.
Locally
mailpit keeps every message in its UI at http://localhost:8025 and delivers none.
The dev import (config/keycloak/import/realm-celine.json) points smtpServer at
mailpit:1025, which reaches a realm created from it only. On an existing local realm,
let bootstrap set it (the host is as Keycloak's container sees it):
CELINE_KEYCLOAK_SMTP_HOST=mailpit CELINE_KEYCLOAK_SMTP_PORT=1025 \
CELINE_KEYCLOAK_SMTP_FROM=noreply@celine.localhost CELINE_KEYCLOAK_SMTP_FROM_DISPLAY_NAME=CELINE \
task keycloak:bootstrap
Other workspace services can send through it too: SMTP is published on the host's 1025, so
a container uses 172.17.0.1:1025 (the same host-gateway convention as the registry URL) and
a process on the host uses localhost:1025.
To let real mail reach a few addresses, set both EMAIL_DEV_RECIPIENTS (comma-separated) and
MAILPIT_RELAY_HOST (with MAILPIT_RELAY_PORT, _USERNAME, _PASSWORD as needed). Mailpit
then relays only those addresses, through an anchored, escaped match built by
config/mailpit/start.sh, and the provisioning service invites only those addresses.
Everyone else gets a WARNING in the service log and invitation: not_on_dev_list. An
account with no email address is no_email in every mode, not not_on_dev_list.
Outside dev, set CELINE_PROVISIONING_EMAIL_MODE=deliver and
CELINE_PROVISIONING_INVITE_REDIRECT_URI to the webapp root.
Dockerfile
The MQTT auth service image (Dockerfile) is a multi-stage build:
- Builder — installs
uv, syncs dependencies, installs the package - Runtime — copies
.venv,src/,policies/, andclients.yaml; runs as non-root user (app:1000)
EXPOSE 8009
CMD ["uvicorn", "celine.mqtt_auth.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8009"]
Configuration Reference
MQTT Auth Service
Environment variables with CELINE_ prefix:
| Variable | Default | Description |
|---|---|---|
CELINE_ENV |
unset (hardened) | Posture signal (celine.sdk.posture, then ENVIRONMENT); only dev relaxes |
CELINE_OIDC_AUDIENCE |
unset | Audience every MQTT token must carry. Required outside dev (REQ-0009); enforced whenever set |
CELINE_OIDC_BASE_URL, CELINE_OIDC_JWKS_URI |
local Keycloak (SDK) | Issuer and JWKS; the SDK default is refused outside dev |
CELINE_OIDC_* |
(from celine-sdk) | Other OIDC/JWT validation settings |
CELINE_POLICIES_DIR |
./policies |
Rego policy directory |
CELINE_POLICIES_DATA_DIR |
None |
Policy data JSON directory |
CELINE_POLICIES_CACHE_ENABLED |
true |
Decision cache on/off |
CELINE_POLICIES_CACHE_TTL |
300 |
Cache TTL (seconds) |
CELINE_POLICIES_CACHE_MAXSIZE |
10000 |
Max cached decisions |
CELINE_MQTT_POLICY_PACKAGE |
celine.mqtt.acl |
Rego package for ACL |
CELINE_LOG_LEVEL |
INFO |
Log level. The celine.audit logger stays at INFO whatever it says |
CELINE_PUBLIC_DOCS |
unset | true serves /docs, /redoc and /openapi.json outside dev (REQ-0018); the provisioning service reads it too |
Outside CELINE_ENV=dev the service refuses to start without an audience or on the SDK's
OIDC defaults, and serves no API docs. It installs no CORS middleware in any environment (its
only callers are mosquitto-go-auth and health probes). A refused /user or /acl is one JSON
record on the celine.audit logger naming the caller by sub and client id; an allowed ACL
check is not audited (REQ-0019). No MQTT audience exists in the realm yet — see .env.example — so a
hardened deployment needs an audience mapper onto one MQTT audience for every broker client
first. The image needs celine-sdk 2.0.0 or later, the first release with celine.sdk.posture.
/superuser answers 403 to every token (REQ-0014): there is no MQTT superuser, whatever
auth_opt_disable_superuser says. User or service is the token's kind (is_service_account);
no group of either level reaches the policy, so a person's token reaches no topic.
CELINE_MQTT_SUPERUSER_SCOPE is gone.
Keycloak CLI
Environment variables with CELINE_KEYCLOAK_ prefix:
| Variable | Default | Description |
|---|---|---|
CELINE_KEYCLOAK_BASE_URL |
http://keycloak.celine.localhost |
Keycloak URL |
CELINE_KEYCLOAK_REALM |
celine |
Target realm. Outranks realm: in clients.yaml, and is outranked by --realm |
CELINE_KEYCLOAK_ADMIN_USER |
— | Admin username |
CELINE_KEYCLOAK_ADMIN_PASSWORD |
— | Admin password |
CELINE_KEYCLOAK_ADMIN_CLIENT_ID |
celine-admin-cli |
Service client ID |
CELINE_KEYCLOAK_ADMIN_CLIENT_SECRET |
— | Service client secret |
CELINE_KEYCLOAK_SECRETS_FILE |
.client.secrets.yaml |
Secrets file path. Set, bootstrap records the admin CLI client's secret and sync the secrets it applied there (mode 0600); unset, neither writes a file unless ENV=dev (REQ-0020) |
CELINE_KEYCLOAK_BOOTSTRAP_CLIENT_ID |
svc-celine-policies-bootstrap |
The master client bootstrap signs in to master with (REQ-0016) |
CELINE_KEYCLOAK_BOOTSTRAP_CLIENT_SECRET |
— | Its secret, from the deployment's secrets; required unless ENV=dev, at least 32 characters |
CELINE_KEYCLOAK_BRUTE_FORCE_ENABLED |
on unless ENV=dev |
Brute-force detection, platform realm and master |
CELINE_KEYCLOAK_ADMIN_MFA_REQUIRED |
on unless ENV=dev |
The admin second factor, platform realm (platform-admin) and master (admin) |
Sync Users
Environment variables with CELINE_SYNC_USERS_ prefix:
| Variable | Default | Description |
|---|---|---|
CELINE_SYNC_USERS_REC_YAML |
— | Path to REC YAML |
CELINE_SYNC_USERS_GROUPS |
(empty) | Space-separated group paths |
CELINE_SYNC_USERS_TEMP_PASSWORD |
(random) | Fixed password for all users |
CELINE_SYNC_USERS_TEMPORARY |
true |
Force password reset on first login |
CELINE_SYNC_USERS_DRY_RUN |
false |
Preview mode |
CELINE_SYNC_USERS_REGISTRY_URL |
— | rec-registry base URL. Setting it selects the live registry instead of a file |
CELINE_SYNC_USERS_REGISTRY_CLIENT_ID |
celine-cli |
Keycloak client the registry is read as |
CELINE_SYNC_USERS_REGISTRY_CLIENT_SECRET |
— | Its secret. Falls back to that client's entry in the secrets file |
CELINE_SYNC_USERS_COMMUNITIES |
(empty) | Space-separated community keys. Empty → every community |
Which source a run reconciles
A file is a picture of the community at export time, so a run against one
leaves out everybody onboarding has approved since it was taken. A run against
the registry reconciles what is true. Both are supported for local
development: a developer's realm is set up before the registry holds anything,
and an offline run is a real case there.
sync-users runs only with ENV=dev (exactly dev: the guard seed-dev-users
uses, KeycloakSettings.is_production). Unset, prod, staging, development,
local, test, ci or any other value, it exits 1 before it reads a source or asks Keycloak anything,
--dry-run and --check included, and says to set ENV=dev if the realm is a
development one. CELINE_KEYCLOAK_ENV and CELINE_ENV outrank ENV.
On a deployed realm sync-users writes nothing, from either source. Members
arrive through onboarding, and a community's organization, org roles and org
groups come from the provisioning service's POST /reconcile/{community}, which
onboarding calls; a manager's managers group is added with
keycloak set-user-organization. See
ADR-0010.
# the file path
ENV=dev celine-policies keycloak sync-users example-rec.yaml
# the live registry, every community it holds
ENV=dev celine-policies keycloak sync-users --from-registry \
--registry-url http://api.celine.localhost/rec-registry
# one community, and report divergence without writing anything
ENV=dev celine-policies keycloak sync-users --from-registry \
--registry-url http://api.celine.localhost/rec-registry \
--community example-renewable-community --check
Passing both a path and --from-registry is refused rather than resolved.
--check writes nothing and exits non-zero when a member of the source has no
Keycloak account, is outside their REC organization, or is outside a group
clients.yaml declares under admin_permissions. It exists because none of
those is visible to a health probe: the failure they produce is a person who logs
in and cannot see their own community.
Only active members are provisioned. The registry keys a member pending
before approval and suspended or inactive after withdrawal; those are skipped
with a log line. A member carrying no status at all is treated as active, which
is what keeps hand-authored seed files working. Nothing is ever disabled —
skipping provisioning and revoking access are different acts.
The registry client needs rec-registry.export. The default, celine-cli, holds
rec-registry.admin, which is wider than that: see
ADR-0006 for why,
and what to change to narrow it.
Member mirror
keycloak mirror-members (REQ-0021,
ADR-0015) runs on an
intermediate environment (staging, preprod) as a scheduled job, pulling from prod. It
refuses ENV prod, production or unset, and a target Keycloak (CELINE_KEYCLOAK_BASE_URL)
whose host is the source issuer's. It writes the target with the target realm's admin client
(CELINE_KEYCLOAK_ADMIN_CLIENT_ID / _SECRET, as sync).
| Variable | Flag | Default | Description |
|---|---|---|---|
CELINE_MEMBER_MIRROR_SOURCE_URL |
--source-url |
— | Prod's registry base URL; the job calls <url>/admin/export/mirror |
CELINE_MEMBER_MIRROR_SOURCE_ISSUER |
--source-issuer |
— | Required. Prod's realm issuer URL: mints the token, is stored on /member-mirrors, and is the host the target must not be |
CELINE_MEMBER_MIRROR_SOURCE_CLIENT_ID |
--source-client-id |
svc-member-mirror |
Prod realm client holding rec-registry.mirror |
CELINE_MEMBER_MIRROR_SOURCE_CLIENT_SECRET |
— | — | Its secret in prod's realm; required with a registry source |
CELINE_MEMBER_MIRROR_FILE |
--from-file |
— | Read the export from a JSON file instead (tests, offline). Exclusive with the URL |
CELINE_MEMBER_MIRROR_IMPERSONATORS |
--impersonator (repeatable) |
— | Space-separated usernames of the target realm that may impersonate the mirrors. Unset: the permission is left as it is |
| — | --dry-run |
off | Count what would change, write nothing |
Exit codes: 0 converged; 1 refused or failed (nothing written when refused); 2 ran, but
a member could not be mirrored (collision with an account the mirror did not make, a username
held by another mirror, a changed key, a contradiction in the export, a community without an
organization, an impersonator not found). The output is counts only.
ENV=staging celine-policies keycloak mirror-members --dry-run \
--source-url https://api.prod.example.org/rec-registry \
--source-issuer https://keycloak.prod.example.org/realms/celine
Keycloak
The Keycloak service uses the custom image from keycloak/Dockerfile (KC 26.7.3 + rec theme). Key environment variables:
| Variable | Value | Description |
|---|---|---|
KC_DB |
dev-file |
Dev-mode file-based DB |
KC_BOOTSTRAP_ADMIN_USERNAME |
admin |
Initial admin username |
KC_BOOTSTRAP_ADMIN_PASSWORD |
admin |
Initial admin password |
KC_HOSTNAME |
keycloak.celine.localhost |
Public hostname |
KC_HTTP_PORT |
8080 |
HTTP port |
A realm import file at config/keycloak/import/realm-celine.json seeds the celine realm on first startup.
Mosquitto
Configuration at config/mosquitto/mosquitto.conf. Key settings:
- JWT backend via mosquitto-go-auth, pointing to
host.docker.internal:8009 - Listeners on port 1883 (MQTT) and 1884 (WebSocket)
- Redis caching available but disabled by default
- Superuser check disabled (
auth_opt_disable_superuser true) - Anonymous access disabled
OAuth2 Proxy
Configuration at config/oauth2-proxy/oauth2-proxy.cfg. Runs on port 4180.
Keycloak Custom Image
The keycloak/ directory builds a custom Keycloak image:
- Base:
quay.io/keycloak/keycloak:26.7.3 - Adds the
reclogin and email themes (seekeycloak/README.md) - Pre-builds Keycloak at image build time for faster startup
- Version tracked in
keycloak/version.txt(26.7.3-1.1.0)
A GitHub Actions workflow (.github/workflows/build-keycloak.yaml) detects changes to keycloak/version.txt and publishes an updated image.
CI/CD
Workflows
| Workflow | Trigger | Description |
|---|---|---|
release.yaml |
Push to main / tags | Release Docker images |
build-keycloak.yaml |
Changes to keycloak/version.txt |
Build and publish custom Keycloak image |
Semantic Release
The project uses python-semantic-release for versioning:
task release
# Runs: uv run semantic-release version --no-vcs-release && git push && git push --tags
Commit messages follow conventional commits (feat:, fix:, chore:).
Skaffold
A skaffold.yaml is available for Kubernetes development workflows.