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, 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 - 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 role groups, 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 dev, development, local, test or ci |
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 is then kept inCELINE_KEYCLOAK_REALM_ADMIN_GROUP(/admins). Its password is never re-sent. 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.
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_OIDC_* |
(from celine-sdk) | OIDC/JWT validation |
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_MQTT_SUPERUSER_SCOPE |
mqtt.admin |
Superuser scope name |
CELINE_LOG_LEVEL |
INFO |
Log level |
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 |
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 and neither is
deprecated: bootstrap runs before the registry holds anything, and an offline run
is a real case.
# the file path, unchanged
celine-policies keycloak sync-users example-rec.yaml
# the live registry, every community it holds
celine-policies keycloak sync-users --from-registry \
--registry-url http://api.celine.localhost/rec-registry
# one community, and report divergence without writing anything
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.
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.