Customizing a Deployment¶
Most Klangk customization is done at runtime via environment variables and bind mounts — no image rebuild required. The stock klangk-host image supports branding, legal links, email templates, CA certificates, and OIDC login hooks out of the box.
The only reason to build a custom host image is features (Dart UI features require a Flutter web rebuild; TypeScript workspace features require a workspace image rebuild). See Building a Custom Image (Features) below.
The customize/ directory in the Klangk repo provides a working example: a docker-compose.yml showcasing runtime configuration, and example runtime-customization files under custom/. Copy it and adapt to your needs.
Runtime Customization¶
Customization Directory¶
Set KLANGKD_CUSTOMIZE_DIR to a single directory containing all your customization files. Klangk looks for well-known subdirectories under this path:
<KLANGKD_CUSTOMIZE_DIR>/
certs/ ← CA .pem/.crt files (custom CA certificates)
branding/ ← logos and other static assets served at /branding
email-templates/ ← Jinja2 email template overrides
If a subdirectory doesn't exist, that subsystem simply isn't customized — no error, no special handling needed. Deployers only populate the subdirs they care about.
Default: <config_dir>/custom (→ ~/.config/klangkd/custom; KLANGKD_CONFIG_DIR
relocates the whole tree). The published host container sets it to
/home/klangk/custom.
docker run -d \
-v ./my-customization:/home/klangk/custom:ro \
-e KLANGKD_CUSTOMIZE_DIR=/home/klangk/custom \
...
One env var and one -v mount replaces three. KLANGKD_EMAIL_TEMPLATES_DIR still works as an override but is deprecated — prefer the unified directory. KLANGKD_BRANDING_DIR and KLANGKD_SSL_CERT_DIR have been removed; branding resolves from <KLANGKD_CUSTOMIZE_DIR>/branding/ (or <KLANGKD_DATA_DIR>/branding/) and custom CA certs from <KLANGKD_CUSTOMIZE_DIR>/certs/.
Product Name¶
Set KLANGKD_PRODUCT_NAME to rename the product across the browser tab title, the app-bar logo wordmark, and all outgoing emails. Defaults to Klangk. Supports the file:/cmd: prefix.
The value is published to the frontend through GET /api/v1/config (product_name field) and interpolated into email subjects and bodies server-side.
Logo¶
Set KLANGKD_LOGO_URL to an absolute image URL. The value is published to the UI through the unauthenticated /api/v1/config endpoint (logo_url), so it renders on the login page before login. Supports file:/cmd: secret resolution.
To serve a local file without a CDN, drop your logo into <KLANGKD_CUSTOMIZE_DIR>/branding/ and set KLANGKD_LOGO_URL=/branding/logo.png. Both steps are needed: placing the file makes it servable (Klangk mounts the branding directory at /branding/), while KLANGKD_LOGO_URL tells the frontend which image to render — this could equally be an external CDN URL like https://cdn.example.com/logo.png. When <KLANGKD_CUSTOMIZE_DIR>/branding/ doesn't exist, branding falls back to <KLANGKD_DATA_DIR>/branding/.
When unset (or if the image fails to load), the default KlangkLogo widget is rendered. The logo also flows into email headers when emails are rendered through the templating system.
Legal & Support Links¶
These env vars add links to the login/registration screens and email footers. All are plain values (no file:/cmd: resolution — they are public, shown pre-auth). Empty hides them.
| Variable | Description |
|---|---|
KLANGKD_TERMS_URL |
Terms of Service link |
KLANGKD_PRIVACY_URL |
Privacy Policy link |
KLANGKD_AUP_URL |
Acceptable Use Policy link |
KLANGKD_SUPPORT_URL |
Support/help link (app bar + auth screens) |
KLANGKD_SUPPORT_EMAIL |
Support email (mailto: fallback when KLANGKD_SUPPORT_URL is unset) |
Email Templating¶
Outgoing auth emails (registration verification, password reset, invitation) are rendered from Jinja2 templates. Place your template overrides in <KLANGKD_CUSTOMIZE_DIR>/email-templates/.
Two approaches:
- Copy the whole tree, then edit. The built-in templates live at
src/klangk/klangk/email_templates/in the source. Copy, delete__init__.py, edit, and mount. - Drop only the files you change. Absent files fall through to the built-ins.
Overrides resolve per-file: a deployer file shadows the built-in at the same path, and {% extends %}/{% include %} resolve your overrides first. Override just base.html to re-brand all emails at once.
Keep the
.htmlextension on HTML templates (not.html.j2). Klangk enables Jinja autoescaping by filename.
Template Variables¶
Global (every email): product_name (KLANGKD_PRODUCT_NAME), logo_url (KLANGKD_LOGO_URL), brand_color (KLANGKD_BRAND_COLOR, default #E65100).
Per-email: link (the verification/reset/invite URL), expiry_hours (real token TTL), and invited_by (invitation only).
Tokens never appear in the subject line — subjects receive only the global branding variables, never the link.
Other Email Knobs¶
KLANGKD_SMTP_REPLY_TO— adds aReply-Toheader to every outgoing message. Unset means no header.- Footer / legal line —
base.htmlexposes an empty{% block legal %}for a compliance footer.
Example¶
docker run -d \
-e KLANGKD_PRODUCT_NAME="Acme Labs" \
-e KLANGKD_LOGO_URL="/branding/logo.png" \
-e KLANGKD_SMTP_REPLY_TO="support@acme.example.com" \
-e KLANGKD_CUSTOMIZE_DIR=/home/klangk/custom \
-v ./my-customization:/home/klangk/custom:ro \
...
Deprecated:
KLANGKD_EMAIL_TEMPLATES_DIRstill works as an override but prefer using<KLANGKD_CUSTOMIZE_DIR>/email-templates/instead.
Consent Banner¶
A login/consent banner lets you require acknowledgement of an acceptable-use notice (or any policy text) before a user can access the app.
Set the banner text (and an optional title) via env vars or the klangkd config file branding keys:
docker run -d \
-e KLANGKD_LOGIN_BANNER_TITLE="Notice" \
-e KLANGKD_LOGIN_BANNER="You must accept the terms to continue." \
...
When set, the banner blocks all access until the user clicks I Accept.
By default the acceptance is cached permanently against the banner text hash — once accepted, the same banner text won't re-prompt the user (even on later visits). To change the wording, operators edit the banner text so the hash flips.
Require acceptance on every visit. For regulated deployments that need a per-session acknowledgement (e.g. a legal notice that must be re-accepted on each fresh app load / login), set:
or in the config file:
When true, acceptance is held for the session only (in-memory) — the
banner re-appears on every app restart / re-login until I Accept is
clicked that session. It does not re-appear on in-app route changes within
the same session. When false (default), behavior is unchanged (permanent
hash-based acceptance).
Custom CA Certificates¶
Place your .pem/.crt CA certificate files in <KLANGKD_CUSTOMIZE_DIR>/certs/ and restart workspaces (or the backend). Klangk makes those CAs trusted at startup without rebuilding any image:
- Workspace containers — the directory is bind-mounted read-only into each container, and the entrypoint builds a merged CA bundle (system CAs plus your custom certs) on the writable
/tmptmpfs. The toolchain trust env vars (SSL_CERT_FILE,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,NODE_EXTRA_CA_CERTS) are set to point at it, so OpenSSL, Python,curl, and Node all honor your CAs. The bundle is merged with system CAs so public-internet TLS keeps working. - Backend process — at startup the backend concatenates your certs with its own system bundle into
<KLANGKD_STATE_DIR>/ssl/ca-bundle.crtand sets the same trust env vars, so outbound TLS (OIDC discovery, SMTP relay, LLM proxy) trusts your private CAs too.
# Using KLANGKD_CUSTOMIZE_DIR (recommended):
docker run -d \
-e KLANGKD_CUSTOMIZE_DIR=/home/klangk/custom \
-v ./my-customization:/home/klangk/custom:ro \
...
# Place your .pem/.crt files in my-customization/certs/
Rotating a cert is just a file change plus a workspace/backend restart — no image rebuild.
Why a merged bundle?
SSL_CERT_FILE/REQUESTS_CA_BUNDLE/CURL_CA_BUNDLEreplace the default trust store rather than add to it. Klangk therefore prepends the system CAs before your custom certs. (NODE_EXTRA_CA_CERTSis additive, but pointing it at the same merged bundle is harmless.)
Restricting trusted CAs to an approved baseline¶
Deployments that must trust only a defined CA set (e.g. a DoD-approved CA baseline) can enforce one with KLANGKD_TRUSTED_CA_DIR (#3198). Point it at an operator-managed directory holding the approved CA certs — those become the only custom CAs trusted:
- Only CAs whose SHA-256 fingerprint appears among the
.pem/.crtcerts in the directory are trusted — by the backend's outbound TLS and every workspace container. klangkd stages the approved certs (one canonical PEM per unique parseable cert) under<KLANGKD_STATE_DIR>/ssl/approved; the staged copy — not the raw directories — is what the backend bundle and the workspace-container mounts consume, so a file the strict certificate parser rejects is never trusted even if a lenient consumer might accept its raw bytes. - Every cert dropped into
<KLANGKD_CUSTOMIZE_DIR>/certsis audited against the baseline on each resolution: approved ones log at debug (subject/issuer), non-approved or unparseable ones are refused with a warning and never reach a trust bundle or a container. - A missing, unreadable, or cert-less baseline directory fails closed — no custom CAs are trusted at all, and an error is logged. Backend trust applied earlier in the process is revoked on reload (the trust env vars klangkd set are cleared, the stale bundle and the staged cert dir are removed), so fail-closed holds across SIGHUP reloads, not just at boot. Already-running child processes that inherited the vars lose the bundle file and fail their outbound TLS — that is the fail-closed guarantee.
docker run -d \
-e KLANGKD_TRUSTED_CA_DIR=/etc/klangk/approved-cas \
-v ./dod-approved-cas:/etc/klangk/approved-cas:ro \
...
With the setting unset (the default), behavior is unchanged: every cert in <KLANGKD_CUSTOMIZE_DIR>/certs is trusted. The setting is read live on every resolution — reloadable on SIGHUP (the backend re-applies — or revokes — its trust bundle; containers started after the change pick up the new baseline).
OIDC Login Hook¶
The customize/custom/oidc/ directory includes a sample login_hook.py that restricts OIDC logins to invited users. Bind-mount it anywhere in the container and point the env var at it:
docker run -d \
-v ./oidc/login_hook.py:/etc/klangk/login_hook.py:ro \
-e KLANGKD_OIDC_LOGIN_HOOK=/etc/klangk/login_hook.py \
...
The file is loaded directly by path — it does not need to be on PYTHONPATH. No image rebuild is needed. To call a function other than the default on_login, append :func_name to the path.
The example hook's logic:
- Most recent invitation is revoked → login is blocked (even with an existing account)
- Most recent invitation is pending or accepted → login is allowed
- No invitation but has an existing account → login is allowed
- No invitation and no account → login is blocked
Re-inviting after a revocation creates a new pending invitation that overrides the revocation.
Workspace-Created Hook¶
A deployment-local Python callback that runs after every workspace is created — on all three creation paths: a fresh POST /workspaces, an import, and a duplicate — after the workspace row, the owner ACE, and the four role groups are committed, and before the create response is returned. It can mutate the new workspace and rewrite its ACL, which is the supported way to change the seeded permission posture without forking the model layer.
The customize/custom/hooks/ directory includes a sample workspace_created.py. Bind-mount it anywhere in the container and point the env var at it:
docker run -d \
-v ./hooks/workspace_created.py:/etc/klangk/workspace_created.py:ro \
-e KLANGKD_WORKSPACE_CREATED_HOOK=/etc/klangk/workspace_created.py \
...
Like the login hook, the file is loaded directly by path (no PYTHONPATH entry, no image rebuild), supports :func_name (default on_workspace_created), and is re-loaded on SIGHUP reconfigure.
The hook receives two arguments:
workspace— a deep copy of the new workspace's row dict (assign keys to mutate attributes:workspace["egress_mode"] = "static";name,image,mounts,env,allowed_domains,rejected_domains,settings,per_handle_home, … all work; nested edits likeworkspace["env"]["K"] = "v"are detected too, and deleting a key clears the column). Edits are persisted after the hook returns, with the same validation the create API applies — the settings-bag schema, the image allowlist, the mount-spec policy, the domain-list grammar, and theegress_mode/setup_state/per_handle_homeenums. An invalid edit is logged and dropped, the create still succeeds. It also carries two async ACL helpers:entries = await workspace.acl_entries()— the workspace's ACL, resolved likeGET /api/v1/workspaces/{id}/acl(each entry'sprincipalis the group name, user email, orEveryone/Authenticated);await workspace.rewrite_acl(entries)— replaces the ACL wholesale; filter/edit/extend the list fromacl_entries()and hand it back. The list order becomes the ACL order, so add/remove/reorder are all the same operation. Keep an entry granting the owner access, or every new workspace starts fully locked out.actor— the creating user's row dict (actor["id"],actor["email"], …).
Both sync and async hook functions are supported. ACL rewrites require the async def form (calling the awaitable helpers from a sync hook raises a clear error — it never silently no-ops).
Failure semantics: log-and-continue. The hook is a mutation extension point, not a gate — if it raises, the workspace still exists and the create response is returned normally. Errors are logged as a WARNING with the hook source, workspace id, and the exception, so partial effects stay visible. A missing hook file or a missing/uncallable on_workspace_created export is a startup failure (same as a broken login hook).
The sample hook's example: force every new workspace to egress_mode: static, and keep the coders/collaborators role groups' files grant while dropping files-download and files-write for a browse-only posture (listings and metadata stay visible; no file body can be read or downloaded). Adapt it to your deployment's stance.
OIDC Authentication¶
To enable OIDC login, create an oidc.yaml and mount it at runtime. The customize/custom/oidc/oidc.yaml template has the schema and placeholder values:
docker run -d \
-v ./oidc/oidc.yaml:/home/klangk/oidc/oidc.yaml:ro \
-e KLANGKD_OIDC_CONFIG=/home/klangk/oidc/oidc.yaml \
-e KLANGKD_AUTH_MODES=both \
...
See the OIDC documentation for the config file format.
Deployment profiles (auth modes)¶
KLANGKD_AUTH_MODES selects the deployment profile — same binary, different
config. The published host image uses password as its supported mode
(oidc and both are also supported). The none (no-login local-dev)
profile is unsupported with the published Docker host image — see the
note below.
See Auth Modes for the full local-dev / customer-locked / team mapping.
nonemode is unsupported in the Docker host image.noneis loopback-only by design (it freely issues an admin token with no password, so its security model is "only the operator's loopback can reach it"), and adocker run -ppublished port isn't loopback. The bind-safety gate refuses to bootnoneon a non-loopback bind, and even withKLANGKD_ALLOW_INSECURE_NO_AUTH=1the proxy/auth/localACL still denies the port-forwarded request with403(the request appears at the container as the Docker bridge IP, not127.0.0.1). The image therefore runsKLANGKD_AUTH_MODES=password(oroidc/both) — setKLANGKD_DEFAULT_PASSWORDaccordingly. For a no-login single-user experience, run klangk locally (devenv, or the bare binary on your own machine), wherenoneworks out of the box.
Building a Custom Image (Features)¶
A custom image build is needed only for features. If you don't need features, use the stock klangk-host image with the runtime customization above.
The feature declaration list is the checked-in features.yaml at the repository root — the build-time source of truth. To ship a different feature set than stock, fork the repo and edit that file directly (the same model package.json/Cargo.toml/go.mod use). There is no separate overlay or build script to maintain.
Prerequisites¶
Fork-and-build workflow¶
# 1. Fork klangk on GitHub, clone your fork.
git clone https://github.com/<your-org>/klangk.git
cd klangk
# 2. Add feature source trees under features/<name>/ (for local features) and
# declare every feature you want compiled in via the checked-in features.yaml
# at the repo root — same format (local `path:` or remote `git:`/`ref:`
# entries).
$EDITOR features.yaml
# 3. Build the host image from source (inside the devenv shell — the
# wrapped `build-host-image` script is on PATH there).
devenv shell -- build-host-image
# Tag the build with a variant identity (surfaced in version.json + debug pane).
# NOTE: KLANGKBUILD_VARIANT is captured at devenv-shell entry (generate-version.sh
# runs in enterShell), so set it *before* `devenv shell`:
KLANGKBUILD_VARIANT="Acme 1.0.0" devenv shell -- build-host-image
# Publish elsewhere than the default local tag (klangk-host):
KLANGKBUILD_HOST_IMAGE=ghcr.io/<your-org>/klangk-host devenv shell -- build-host-image
To pull upstream klangk improvements into your custom build, git pull upstream main (or origin main, depending on how you cloned) from the fork — the feature declaration and feature trees merge like any other source file.
Features¶
Edit the checked-in features.yaml to add or remove features. The default build compiles in the built-in features declared there: celebrate, beep, bobdobbs, word-count, browser-fetch, boingball, git-credential, soliplex (word-count, soliplex, and boingball ship compiled-in but dormant — activate with KLANGKD_FEATURES_ENABLE).
To add an external feature:
How the Build Works¶
scripts/build-host-image.sh is a single source build: it embeds the Flutter web build, the workspace tarball, and the feature directories declared in features.yaml — so one build produces the final image with features baked in. There is no separate overlay, Dockerfile, or base-image pass. Run it via the devenv-wrapped build-host-image script (devenv shell -- build-host-image); KLANGKBUILD_VARIANT is captured by scripts/generate-version.sh in devenv's enterShell hook, so set it (and KLANGKBUILD_HOST_IMAGE) before entering the shell, not on the build command.
Build Options¶
The build reads these from the environment (KLANGKBUILD_HOST_IMAGE / KLANGKBUILD_PLATFORM by scripts/build-host-image.sh; KLANGKBUILD_VARIANT by scripts/generate-version.sh during the build):
| Variable | Default | Description |
|---|---|---|
KLANGKBUILD_HOST_IMAGE |
klangk-host |
Output image name — a local tag by default; override with a full registry path to publish |
KLANGKBUILD_VARIANT |
unset | Build identity string written to version.json (see Build Variant) |
KLANGKBUILD_PLATFORM |
linux/amd64 |
Target platform |
Build Variant¶
KLANGKBUILD_VARIANT stamps a product-identity string into the built image's
version.json. It is surfaced in three places:
GET /api/v1/version— avariantfield (betweenversionandcommit)- The debug pane — a "Variant" row (shown only when the field is present)
version.jsonon disk — the source of truth, written byscripts/generate-version.shat build time
It is independent of the upstream klangk version — version always reports
the klangk release (tag/branch/SHA), while variant names this downstream
build. Set it to your product name and release, e.g. "Acme 1.0.0":
# KLANGKBUILD_VARIANT is read at devenv-shell entry, so set it before `devenv shell`:
KLANGKBUILD_VARIANT="Acme 1.0.0" devenv shell -- build-host-image
When empty (or unset), the variant field is omitted entirely from
version.json and the API/debug output — so a fork that doesn't set it is
byte-identical to upstream. Set it only if you want to distinguish your build.
The variant is a single free-form string (e.g.
"Acme 1.0.0"). A split into separate name + version fields is a non-goal for now — keep them together in one human-readable string.
Running¶
Use the stock image with runtime customization (no features):
docker run -d \
-p 8997:8997 \
-v ./data:/home/klangk/data \
-v ./mount:/home/klangk/mount \
--cap-add SYS_ADMIN \
--device /dev/fuse \
--device /dev/net/tun \
--security-opt seccomp=unconfined \
--security-opt systempaths=unconfined \
-e KLANGKD_EGRESS_PORT=8995 \
-e KLANGKD_PORT=8997 \
-e KLANGKD_DEFAULT_USER=admin@example.com \
-e KLANGKD_DEFAULT_PASSWORD=changeme \
-e KLANGKD_JWT_SECRET=change-this-to-a-random-secret \
-e KLANGKD_PREVENT_INSECURE_JWT_SECRET=1 \
-e KLANGKD_DATA_DIR=/home/klangk/data \
-e KLANGKD_PRODUCT_NAME="Acme Labs" \
-e KLANGKD_LOGO_URL="/branding/logo.png" \
-e KLANGKD_LLM_MODELS="openai/gpt-4o::your-api-key" \
ghcr.io/mcdonc/klangk/klangk-host:latest
Or use docker-compose.yml — see the example in customize/docker-compose.yml.