Skip to main content

Environment variables

This is the canonical list of every environment variable KilnCMS reads, with a pointer to the line that reads each one. It is long because the surface is genuinely large — but almost none of it is something you have to decide.

Read it in this order:

  1. Required (3) — the only variables without which a production release refuses to start. There are three.
  2. Common (10) — not required, but what a real deployment usually sets. A short list, with a one-line reason for each.
  3. Everything else — the other 147, grouped by the feature they configure. Every one is optional and has a default, so you can skip a whole section by not using the feature it configures.

Variables are read at boot by config/runtime.exs, which executes for releases after compilation and before the system starts. That file is an index: the configuration itself lives in per-concern fragments under config/runtime/, and the "Where it's read" column points into whichever one holds the read.

Scope. Most variables only take effect when config_env() == :prod (i.e. in a mix release / MIX_ENV=prod build). In dev and test, sensible defaults from config/dev.exs and config/test.exs are used instead, so you do not need to set these locally. The exceptions — read in every environment, because they sit outside the config_env() == :prod fragments — are:

PHX_SERVER, PORT, CSP_IMG_SRC, UNSPLASH_ACCESS_KEY, CORS_ORIGINS, KILN_READING_TIME_WPM, VISUAL_EDITING_ENABLED, the KILN_API_CACHE* and KILN_CDN_PURGE_* groups, KILN_LINK_CHECK_CRON / KILN_LINK_CHECK_USER_AGENT, KILN_TASK_DIGEST_CRON, KILN_OBAN_RESCUE_AFTER_MINUTES, KILN_OCCURRENCE_SWEEP_CRON / KILN_OCCURRENCE_BACKFILL_ON_BOOT, PRESENTATION_PREVIEW_URL, the KILN_UPDATE_* group (plus KILN_PIN_PATH), KILN_ANALYTICS_REFERRERS, KILN_ANALYTICS_LOW_COUNT_THRESHOLD, SENTRY_DSN / SENTRY_ENV / RELEASE_VSN, and the OTEL_* group.

Several more are read in every environment except :test, where they are skipped so the suite cannot depend on a developer's exported shell: KILN_ENV_LABEL / KILN_ENV_COLOR, EMBED_ORIGINS / EMBED_ORIGINS_LOCKED, KILN_AUDIT_ANCHORS_ENABLED, KILN_AUDIT_ANCHOR_EVERY_WRITE, the KILN_GOVERNANCE_WITNESS* group (plus KILN_GOVERNANCE_CHECKPOINT_CRON), and the KILN_PROVENANCE_* group, and the KILN_DEMO_* group. MIX_TEST_PARTITION and KILN_STRICT_TEST are the reverse — test-only. KILN_ML is neither: it is read at build time, by mix.exs itself.

Required (3)

Exactly three variables stop a production boot. Nothing else in this document does: every other variable has a default, and a release that sets only these three starts and serves.

VariablePurposeWhere it's read
DATABASE_URLPostgres connection string, e.g. ecto://USER:PASS@HOST/DATABASE. Raises if missing.config/runtime/prod/database.exs:11
SECRET_KEY_BASESigns/encrypts session cookies and other secrets. Generate with mix phx.gen.secret. Raises if missing.config/runtime/prod/web.exs:16
TOKEN_SIGNING_SECRETSigns authentication tokens (AshAuthentication). Raises if missing.config/runtime/prod/web.exs:247

Those three are derived from the code rather than maintained by hand: they are the only raises a :prod evaluation of the runtime config reaches with nothing else set, and a test pins that. A handful more raise once you opt into the feature they belong to, which is a different promise: setting S3_BUCKET without S3_PUBLIC_BASE_URL, AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY raises, as does MAIL_MODE=smtp without SMTP_HOST, MAIL_MODE=direct without MAIL_FROM_EMAIL, and any MAIL_MODE that is neither. Leave the feature alone and none of them applies.

Common (10)

Not required — a release boots without every one of these — but these are the ones a real deployment usually sets, and the ones worth reading before you go looking through Everything else. Each links to its full row.

VariableWhy you probably want itDetails
PHX_HOSTYour public hostname. Left unset it is example.com (or, on Render, Railway and Fly, the platform's own hostname), and every generated link, email URL and LiveView socket origin check uses that — so the console appears to load and then silently fails to connect.server & networking
PHX_SERVERA release that does not set it boots, answers bin/kiln_cms rpc, and keeps the container healthcheck green while serving no HTTP at all. bin/server sets it for you.server & networking
PORTThe port to bind, when 4000 is not what your platform routes to.server & networking
POOL_SIZEPostgres pool size. The default of 10 is shared by web requests and Oban workers, so a busy instance wants more.server & networking
TRUSTED_PROXIESBehind a proxy (Coolify, Traefik, nginx, a load balancer) every request appears to come from the proxy, so per-IP rate limiting and the brute-force protection on /sign-in collapse into one shared bucket. Nothing errors.server & networking
DATABASE_SSLOn by default, and worth knowing before you turn it off: it is the switch a typo used to silently disable (#606).database TLS
MAIL_MODEWithout it no email leaves the deployment — invitations, password resets and digests are queued, fail in Oban, and retry forever. Nothing raises.outbound email
MAIL_FROM_EMAILThe address mail is sent from; its domain is the sending and DKIM-signing domain under MAIL_MODE=direct.outbound email
S3_BUCKETObject storage. Without it (or KILN_MEDIA_ROOT on a mounted volume) uploads live on the container's local disk, which a redeploy discards.object storage
SENTRY_DSNError tracking. It is also the only sink that receives the boot-time configuration warnings described under On/off variables — without it a mistyped variable is one line in docker logs.error tracking

Everything else

The full surface, grouped by the feature it configures. Everything below is optional: unset means the feature is off or keeps the default named in its row.

server and networking

VariableDefaultPurposeWhere it's read
PHX_SERVERunset ⇒ no HTTPSet to start the web server in a release; without it the release boots but does not serve HTTP. The generated bin/server script sets this for you. Presence-checked, not parsed — the partial exception to the on/off rules above. Any value starts the server, including a blank PHX_SERVER= and an unrecognized one, because Phoenix documents this as "any truthy value" and reading a declared-but-empty variable as "serve nothing" is a silent outage. The one rule it does honour is the off-spellings: false/0/no/off keep the server off, where they used to start it anyway.config/runtime.exs:67
PHX_HOSTthe platform's hostname, else example.comPublic hostname used to generate URLs and validate socket origins — wrong values break links, emails, and LiveView socket connections. Bare hostname; any https:// prefix or trailing / is stripped. Unset or blank, it falls back to the hostname a one-click platform provides (#1529): RENDER_EXTERNAL_HOSTNAME, then RAILWAY_PUBLIC_DOMAIN, then <FLY_APP_NAME>.fly.dev; with none of those, example.com, so on any other host it is effectively required. See deploy-platforms.md.lib/kiln_cms/config/host.ex:80
PUBLIC_BASE_URLhttps://<PHX_HOST>The origin every absolute public link is built from (#1833): sitemap, RSS/Atom feeds, canonical tags and JSON-LD, llms.txt, embed snippets, preview links, newsletter confirmation emails, site SSO callbacks, federation, static export and the provenance origin. The default org uses it as-is; another org keeps its scheme and port with <slug>.<TENANT_BASE_HOST> as the host, and an org's custom domain replaces the host. Unset, it is derived from PHX_HOST exactly as the endpoint's own URL is (https, port 443), so most deployments never set it. Set it only when the public site is served from a different origin than PHX_HOST — another host, a non-default port, or plain http. Must be an origin and nothing else (https://cms.example.com): a trailing / is dropped, but a path, query, fragment or credentials refuses to boot, naming the variable. Kiln warns at boot (log and Sentry) when it resolves to localhost or a loopback address. Blank counts as unset. Production only; dev and test use http://localhost:4000.lib/kiln_cms/config/public_base_url.ex:54
PORT4000HTTP listen port the Bandit server binds to.config/runtime.exs:72
CHECK_ORIGINSunsetComma-separated extra origins allowed to open LiveView/channel sockets, for when the app is served from more than one hostname (e.g. mid domain migration). Entries may be full origins (https://cms.example.com), scheme-less (//cms.example.com — any scheme/port), or bare hosts (normalized to //host). The PHX_HOST origin is always allowed. Unset ⇒ only PHX_HOST may connect.config/runtime/prod/web.exs:42
CORS_ORIGINSunsetComma-separated allowlist (or *) of origins allowed cross-origin HTTP reads of the headless API (/api/*, /gql). Read in every environment; without it prod stays same-origin-only. Does not affect sockets — that's CHECK_ORIGINS. See KilnCMSWeb.CORS.config/runtime/cross_origin.exs:16
EMBED_ORIGINSunset ⇒ same-origin onlyComma-separated allowlist of sites permitted to iframe an embeddable form (/forms/:slug/embed) — sets that page's CSP frame-ancestors. Unset (or blank) means same-origin only, so cross-site embedding is off until you set it (#562 — the default used to be *). * re-opens it to any site; that is a clickjacking surface, since form submission is deliberately CSRF-free. This is the default for forms that do not set their own allowlist in the builder's Embed tab; on a multi-org deployment set it there instead, or this variable has to be the union of every org's embedders and every org's forms become framable by all of them (#648). On a multi-org deployment it is also the ceiling unless EMBED_ORIGINS_LOCKED=false (#1618, below): list every site any org may allow here, and let each org's own list narrow it — an org cannot open a site this variable does not name. See KilnCMSWeb.Embed and forms.md.config/runtime/cross_origin.exs:28
EMBED_ORIGINS_LOCKEDunset (auto)Make EMBED_ORIGINS a ceiling as well as a default (#1133): a form's or an org's own embed allowlist (#648, #1131) may narrow it but not reach outside it — an admin's write naming an origin the ceiling does not cover is refused (naming the entry, never the ceiling), and the served frame-ancestors is clamped to it, so a list saved before the cap was turned on takes no effect beyond it. Unset, it is automatic (#1618): off while the deployment has one organization, on as soon as a second exists — with no restart, and on every node, following the same organization count as TENANT_STRICT_HOST. true forces it on; false forces it off, which is what every release before 0.11 did. If Kiln cannot count its organizations yet (it booted while the database was unreachable), auto counts as locked until the count succeeds. Off, the #1130/#1131 behaviour is unchanged. EMBED_ORIGINS=* under the cap is a ceiling of everything; EMBED_ORIGINS unset under it closes cross-site framing deployment-wide — so on a multi-org deployment that never set EMBED_ORIGINS, every form and site allowlist is clamped to same-origin and no other site can frame any form until you set EMBED_ORIGINS (or EMBED_ORIGINS_LOCKED=false). Stored lists are never rewritten; Kiln warns at boot, when the second organization is created, and on /editor/system while any of them names a site outside the ceiling. Parsed by the shared on/off rules; only a recognized spelling writes config. See KilnCMS.Forms.EmbedCeiling and forms.md.config/runtime/cross_origin.exs:42
VISUAL_EDITING_ENABLEDtrueSet to an off-spelling to disable the visual-editing bridge (#355): the annotated preview route (/api/visual-editing/:type/:slug) 404s and the live-preview socket (/ws/bridge) refuses. Which origins may use the bridge (annotated read, write API, socket) is governed by CORS_ORIGINS — the bridge is cross-origin to a different app, so it uses that allowlist, not CHECK_ORIGINS (same-app extra hosts). See visual-editing-bridge.md and KilnCMS.VisualEditing.config/runtime/delivery.exs:30
REQUIRE_AV_METADATA_STRIPfalseRefuse a video or audio upload whose container metadata could not be stripped, instead of storing it as it arrived and logging a warning (#820). An MP4 off a phone carries GPS coordinates, device model and OS version, and a local wall-clock creation date; Kiln remuxes those away with ffmpeg -map_metadata -1 when ffmpeg is present. Only set this to true on a host that has ffmpeg — without it, every A/V upload is refused. Off by default because flipping it for existing deployments would be exactly that outage, silently, on upgrade; see the guarantee table in media-pipeline.md. One case ignores this setting: an upload that cannot be stripped because the temp filesystem is out of space is refused either way (#1100), since that failure is transient and retrying works.config/runtime.exs
KILN_CONSOLE_HOSTunsetServe the editor console only from console hosts (#740): this host is the default org's console (never refused, even under TENANT_STRICT_HOST), and <slug>.<this host> is each other org's (#1688) — a derived name, so point this host and *.<this host> at Kiln in DNS and TLS. A console route on an org's site redirects (GET) to that org's console host or 404s; tenant content is never served on a console host (the bare host goes to /editor); shared routes — sign-in/account, the headless APIs, previews, media download/stream, probes — serve on both. Which routes are which is KilnCMSWeb.Surface, pinned by a test. Console hosts are admitted to the socket origin check automatically. Put it under PHX_HOST (e.g. console.<PHX_HOST>): passkeys' relying-party ID is the PHX_HOST host, and a browser only accepts it on hosts under it — outside, the console has no passkey sign-in and Kiln warns at boot. See multi-tenancy.md and code-injection.md. Unset on a deployment with more than one organization, Kiln warns at boot, when the second org is created, and on /editor/system (#1661): an org admin's code injection is then same-origin with the console, and can act as any editor who opens that site signed in.config/runtime/delivery.exs:43
KILN_AV_STRIP_MODEsyncdeferred moves the A/V metadata strip off the upload request (#1122): the upload is staged to private storage as a quarantined MediaItem — invisible to every non-editor read (policy, not a UI filter), a 404 on /media/:id/download and /stream, its public url pointing at nothing — and KilnCMS.Media.AVStripWorker strips, promotes the stripped copy to the public key, releases the quarantine and only then enqueues derivation. Needs private storage (the Local adapter always has it; S3 needs a private bucket) and falls back to sync with a one-time warning otherwise. sync is the bounded synchronous path (#1112). Failure outcomes and REQUIRE_AV_METADATA_STRIP apply the same, one step later. See media-pipeline.md.config/runtime/media.exs:34
KILN_MEDIA_QUARANTINE_REAPER_CRON20 * * * *When KilnCMS.Media.QuarantineReaper runs (#1122): removes quarantined uploads whose deferred strip never completed — private blob deleted, row purged — once older than KILN_MEDIA_QUARANTINE_MAX_AGE_HOURS. false disables the schedule.config/runtime/media.exs:43
KILN_MEDIA_QUARANTINE_MAX_AGE_HOURS24How long a quarantined upload may wait for its strip before the reaper removes it (#1122). Generous on purpose: the failure it exists for is "stuck", not "slow". Positive integer.config/runtime/media.exs:47
KILN_IMAGE_TRANSFORM_KEYunset ⇒ derived from SECRET_KEY_BASEHMAC key for signed on-the-fly image transform URLs (/media/:id/t/…). A signed URL may ask for any size within the hard limits; an unsigned one only for the allowlist. Unset, Kiln derives a key from SECRET_KEY_BASE — enough for its own templates, which sign server-side, but no one else can sign. Set it (at least 32 characters; mix phx.gen.secret 32) to let a server-side frontend sign with the JS SDK or KilnClient. It is a secret: never ship it to a browser. Shorter values refuse to boot. Rotating it (or SECRET_KEY_BASE, when unset) invalidates every signed URL already embedded in pages. See media-pipeline.md.config/runtime/media.exs:60
KILN_IMAGE_TRANSFORM_UNSIGNEDtrueSet to an off-spelling to serve only signed transform URLs — every unsigned /media/:id/t/… becomes a 403. On by default: unsigned URLs are held to an allowlist of sizes, aspect ratios and qualities (the SDKs snap to it), which is what lets a browser build them. Parsed by the shared on/off rules.config/runtime/media.exs:80
KILN_IMAGE_TRANSFORM_AUTO_AVIFfalseLet fm_auto answer AVIF to a browser that accepts it (otherwise it stops at WebP). Off by default because an AVIF encode costs roughly ten times a WebP one; an explicit fm_avif works either way. Parsed by the shared on/off rules.config/runtime/media.exs:87
PRESENTATION_PREVIEW_URLunsetThe external front end's URL template for the Presentation console (/editor/presentation/:type/:slug, #355) — placeholders {path}/{type}/{slug}/{locale} (a bare base URL gets {path} appended). Unset ⇒ the console shows a setup hint. The front-end origin is derived from this for postMessage validation. See visual-editing-bridge.md and KilnCMSWeb.Presentation.config/runtime/updates.exs:16
POOL_SIZE10Ecto database connection pool size. See the pool-sizing formula in docs/performance.md.config/runtime/prod/database.exs:50
ECTO_IPV6unsetSet to an on-spelling to connect to Postgres over IPv6.config/runtime/prod/database.exs:17
PREVIOUS_SECRET_KEY_BASEunsetSet only while rotating SECRET_KEY_BASE (#1487): the old value, next to the new SECRET_KEY_BASE. KilnCMS.Keys.Vault then still opens the key material it stored under the old secret (the DKIM key, social credentials, billing secrets and the ActivityPub actor key), and every new write uses the new one. mix kiln.vault.reencrypt (or KilnCMS.Release.reencrypt_vault/1) uses it by default to move those values across. Unset it once a dry run reports nothing left to re-encrypt. This covers the vault only: session cookies, Phoenix.Tokens and JWTs are still a hard cutover. A blank value, or one equal to SECRET_KEY_BASE, is ignored. See secrets-rotation.md.config/runtime/prod/web.exs:243
TRUSTED_PROXIESunsetComma-separated reverse-proxy CIDRs (e.g. 10.0.0.0/8,172.16.0.0/12). When set, KilnCMSWeb.Plugs.ClientIp rewrites remote_ip from X-Forwarded-For for rate limiting. Leave unset only when the app is internet-facing directly, where X-Forwarded-For is spoofable. If you run behind a proxy — Coolify, Traefik, nginx, a cloud load balancer — set this. Unset there, every request carries the proxy's address, so every rate-limit bucket becomes one shared counter for the whole internet: one noisy client exhausts :auth (40/min) for everybody, and the per-IP brute-force protection on /sign-in stops being per-IP. Nothing errors, so the app logs a warning once per node the first time a forwarded request arrives while this is unset (#564). Note the CIDRs name which hops to skip while walking the forwarded chain, not which peers are allowed to forward — once this is set at all, X-Forwarded-For is honoured whatever address the request arrives from, so set it only on a deployment that really is behind a proxy. And every private range is skipped regardless (10/8, 172.16/12, 192.168/16, 127/8, ::1, fc00::/7), so listing those has no effect on the chain — its only job there is flipping the honour-the-header switch. If your proxy has a public address (a cloud load balancer), you must list its CIDR or the app will key every bucket on the balancer instead of the client. The same rule serves /live handshakes through ClientIp.resolve/2, so a socket keys the bucket on the same client its HTTP request would have (#715, #934).config/runtime/prod/web.exs:93
CLIENT_IP_HEADERunsetFly.io, Railway and DigitalOcean App Platform only (#1548): take the client address for rate limiting from the platform proxy's own header instead of X-Forwarded-For — fly-client-ip, x-real-ip (Railway) or do-connecting-ip; case-insensitive. None of those platforms publishes its proxy's address range, so TRUSTED_PROXIES cannot be set there. A request header is trusted with no peer check, which is safe only when every request arrives through that platform's proxy, so it is honoured only while that platform's own variables are present too: FLY_APP_NAME and FLY_MACHINE_ID; RAILWAY_SERVICE_ID and RAILWAY_ENVIRONMENT_ID; or APP_ID holding an app id (bind it to ${APP_ID} in the app spec — App Platform injects nothing of its own). Anywhere else, or with any other header name, it is ignored and one error is logged per node; boot is not refused. When honoured it wins over TRUSTED_PROXIES; a request without the header, or with anything but a single address in it, falls back to the TRUSTED_PROXIES rules. LiveView sockets see only x- headers, so on Fly and DigitalOcean the /sign-in form's bucket stays per deployment. Set by fly.toml and .do/app.yaml; see deploy-platforms.md.lib/kiln_cms_web/plugs/client_ip.ex:262
DNS_CLUSTER_QUERYunsetDNS query for libcluster-style node discovery.config/runtime/prod/web.exs:81
KILN_READING_TIME_WPM230Words per minute behind the reading_time_minutes calculation and the reading_time() computed-field function (#492). A non-positive or unparseable value keeps the default and warns on stderr. 230 is a mid-range figure for adult silent reading of English prose; a single rate is an English assumption, so see the caveat in headless-consumer-guide.md.config/runtime/delivery.exs:16
CSP_IMG_SRCunsetSpace-separated extra origins allowed in the browser CSP's img-src and media-src (#494) — needed when media serves from a CDN or media host on a different hostname than the site (e.g. https://media.example.com). Without it, default-src 'self' blocks a cross-host <video> as well as a cross-host <img>. See media-pipeline.md.config/runtime/console.exs:11

Note on ports. The public URL is hardcoded to port 443/https (config/runtime/prod/web.exs:223); the app itself listens on PORT. The expected topology is a TLS-terminating reverse proxy on 443 forwarding to the app on PORT. Public links (PUBLIC_BASE_URL) follow the same rule unless you set that variable.

background jobs (Oban)

VariableDefaultPurposeWhere it's read
KILN_OBAN_RESCUE_AFTER_MINUTES180How long a job may stay executing before Oban.Lifeline decides its node died (a deploy killed it after the 15 s grace period) and makes it available again, or discarded once its attempts are spent (#1718). Must exceed the longest legitimate job: the rescue goes by time alone, so a live job older than this runs a second time. The longest bounded job is a backup (2 h). false switches the rescue off; a value that isn't a positive whole number warns on stderr and keeps 180. See deploy.md.config/runtime/schedules.exs:80

API documentation (#567)

The OpenAPI 3 document and the Swagger UI explorer over it. Served in dev and test; off in a production build, for the reason GraphQL introspection is — since #330 the described surface includes the write routes, so the document is a complete machine-readable map of the mutation API. It grants nothing (the Ash policies and the API key's scope still enforce every route), but it removes the guesswork.

Disabled, both paths answer 404 rather than 403: a 403 confirms the route exists and is merely closed. The one exception is a request carrying an API key (Authorization: Bearer kiln_…): the OpenAPI document answers it, and so does GET /api/graphql/schema.graphql where introspection is off, so a client author can run codegen against their own production site. The explorer never does. The stock build's specs are also committed, at docs/api/ — see api.md.

VariableDefaultPurposeWhere it's read
API_DOCS_ENABLEDon outside prod, off in prodServe GET /api/json/open_api and GET /api/json/swaggerui. Turn it on for a deployment that publishes a public API. Off, the document still answers a request carrying an API key; the explorer does not.config/runtime/prod/web.exs:124
GRAPHQL_INTROSPECTION_ENABLEDon outside prod, off in prodAnswer GraphQL introspection (__schema/__type) on /gql, and serve GET /api/graphql/schema.graphql to anyone. Off, that SDL route still answers a request carrying an API key.config/runtime/prod/web.exs:132

API caching and CDN purge

Anonymous reads of JSON:API, GraphQL GET and /api/search are marked public with a body ETag, so a CDN in front of Kiln can answer them; any request carrying a credential is private, no-store (see api.md). Read in every environment.

VariableDefaultPurposeWhere it's read
KILN_API_CACHEtrueSet to an off-spelling to keep anonymous JSON:API/GraphQL/search responses at Plug's private default instead of public. Credentialed responses are private, no-store and every response carries Vary: Accept, Authorization, Origin either way. Parsed by the shared on/off rules. See KilnCMSWeb.Plugs.PublicCache.config/runtime/delivery.exs:54
KILN_API_CACHE_MAX_AGE60max-age, in seconds, on a cacheable anonymous API response. Without KILN_CDN_PURGE_URL this is how stale a publish can look through a CDN. Positive integer.config/runtime/delivery.exs:58
KILN_API_CACHE_SWR60stale-while-revalidate, in seconds: how long a cache may keep serving an expired response while it refetches. Positive integer.config/runtime/delivery.exs:62
KILN_CDN_PURGE_URLunsetWhen set, every <type>.published/.unpublished/.updated (and release.published) POSTs the site's surrogate key here: body {"tags": ["kiln-org-<id>"]} (Cloudflare purge-by-tag) and header Surrogate-Key: kiln-org-<id> (Fastly purge-by-key). Coalesced per site, retried with backoff, sent through KilnCMS.SafeFetch — so in production it must be an https:// URL that resolves to a public address. Unset, cached responses age out on KILN_API_CACHE_MAX_AGE. See KilnCMS.CDN.config/runtime/delivery.exs:73
KILN_CDN_PURGE_TOKENunsetCredential for the purge request. Resolved at call time through the KilnCMS.Keys env provider; never logged.config/runtime/delivery.exs:79
KILN_CDN_PURGE_TOKEN_HEADERauthorizationHeader the token goes in. authorization sends Bearer <token> (Cloudflare API tokens); any other name sends the bare token in that header, e.g. fastly-key for Fastly.config/runtime/delivery.exs:82

multi-tenancy (#336)

One deployment can serve many organizations, each on its own host (the isolation model as a whole is in multi-tenancy.md). The request's Host picks the org: a subdomain of TENANT_BASE_HOST (acme.example.com → org acme), else an exact custom_domain on an org, else the default org. See KilnCMSWeb.Tenant.

VariableDefaultPurposeWhere it's read
TENANT_BASE_HOSTPHX_HOSTThe apex tenant subdomains are carved from. Set it only when tenant subdomains live under a different apex than the canonical URL host.config/runtime/prod/web.exs:103
TENANT_STRICT_HOSTunset (auto)Reject a request whose Host matches no org (404) instead of serving it the default org. Unset, it is automatic (#1547): off while the deployment has one organization, on as soon as a second exists — with no restart, and on every node. true forces it on. false keeps it off only while there is one organization (#1662, 0.12): once a second exists, unknown hosts are refused whatever this says — until 0.12 false kept the fallback with many orgs too, which served an unrecognized, possibly attacker-chosen Host another tenant's site. There is no switch to get that back; give each host an organization or redirect it at the proxy. With one org the fallback is harmless, and a single-host install is served through it — a bare hostname, an IP literal, localhost, the load balancer's health-check host. With two or more, a bare hostname, an IP literal or an attacker-supplied Host would be served the default site's content, branding and analytics, which is why it turns on. A node that missed the second org's create catches up within 30 seconds (#1654). If Kiln cannot count its organizations yet (it booted while the database was unreachable), auto treats the deployment as multi-org and refuses unknown hosts until the count succeeds, while an explicit false stays lenient — see KilnCMSWeb.Tenant.OrgCount for why. Kiln tells you three ways if TENANT_STRICT_HOST=false is set on a deployment with more than one org, where it is ignored: an error at boot, one when the second organization is created, and a standing notice on /editor/system.config/runtime/prod/web.exs:116

What it covers. Everything the router serves, plus LiveView mounts and all three sockets — GraphQL (/ws/gql), visual editing (/ws/bridge) and collaborative editing (/ws/collab, since #655) — each resolving its tenant from its own connect URI and refusing rather than falling back. One thing sits outside it, and it performs no org-scoped reads:

  • Static files, including /uploads under the local storage adapter. Both Plug.Static mounts run earlier in the endpoint and halt on a match, so an asset URL answers on any Host. Keys are unguessable UUIDs, and putting tenant resolution ahead of static would add two DB lookups to every asset request; treat media URLs as host-agnostic, as they would be behind a CDN.

What stays reachable. Two controllers are exempt, because both are deliberately host-independent and neither reads the ambient tenant:

  • The liveness and readiness probes (/up, /ready), so a load balancer or orchestrator sending the container's IP as Host keeps getting a 200 — turning this on will not mark a healthy deployment unhealthy.
  • The payment-provider webhook (POST /billing/webhooks/stripe), which arrives at whatever host the endpoint was registered with, is authorized by an HMAC over the raw body, and resolves its organization from the event payload.

The exemption keys on the controller, not on a path list, so it tracks the router.

Before creating a second organization (or setting TENANT_STRICT_HOST=true), confirm every host that must reach the app is accounted for: each org's subdomain or custom_domain, and the PHX_HOST apex itself (it resolves to the default org, and is never refused even if the database is briefly unreachable). Once strict matching is on, anything else gets a 404.

404 means "no such host"; a database outage gets a 503 (#341). A host that could not be looked up is a different answer from one that matches no org, and Kiln keeps them apart:

  • With strict matching off — a single-org install under the default or under TENANT_STRICT_HOST=false — a failed lookup falls back to the default org exactly as an unmatched host does. Nothing is refused in this mode, including during an outage, which is what lets warm content keep being served from cache without a database (#341): tenant resolution runs in the endpoint, above the cache.
  • With it on, an unresolvable host is still refused (falling back would serve the default org on an unrecognized host, which is what this setting exists to prevent), but as a plain-text 503 with retry-after, not a 404. The host may well exist; a 404 is what a CDN caches, an uptime monitor pages the tenant about, and a search engine deindexes on. The health-probe and webhook exemptions above apply to this refusal too.

The refusal no longer costs a query every time (#659). A refused request is halted before the router, and every rate limiter lives in a router pipeline — so turning this on originally took the path out of the :delivery ceiling and left one uncached organization lookup per request, metered by nothing. Unresolvable hosts are now cached as misses, in a cache of their own (KilnCMS.Cache.Hosts, one-minute negative TTL) so that a flood of invented hosts cannot evict hot published pages the way it would have in the shared content cache. A repeated flood costs one lookup per distinct host per minute rather than one per request; a flood of distinct hosts still costs a lookup each, which is the price of never refusing a host that does exist. Terminate unknown hosts at the proxy if that load matters. As a side effect, tenant resolution no longer evaporates when an editor saves a media item — it used to live in the cache that a content bust clears wholesale.

What the refusal reveals. An unknown host gets a plain-text 404; a known host with an unmatched path gets the branded HTML 404. The two are distinguishable, so a dictionary sweep of <candidate>.<base host> will enumerate which org slugs exist, and which custom_domains are configured. This is accepted rather than fixed: making them identical means either showing unknown hosts the branded page — the default-org leak this control exists to prevent — or degrading every tenant's real 404 to plain text, to hide names that are already public in DNS and in TLS certificates. If your tenant list is itself confidential, terminate unknown hosts at the proxy, where one uniform response covers both cases.

white-label branding (#48)

The instance-wide branding layer, beneath each site's own editor-managed SiteBranding row — a per-org row always wins, so these set the fallback every org inherits until it overrides them. Unset vars fall through to the stock KilnCMS defaults. All four are read only under :prod; for dev or test, set config :kiln_cms, :branding, … in a config file. See KilnCMS.Branding.

VariableDefaultPurposeWhere it's read
SITE_NAMEKilnCMSInstance name in the admin chrome, page titles and outbound email. Also the default provenance signer identity when KilnCMS.Provenance's :signer is unset.config/runtime/prod/web.exs:183
BRAND_LOGO_URLunsetLogo shown in the admin chrome and on branded error pages. If the host differs from the site's origin it must also be in CSP_IMG_SRC, or the browser blocks the image.config/runtime/prod/web.exs:190
BRAND_FAVICON_URLunsetFavicon URL. Same CSP_IMG_SRC caveat as the logo.config/runtime/prod/web.exs:197
BRAND_PRIMARY_COLORunsetHex colour driving the emitted OKLCH theme tokens — #1d4ed8 or the #1d4 shorthand, stored in canonical long lowercase form. Anything else is ignored with a warning rather than interpreted, since the value feeds contrast computation. Validated at boot alongside every other variable here, so a bad value reaches Logger and Sentry and not just container stdout (#1089); before that it was checked only at render time, where a bare Logger.warning never reaches Sentry.config/runtime/prod/web.exs:150

Unsplash (media library)

VariableDefaultPurposeWhere it's read
UNSPLASH_ACCESS_KEYunsetThe Unsplash API Access Key (not the secret key, which this never needs). Setting it adds the Unsplash tab to the media library, where an editor can search and import stock photos; unset, the tab never renders and no request reaches Unsplash. Read in every environment. Where to get it: register an application at unsplash.com/oauth/applications. A new app is in Demo mode (50 requests/hour) — enough to evaluate the tab, but production traffic needs Unsplash to approve the application.config/runtime/console.exs:17

database TLS

VariableDefaultPurposeWhere it's read
DATABASE_SSLtrueEncrypt the Postgres connection. Set to an off-spelling only for a provider that genuinely cannot offer TLS — an unrecognized value keeps TLS on rather than silently downgrading to plaintext (#606).config/runtime/prod/database.exs:28
DATABASE_SSL_CACERTFILEunsetPath to the provider's CA bundle. When set, the server cert is verified (verify_peer) and must name the host in DATABASE_URL (Postgrex's HTTPS-style hostname match; a wildcard certificate passes, an IP address or an unlisted internal name does not); unset — or blank, like every variable above — leaves the connection encrypted but verify_none.config/runtime/prod/database.exs:36

object storage (S3-compatible)

Opt into the S3 storage adapter by setting S3_BUCKET. When it is set, S3_PUBLIC_BASE_URL, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY become required (the latter two raise via System.fetch_env!). See KilnCMS.Storage.S3 for per-provider hosts, and media-pipeline.md for the CDN deployment guide. A site admin can give their site its own bucket at /editor/site-storage (#1559); that site's new uploads then use it and none of these. See Per-site buckets.

VariableDefaultPurposeWhere it's read
KILN_MEDIA_ROOTunsetA stable directory for the Local adapter (#1529): public files in <dir>/public (served at /uploads), private ones in <dir>/private (never served). Unset, local media goes under the release's own priv/uploads — a path that changes with every version, so no volume can be mounted there. Set it to where a volume is mounted (/app/media in the image and the one-click templates). Must be an absolute path; a relative one is ignored with a warning. Ignored when S3_BUCKET is set. Also the default for MEDIA_DIR. The app checks at boot that it can write there.lib/kiln_cms/config/media_root.ex:27
S3_BUCKETunsetEnables the S3 adapter. Leave unset to use local storage.config/runtime/prod/storage.exs:37
S3_PUBLIC_BASE_URL—Public base URL objects are served from — the CDN hostname, including the bucket path if the provider's URLs carry one. Required when S3_BUCKET is set (raises otherwise).config/runtime/prod/storage.exs:44
AWS_ACCESS_KEY_ID—S3 access key. Required when S3_BUCKET is set (fetch_env!).config/runtime/prod/storage.exs:70
AWS_SECRET_ACCESS_KEY—S3 secret key. Required when S3_BUCKET is set (fetch_env!).config/runtime/prod/storage.exs:71
AWS_REGIONus-east-1Region. Use auto for Cloudflare R2; a real region for B2/Wasabi/AWS.config/runtime/prod/storage.exs:73
S3_ACLunsetPer-object canned ACL (e.g. public_read). Only needed if the bucket isn't public at the bucket level.config/runtime/prod/storage.exs:51
S3_PRIVATE_BUCKETunsetA separate bucket for gated documents (#481) — this app's own AWS credentials read it directly, so it needs no public-read config, CDN, or public-base-URL equivalent. Without it, gating a document is refused rather than silently falling back to the public bucket. Also where API direct uploads are staged (POST /api/media/uploads answers 501 without it — media-pipeline.md).config/runtime/prod/storage.exs:62
S3_ENDPOINT_HOSTunsetCustom endpoint host for non-AWS stores (R2/B2/Wasabi/MinIO). Leave unset for AWS S3.config/runtime/prod/storage.exs:77
S3_ENDPOINT_SCHEMEhttps://Scheme for the custom endpoint.config/runtime/prod/storage.exs:79
S3_ENDPOINT_PORT443Port for the custom endpoint.config/runtime/prod/storage.exs:81

Media objects are uploaded with Cache-Control: public, max-age=31536000, immutable — there is no env var for it, because storage keys are write-once UUIDs so a URL's bytes never change. If the CDN hostname differs from the site's origin, add it to CSP_IMG_SRC or the browser will block the images.

in-app backups (#484)

Read in production only. Every one of these is a variable scripts/backup.sh already reads, by the same name — the cron path and the in-app path are two front doors to one backup directory, and an operator who configured the script does not configure this separately. See backups.md.

The console's Backups page (/editor/backups, admin only) reads $BACKUP_DIR/manifest.json, which backup.sh all writes — so it reports on cron's backups, not only ones taken from the app.

VariableDefaultPurposeWhere it's read
BACKUP_ENABLEDtrueSet to an off-spelling to disable the in-app backup path — the console explains why and the button is disabled. Cron backups on the host are unaffected, which is the point: turning off the button is not turning off backups. Parsed by the shared on/off rules.config/runtime/prod/backups.exs:46
BACKUP_DIR/var/backups/kilnWhere backups land, for both paths.config/runtime/prod/backups.exs:47
BACKUP_KEEP_DAYS14Local retention in days, enforced by both paths. A non-positive or unparseable value keeps the default and warns — read literally, 0 would delete the backup it had just taken.config/runtime/prod/backups.exs:48
BACKUP_STALE_AFTER_HOURS36How old the newest backup may be before the console warns and the overview shows a red strip. Deliberately longer than a daily cadence: a warning that fires because a nightly job ran at 03:20 instead of 03:17 is one an admin learns to ignore.config/runtime/prod/backups.exs:49
MEDIA_DIRKILN_MEDIA_ROOT, else unsetUploads root to archive — Local storage adapter only. Leave unset on S3/R2, where the bucket is backed up provider-side: tarring a directory that doesn't hold the media produces an archive that looks like a media backup and restores nothing. When KILN_MEDIA_ROOT is set (and S3_BUCKET is not), it is the default here and in scripts/backup.sh, so a volume-backed deployment states the path once.config/runtime/prod/backups.exs:86

The runtime image needs pg_dump. It installs postgresql-client-17, and the major version must match your Postgres server — pg_dump refuses to run against a newer one. Bump the pin in the Dockerfile alongside a server upgrade. Where the tools are absent, the console says so rather than failing at the point of use.

SSO (OpenID Connect, #331)

Only read when SSO was compiled in (config :kiln_cms, :sso_oidc, enabled: true — see docs/sso.md). All four are then required for the flow to work.

Where to get them. Register a confidential (server-side web) application at your identity provider — Entra ID, Okta, Keycloak, Auth0, Google Workspace, any OIDC-conformant IdP. It issues the client id and secret; you hand it OIDC_REDIRECT_URI as the permitted callback. Kiln authenticates with client_secret_basic, so the IdP must allow that token-endpoint auth method. OIDC_ISSUER is the provider's base URL — whatever serves <issuer>/.well-known/openid-configuration — and discovery supplies the rest, so there are no other endpoints to copy across.

VariablePurposeWhere it's read
OIDC_CLIENT_IDClient id registered at the IdPconfig/runtime.exs
OIDC_CLIENT_SECRETClient secret (client_secret_basic)config/runtime.exs
OIDC_ISSUERProvider base URL (OIDC discovery)config/runtime.exs
OIDC_REDIRECT_URIThis site's callback base, e.g. https://cms.example.com/authconfig/runtime.exs

outbound email

With none of these set, production uses the dev-only in-memory adapter: the app runs, but every delivery job fails in Oban and no email leaves. Opt into real delivery with MAIL_MODE (or, for back-compat, just SMTP_HOST, which implies MAIL_MODE=smtp). Direct mode is configured and verified from /editor/mail — see the operator guide docs/direct-email-delivery.md for the DNS requirements (SPF/DKIM/DMARC/PTR) and the big caveat: many cloud hosts block outbound port 25.

These variables set the deployment's relay. A site admin can send their site's mail through their own SMTP relay and From address instead, from /editor/site-mail (#1322), with no redeploy. That covers newsletters, form mail, notifications and automation emails. The relay set here stays in use for account mail (sign-in links, password resets), and for every site that hasn't set its own. A site's relay host may not be a private address. An internal relay belongs here, where the operator sets it.

VariableDefaultPurposeWhere it's read
MAIL_MODEunsetsmtp = relay through an SMTP server; direct = deliver straight to each recipient domain's MX hosts (built-in MTA, no relay). Anything else raises at boot.config/runtime/prod/mailer.exs:67
MAIL_FROM_EMAILunsetFrom address for all outbound mail. Required when MAIL_MODE=direct (raises otherwise) — its domain is the sending/DKIM domain.config/runtime/prod/mailer.exs:107
MAIL_FROM_NAMEKilnCMSDisplay name for the From address.config/runtime/prod/mailer.exs:143
SMTP_HOSTunsetRelay host. Required when MAIL_MODE=smtp; setting it without MAIL_MODE also selects smtp mode.config/runtime/prod/mailer.exs:68
SMTP_PORT587Relay port. 587 (STARTTLS) is the right default, and 25 belongs to MAIL_MODE=direct. 465 will not work: that port expects implicit TLS from the first byte, and the adapter is configured for STARTTLS only (tls:, never gen_smtp's ssl:) with no environment variable to change it — a relay that offers both ports should be pointed at 587.config/runtime/prod/mailer.exs:99
SMTP_USERNAMEunsetRelay username (auth: :always). From the relay provider's dashboard — providers name the pair differently: Postmark issues one Server API Token used as both username and password; SES issues dedicated SMTP credentials, which are not your AWS access keys; Gmail requires an App Password rather than the account password.config/runtime/prod/mailer.exs:100
SMTP_PASSWORDunsetRelay password; see SMTP_USERNAME for where it comes from.config/runtime/prod/mailer.exs:101
SMTP_TLStrueSTARTTLS to the relay. Set to an off-spelling only for a local dev/test relay.config/runtime/prod/mailer.exs:85
SMTP_TLS_VERIFYtrueVerify the relay's certificate against CAStore's bundle, with SNI. Set to an off-spelling for a relay with a self-signed or mismatched certificate: the connection stays encrypted but the peer is not verified (verify_none).config/runtime/prod/mailer.exs:85
MAIL_HELO_HOSTPHX_HOSTDirect mode only: HELO/EHLO hostname. Deliverability requires the sending IP's PTR record to resolve to this name.config/runtime/prod/mailer.exs:119
DKIM_PRIVATE_KEYunsetDirect mode's DKIM signing key (PKCS#1 RSA PEM, same shape as the provenance key). Most deployments should not set this: /editor/mail generates the keypair, picks a selector, prints the TXT record to publish and then verifies it against DNS alongside SPF, DMARC, PTR and outbound port 25. This variable exists for a deployment whose policy forbids a private key in the database — select the env provider on that page (it falls back to this variable name when none is given, KilnCMS.Keys.Providers.Env) and supply the PEM yourself, because that provider is read-only and the Generate button can no longer help. A blank value counts as unset. Being a multi-line PEM, it takes the same forms as KILN_PROVENANCE_PRIVATE_KEY — an escaped one-line double-quoted value (literal \n, unescaped on read, #609), a true multi-line double-quoted value, or the file provider. Generate with openssl genrsa -traditional -out kiln-dkim.pem 2048, and stay at 2048 bits — a 4096-bit public half overflows the 255-byte TXT string limit. See direct-email-delivery.md.KilnCMS.Keys.Providers.Env

search (Meilisearch)

Opt into the typo-tolerant search backend by setting MEILI_URL; otherwise Postgres full-text search is the only backend. Run mix kiln.meili.reindex once after enabling. See docs/meilisearch.md.

These variables set the deployment's instance. A site admin can index their site into their own Meilisearch instead, from /editor/site-search (#1558), with no redeploy; the instance set here stays in use for every site that hasn't set one. A site's instance must be HTTPS and may not be a private address. An internal instance belongs here, where the operator sets it.

VariableDefaultPurposeWhere it's read
MEILI_URLunsetMeilisearch server URL. Enables the backend when set.config/runtime/prod/search.exs:13
MEILI_MASTER_KEYunsetMeilisearch API master key.config/runtime/prod/search.exs:17, lib/kiln_cms/search/meilisearch.ex:14
MEILI_INDEXkiln_contentIndex name.config/runtime/prod/search.exs:18

search (chunked document embeddings)

Semantic search only (config :kiln_cms, KilnCMS.Search, semantic: true).

VariableDefaultPurposeWhere it's read
SEARCH_CHUNK_LONG_DOCUMENTSunset (false)Embed the part of a long record's search text past the embedder's 512-token window as overlapping chunks, so hybrid search's block leg and the relevance floor see the whole record rather than its opening (#1940). Roughly 2× the embedding work for a corpus of long records; run mix kiln.embed_all after turning it on. Read with fetch/1, so an unset variable leaves a project overlay's chunk_long_documents in force. See semantic-search-plan.md.config/runtime/prod/search.exs:32

search relevance floor (#1936)

Runtime overrides for hybrid search's relevance floor, which is otherwise compiled config :kiln_cms, KilnCMS.Search — so a retune, or a model-swap window, is a restart rather than a deploy each way. Unset leaves the compiled value (or a project overlay's) in force; an unusable value keeps it too, and warns. See semantic-search-plan.md for what the floor does and how to measure it.

VariableDefaultPurposeWhere it's read
SEARCH_SEMANTIC_MAX_DISTANCEunset (compiled semantic_max_distance, nil = no floor)The floor: the cosine distance in [0, 2] beyond which a hit only judged legs returned is dropped. 0.0 is legal and is the model-swap setting — it drops every judged hit with any distance, for the window in which stored vectors and query vectors come from different models (keep keyword_any out of SEARCH_SEMANTIC_FLOOR_LEGS meanwhile). 2.0 floors nothing, the runtime spelling of "off". See KilnCMS.Search.semantic_max_distance/0.config/runtime/prod/search.exs:52
SEARCH_SEMANTIC_FLOOR_LEGSunset (semantic,block,tag)Which legs' hits the floor judges, comma-separated. Must include semantic, block and tag; may add keyword_any (the any-term relaxation, where junk on a long-prose corpus comes from) and fuzzy. keyword, title and alias vouch for a hit and are refused. Add keyword_any together with chunked document vectors (#1940), or accept that a match in a long document's late passage is judged by a vector that never saw it. See KilnCMS.Search.semantic_floor_legs/0.config/runtime/prod/search.exs:70
SEARCH_KEYWORD_ANY_IDF_COVERAGEunset (off)The opt-in IDF-coverage variant, a share in (0, 1]: a keyword_any hit beyond the floor is kept when the query lexemes it matches carry at least this share of the query's IDF mass (per content type, over published documents). Acts only while keyword_any is in the floor legs. Shipped for measurement — the preregistered grid is 0.5/0.6/0.7. See KilnCMS.Search.keyword_any_idf_coverage/0.config/runtime/prod/search.exs:82

vector search scan (measurement runs)

How the semantic legs scan pgvector's HNSW indexes. Leave both unset on a deployment serving traffic — unset is what every release has always done (hnsw.iterative_scan = strict_order, pgvector's default ef_search of 40). They exist for a measurement replica: recall@k and MRR comparisons (mix kiln.search.eval, mix kiln.search.measure_floor) taken at the default ef_search carry approximate-recall noise, so measurement runs use ef_search 200 or exact. Read in every environment. Unlike every other variable in this document, an unusable value refuses to boot instead of keeping the default and warning: a typo that fell back to ef_search 40 would produce exactly the numbers the setting exists to exclude. Setting both is refused too. The search tasks take the same settings as --ef-search N / --exact for a local run, and print the mode in effect in their header. See KilnCMS.Search.VectorScan and semantic-search-plan.md.

VariableDefaultPurposeWhere it's read
KILN_HNSW_EF_SEARCHunset (pgvector's 40)SET hnsw.ef_search = N on every database connection, 1–1000 (pgvector's range). A wider candidate list per HNSW batch: better recall, more latency, still approximate. 200 is the measurement setting. Read with Env.positive_integer/1; out of range or unparseable refuses to boot.config/runtime/search.exs:25
KILN_VECTOR_SEARCH_EXACTunset (false)Exact vector search: SET enable_indexscan = off on every database connection, so no query can use an HNSW index and every vector query sorts its filtered rows by true distance. Btree indexes stay usable through bitmap scans (HNSW has none), but nothing gets an ordered index walk any more — for a measurement replica only. Parsed by the shared on/off rules, except that an unrecognized value refuses to boot; set together with KILN_HNSW_EF_SEARCH it refuses to boot.config/runtime/search.exs:32

AI-assisted SEO drafting

Opt in by setting SEO_MODEL. Unset, the editor's suggest control never renders and no content leaves the deployment — the deterministic SEO analysis and score are unaffected either way. Like the other variables on this page these are read in the production branch of runtime.exs; for dev or test, set config :kiln_cms, KilnCMS.Seo, … in a config file. Prefer an on-prem model (ollama:/vllm:); a hosted provider is announced at boot and in the editor, and should be added to your DPA's subprocessor list. See docs/seo.md.

These AI variables (this section and the two after it) are the operator's layer. A site admin can give their site its own provider, API key and models at /editor/site-ai (#1557); that site then uses its own and none of these. See Per-site provider.

VariableDefaultPurposeWhere it's read
SEO_MODELunsetreq_llm model spec, e.g. ollama:llama3.1 or anthropic:claude-sonnet-5. Enables drafting when set.config/runtime/prod/ai.exs:22
SEO_GENERATORKilnCMS.Seo.Generator.ReqLLMOverride the adapter module with your own KilnCMS.Seo.Generator.config/runtime/prod/ai.exs:24
ANTHROPIC_API_KEY, OPENAI_API_KEY, …unsetProvider credentials. Read by req_llm, never by Kiln — they don't enter Kiln's config or database.req_llm

AI block assist in the editor

The body-copy twin of SEO_MODEL, and a separate switch: this one sends a block's prose and the editor's typed instruction on each request, and returns text bound for the page body. Setting SEO_MODEL alone leaves it off and the per-block "AI assist" control never renders. Same on-prem preference and same boot warning for a hosted provider. See docs/ai-assist.md.

VariableDefaultPurposeWhere it's read
ASSIST_MODELunsetreq_llm model spec, e.g. ollama:llama3.1. Enables block assist when set.config/runtime/prod/ai.exs:45
ASSIST_GENERATORKilnCMS.Assist.Generator.ReqLLMOverride the adapter module with your own KilnCMS.Assist.Generator.config/runtime/prod/ai.exs:47

generated answers for /api/ask

The third AI switch, and the one to think hardest about, because it is the only one a stranger can trigger: /api/ask is a public, anonymous endpoint. Unset, it stays what it is by default — retrieval-only, returning cited published passages, "answer": null and "generation": "disabled" — and nothing leaves the deployment.

Only published, world-readable content is ever retrieved, so no draft can reach the model whatever the setting. Generation carries its own rate-limit buckets on top of the pipeline's per-IP limiter, keyed on the client address for anonymous callers; an exhausted bucket degrades to retrieval-only rather than refusing the request, and says so — "generation": "rate_limited" with a retry_after, so a client can tell a throttle from a switch. Same on-prem preference and same boot warning for a hosted provider. See docs/rag.md.

VariableDefaultPurposeWhere it's read
ASK_MODELunsetreq_llm model spec, e.g. ollama:llama3.1. Enables generated answers when set.config/runtime/prod/ai.exs:73
ASK_GENERATORKilnCMS.Ask.Generator.ReqLLMOverride the adapter module with your own KilnCMS.Ask.Generator.config/runtime/prod/ai.exs:75
ASK_RERANKunset (false)Rerank /api/ask's retrieved candidates with the KilnCMS.Search cross-encoder (bge-reranker-base) — the ask path only. KilnCMS.Search's rerank is the other switch, and it reranks every search surface on every query; this one is a bounded, per-question cost (at most limit candidates per content type). It fixes the order of what the fused legs returned, not recall, and the cross-encoder runs on the CPU — measure a question's cost on your host first; the deployment that asked for it has no AVX2. Read with fetch/1, so an unset variable leaves a project overlay's config :kiln_cms, KilnCMS.Ask, rerank: true in force. See rag.md.config/runtime/prod/ai.exs:97
SEARCH_RERANK_MODEunset (replace)How a rerank combines with the fused order on every reranked surface: replace (the reranker's order and 0–1 score outright, as always), rrf (Reciprocal Rank Fusion of the fused rank and the rerank rank, k = 60) or rrf:<k>. A blended hit's score is its blend, 1/(k + fused rank) + 1/(k + rerank rank). Anything else keeps the configured mode and is reported. See rag.md.config/runtime/prod/ai.exs:124
ASK_RERANK_MODEunset (SEARCH_RERANK_MODE's)As SEARCH_RERANK_MODE, for /api/ask alone.config/runtime/prod/ai.exs:128
ASK_RERANK_SCOPEunset (section)Where /api/ask reranks: section (each content type's candidates, one call per section) or union (the sections merged by fused score, the top 30 reranked in one call). union waits on a held-out measurement before it becomes the default (#1937).config/runtime/prod/ai.exs:132
ASK_RERANK_PINunset (false)In the union scope, hold every source the question names (its title or a flagged name field matched) at its fused rank and rerank only the rest. Read with fetch/1.config/runtime/prod/ai.exs:136

rich embed cards (oEmbed, #489)

Off by default, and enabling it is egress: the server makes an outbound HTTPS request when an editor saves a document containing an embed block whose URL one of the curated providers claims. With it off, an embed renders exactly as it always has — a bare figure for anything but YouTube and Vimeo, which are framed without any network call.

Requests only ever go to the provider endpoints listed in KilnCMS.OEmbed.Provider — never to a URL discovered from content — and through the address-pinned, size-capped KilnCMS.SafeFetch. The provider's html is discarded rather than sanitized; only title, author, provider name and a thumbnail are stored, and the thumbnail must be on that provider's own CDN.

VariableDefaultPurposeWhere it's read
OEMBED_ENABLEDfalseResolve oEmbed metadata so embeds render as cards.config/runtime/prod/oembed.exs:27
OEMBED_PROVIDERSunset (all)Comma-separated provider names to narrow the built-in list. Cannot add one — a new provider is a host this server dials, so it is a code change.config/runtime/prod/oembed.exs:29

Neither of these switches the feature on. Outbound checking is opt-in per site, in the console at /editor/links, because it is the site's content that decides which third parties get requested — see docs/link-checking.md. An unconfigured deployment with no site opted in makes no outbound requests at all, so the sweep is scheduled everywhere by default.

Internal links need none of this: they are checked by a database query in the editor's advisory panel, with no switch and no schedule.

VariableDefaultPurposeWhere it's read
KILN_LINK_CHECK_CRON20 4 * * *Oban cron expression for the sweep. false (or an unparseable value, which warns on stderr) leaves it unscheduled, for a deployment driving KilnCMS.Links.Sweep.run/0 from its own scheduler.config/runtime.exs
KILN_LINK_CHECK_USER_AGENTKilnCMS-LinkCheck (+github.com/…)What the checker calls itself to every site it asks about. Worth setting to something with your own contact URL: it is what an operator on the receiving end reads before deciding whether to block you. Deliberately carries no version — a link checker announces itself to every site an author has ever cited, and a build number there is a permanent broadcast of what to try.config/runtime.exs

editorial tasks (#501)

Safe to leave scheduled everywhere: with no tasks assigned in any org, the digest sweep enqueues nothing.

VariableDefaultPurposeWhere it's read
KILN_TASK_DIGEST_CRON0 8 * * *Oban cron expression for the daily due-soon/overdue task digest email. false (or an unparseable value, which warns on stderr) leaves it unscheduled, for a deployment driving the equivalent itself.config/runtime.exs

Web Push notifications (#628)

Unset ⇒ push is off for every site that hasn't generated its own key: /editor/settings never offers the toggle there and nothing is sent. A site admin can instead generate a key pair for their site at /editor/site-push (#1560), with no variables and no redeploy. These variables stay the default for every site without its own pair, and a device subscribed with them keeps them after its site generates one. Generate a pair with mix kiln.vapid.gen (the same format npx web-push generate-vapid-keys emits, so an existing pair carries over).

Rotating the pair invalidates every live subscription — the push service answers 403, the row is pruned, and reviewers must re-enable notifications on each device. Notifications never carry draft content; see KilnCMS.Push.

VariableDefaultPurposeWhere it's read
KILN_VAPID_PUBLIC_KEYunsetbase64url, unpadded, uncompressed P-256 point (65 bytes). Handed to the browser as applicationServerKey when it subscribes, and published in the VAPID header so a push service can verify the signature. Must be the public half of KILN_VAPID_PRIVATE_KEY — a mismatched pair disables push and logs an error at the first send rather than failing per message months later.config/runtime.exs
KILN_VAPID_PRIVATE_KEYunsetbase64url, unpadded, the 32-byte scalar. A secret: anyone holding it can push a notification to every subscriber of this deployment. Keep it with the rest of the secret store, not in shell history or a committed .env.config/runtime.exs
KILN_VAPID_SUBJECTthe deployment's public base URLA contactable mailto: or https: URL, so a push service operator can reach whoever is sending (RFC 8292 §2.1).config/runtime.exs

events, the "what's on" index (#766)

Safe to leave scheduled everywhere: a site with no event-shaped content does one indexed probe per content type and matches nothing. See docs/events.md.

VariableDefaultPurposeWhere it's read
KILN_OCCURRENCE_SWEEP_CRON50 * * * *Oban cron expression for the sweep that advances next_occurrence_at once an occurrence has gone by. The interval is how stale the listing may be — a finished event keeps its place until the next run — so shorten it on a site whose events turn over during the day. false (or an unparseable value, which warns on stderr) leaves it unscheduled, for a deployment driving KilnCMS.Events.Sweep.run/0 from its own scheduler.config/runtime.exs
KILN_FEDERATION_NONCE_SWEEP_CRON40 * * * *When KilnCMS.Federation.SeenSignatureSweeper removes expired rows from the inbound-signature replay store (#967). Hygiene, not security: an expired row cannot verify anyway. false disables.config/runtime/schedules.exs:40
KILN_HEALTH_SWEEP_CRON30 7 * * *Oban cron expression for the content-freshness sweep: finds published content whose health has gone :overdue/:expired and dispatches one automation event per record, so a rule can turn staleness into an assigned task. Daily is the right period — a review cadence is measured in months. Scheduled before the task digest so a task it raises lands in that morning's email rather than tomorrow's. Safe everywhere: with no review cadences set it matches nothing. false (or an unparseable value, which warns on stderr) leaves it unscheduled, for a deployment driving KilnCMS.CMS.HealthSweep.run/0 itself. See docs/content-lifecycles.md.config/runtime.exs
KILN_OCCURRENCE_BACKFILL_ON_BOOTtrueWhether booting enqueues the one-off backfill that gives pre-existing content its first next_occurrence_at. On, because the alternative is an upgrade step someone has to remember and the index is empty until they do. Deduplicated for a day at the database level, so a rolling deploy queues one job across replicas; a redundant pass writes nothing, which also makes it a repair pass for a value knocked out of sync. Set false to run mix kiln.occurrences.backfill yourself.config/runtime.exs

error tracking (Sentry)

Enabled in any environment only when SENTRY_DSN is set; otherwise every Sentry capture is a no-op. See docs/observability.md.

VariableDefaultPurposeWhere it's read
SENTRY_DSNunsetSentry DSN (https://<publickey>@o0.ingest.sentry.io/0, or your own host when self-hosting). Enables error reporting when set. Where to get it: the Sentry project's Settings → Client Keys (DSN). It is not confidential in the strict sense — browser SDKs ship it publicly — but it does authorize writes to the project, so handle it like a credential anyway.config/runtime/observability.exs:15
SENTRY_ENVconfig_env()Environment name tag for Sentry events.config/runtime/observability.exs:18
RELEASE_VSNunsetRelease version tag (set automatically by the release runtime) to pin regressions to a deploy.config/runtime/observability.exs:21

distributed tracing (OpenTelemetry)

Enabled only when OTEL_EXPORTER_OTLP_ENDPOINT is set, which flips the :otel_enabled flag and points the OTLP exporter at the collector. See docs/observability.md.

VariableDefaultPurposeWhere it's read
OTEL_EXPORTER_OTLP_ENDPOINTunsetOTLP collector endpoint — anything that speaks OTLP: a local otel-collector, Grafana Alloy, Jaeger, or a vendor's ingest URL. Enables tracing when set. Port 4318 is the conventional HTTP port, 4317 gRPC; match it to the protocol below.config/runtime/observability.exs:31
OTEL_SERVICE_NAMEkiln_cmsService name attached to spans. Free-form — this is how the deployment is labelled in the tracing UI.config/runtime/observability.exs:37
OTEL_EXPORTER_OTLP_PROTOCOLhttp_protobufOTLP protocol: http_protobuf, http_json or grpc.config/runtime/observability.exs:41
OTEL_EXPORTER_OTLP_HEADERSunsetStandard OTLP headers, as comma-separated key=value pairs (api-key=abc123,x-tenant=acme) — usually the ingest credential from whichever vendor OTEL_EXPORTER_OTLP_ENDPOINT points at. Honored by the exporter library, not by Kiln.OpenTelemetry exporter (standard OTEL_*)

metrics exporter (Prometheus, #1362)

Off by default. When KILN_METRICS_ENABLED is on, GET /metrics is served in the Prometheus text format on a listener of its own. It is never a route on the public endpoint. With the exporter off, no port is opened and nothing records the metrics. See docs/observability.md for the exposure options and a scrape config.

VariableDefaultPurposeWhere it's read
KILN_METRICS_ENABLEDfalseStarts the metrics reporter and its /metrics listener. Unrecognized values keep it off and warn.config/runtime/observability.exs:51
KILN_METRICS_PORT9568Port for the /metrics listener. Must differ from PORT. An unparseable value keeps the default and warns. A port above 65535 fails the boot, loudly.config/runtime/observability.exs:57
KILN_METRICS_BINDloopbackloopback (127.0.0.1) suits an agent on the same host or pod. all binds every interface, for a scraper on a private network; don't publish the port. Other values, including an IP address, keep loopback and warn.config/runtime/observability.exs:66
KILN_METRICS_TOKENunsetWhen set, a scrape must send Authorization: Bearer <token>. It is compared in constant time, and anything else gets 401. A blank value counts as unset. Recommended with KILN_METRICS_BIND=all; without a token, that combination logs a warning at boot.config/runtime/observability.exs:72

tamper-evident history & content signing (#356, #340)

Every publish folds the document's version chain into a canonical hash and records it append-only in history_anchors, RSA-signed when a signing key is configured. See editorial-consent.md and KilnCMS.Governance.Chain.

VariableDefaultPurposeWhere it's read
KILN_AUDIT_ANCHORS_ENABLEDtrueThe tamper-evident history master switch (#356). Chain.extend/2 requires both this and KILN_AUDIT_ANCHOR_EVERY_WRITE below, so turning this off is a complete kill switch for anchoring regardless of the per-write setting. Set to false/0/no/off to disable, recoverable at runtime without a rebuild (#611 — before this variable existed, the only way back was a rebuild with :audit_anchors_enabled compiled to true). Only a recognized spelling writes config; an unrecognized value keeps the default (true) — the safe side, opposite of KILN_AUDIT_ANCHOR_EVERY_WRITE's. Ignored under MIX_ENV=test.config/runtime/governance.exs:24
KILN_AUDIT_ANCHOR_EVERY_WRITEfalseSet to true/1/yes/on to anchor every versioned write, not just publishes — #356's "sign every version, not just published artifacts". Closes the window between two publishes, at the cost of one signature and one history_anchors row per save — and of autosave coalescing, which cannot collapse rows an anchor has committed to, so every debounced draft save keeps its own version row (#671; docs/editorial-consent.md has the reasoning). A regulated deployment wants this; a blog does not. Read at runtime so it can be turned off without rebuilding the image — this governs only the per-write extension; it is a no-op whenever KILN_AUDIT_ANCHORS_ENABLED above is off. Only a recognized spelling writes config, so an unrecognized value keeps the configured default rather than being read as "off" — silently not signing is the dangerous direction. Ignored under MIX_ENV=test so the suite stays deterministic.config/runtime/governance.exs:51
KILN_PROVENANCE_PRIVATE_KEYunsetPKCS#1 RSA private key PEM (BEGIN RSA PRIVATE KEY) used to sign history anchors and C2PA-style content manifests (#340). Unset ⇒ anchors are stored unsigned — still an integrity checksum, but the anchor row itself is no longer tamper-proof, and verify reports :unsigned rather than :verified. The key source is configurable (config :kiln_cms, KilnCMS.Provenance, signing_key:); this var is only the default {:env, …} binding, so a deployment that set signing_key: :dkim or a {:file, …} in source ignores it. It is a multi-line PEM: write it as an escaped one line (double-quoted, each newline a literal \n — unescaped on read, #609) or as a double-quoted true multi-line value, or mount it via KILN_PROVENANCE_KEY_FILE. PKCS#8 is rejected — convert with openssl rsa -in key.pem -traditional -out key-pkcs1.pem (the -out matters — without it the private key streams to stdout instead of a file).config/config.exs:842
KILN_PROVENANCE_KEY_FILEunsetPath to the same PEM, mounted as a file (Docker/K8s secret). Sets signing_key: {:file, …} at runtime — before #608 this shape existed only in config/config.exs, i.e. only with a rebuild. It replaces signing_key wholesale, so it wins over KILN_PROVENANCE_PRIVATE_KEY (mount the file first, unset the var after) but equally over a source-configured :dkim or {:file, …} — setting it switches the signing key, and every new anchor gets a new key_id.config/runtime/feature_gates.exs:84
KILN_PROVENANCE_RETIRED_KEY_FILESunsetComma-separated paths to the public halves of keys that no longer sign but must still verify. Sets :retired_key_files, which KeyRegistry.retired/0 unions with any :retired_keys configured in source — so the env route can only add verification keys, never drop one. (That holds because this var is the sole writer of :retired_key_files; put source config in :retired_keys.) Blank entries are ignored, so a trailing comma is harmless, and a value with no paths at all warns and changes nothing rather than clearing the list. An unreadable path is logged and skipped rather than blinding the keys that do resolve. Paths only — a public key is multi-line too — and since , separates and each entry is trimmed, a path containing a comma or significant leading/trailing whitespace can't be expressed here; use retired_keys in source for those.config/runtime/feature_gates.exs:110
KILN_PROVENANCE_ENABLEDtrueSigned manifests for fired artifacts and the /api/provenance/* endpoints. On by default since 1.7 (#1929): /setup generates a signing key unless KILN_PROVENANCE_KEY_FILE (or KILN_PROVENANCE_PRIVATE_KEY) supplies one, and an install with no key serves its artifacts unsigned with a console banner rather than refusing them. Set to false/0/no/off to produce no manifest and 404 every provenance endpoint. Parsed by the shared on/off rules, so an unrecognized value keeps the default and warns. Ignored under MIX_ENV=test. See provenance.md.config/runtime/feature_gates.exs:34
KILN_FEDERATION_ENABLEDfalseSet to true/1/yes/on to let sites on this deployment be followed from Mastodon and other fediverse servers (#491). This is the deployment half of a two-part gate: with it unset, every federation route 404s no matter what a site admin enabled. Turning it on means the server signs and POSTs to hosts chosen by strangers who followed a site, indefinitely and unattended — if your egress policy forbids that, leave it alone, since no tenant admin can override it. A site still opts in individually (mix kiln.federation enable). Parsed by the shared on/off rules. Ignored under MIX_ENV=test. See federation.md.config/runtime/feature_gates.exs:46
KILN_EXPERIMENTS_ENABLEDfalseSet to true/1/yes/on to serve A/B content experiments (#499). OFF by default, and the deployment gets a say because a page under a running experiment is served private, no-store — with the usual public, max-age=60 a CDN would cache one arm and hand it to every visitor, which is a 100/0 split reported as 50/50. No visitor is tracked unless you also set KILN_EXPERIMENTS_STICKY below: on-site assignment is stateless and headless callers supply their own ?variant_key=. Parsed by the shared on/off rules. Ignored under MIX_ENV=test. See content-experiments-plan.md.config/runtime/feature_gates.exs:56
KILN_EXPERIMENTS_STICKYfalseSet to true/1/yes/on to keep a visitor in the same A/B arm across page loads (#984). This is the one switch that puts a cookie on visitors, so it is deliberately separate from KILN_EXPERIMENTS_ENABLED rather than implied by it. What is stored is a bucket — one integer in 0..99, shared by a crowd, joined to nothing server-side — plus, for a goal that converts on a later page (content_view, funnel_completion), a second cookie _kiln_ab_x naming the arm the visitor was shown (up to 4, each cleared the moment it converts) — the weaker of the two claims, since a combination of arms starts to narrow a visitor down. Both are __Host--prefixed wherever your cookies are Secure, and are only minted on a page actually under experiment. Whether your regime wants consent for it is your call; see data-flows.md. Parsed by the shared on/off rules.config/runtime/feature_gates.exs:66
KILN_EXPERIMENTS_STICKY_DAYS30Lifetime of the sticky cookies above, in days. Long enough to outlive the experiment a visitor is in, short enough not to be a standing marker — a year is the reflex default and would be one. A non-positive or unparseable value warns and keeps the default.config/runtime/feature_gates.exs:72
KILN_PROVENANCE_SIGNER:site_nameHuman-readable signer identity recorded in every manifest a consumer verifies — part of what the deployment publicly asserts about its content (#644). Unset falls back to the site name (which a released image can already set), so it is lower stakes than the three above; set it only to override the claim's signer without a rebuild. A blank value counts as unset. Ignored under MIX_ENV=test.config/runtime/provenance_claims.exs:27
KILN_PROVENANCE_ORIGIN:public_base_urlOrigin URL recorded in the manifest claim (#644). Unset falls back to the deployment's public base URL — PUBLIC_BASE_URL, else https://<PHX_HOST> (#1833); set it only to record a different origin. A blank value counts as unset. Ignored under MIX_ENV=test.config/runtime/provenance_claims.exs:33
KILN_PROVENANCE_AI_DISCLOSUREhumanDefault AI-generation disclosure embedded when a document declares none (an editor can override per-document via custom_fields["ai_disclosure"]). One of human/ai_assisted/ai_generated (case-insensitive). An unrecognized value warns and keeps the default rather than being coerced — it rides into a signed claim, so a typo must not silently rewrite what the deployment asserts (#644). A blank value counts as unset. Ignored under MIX_ENV=test.config/runtime/provenance_claims.exs:54

Rotating the signing key. Verification resolves the key named by each signature's key_id, so pre-rotation anchors and manifests keep verifying — once the outgoing key's public half is registered (KILN_PROVENANCE_RETIRED_KEY_FILES, or retired_keys in source). Register it before destroying the outgoing private half: until it is registered, those signatures resolve to {:error, {:unknown_key_id, …}}, and with the private half gone there is no way to re-derive the public one. Order matters — export the public half, point the var at it, restart, confirm an old anchor still verifies, and only then destroy the private key. See KilnCMS.Provenance.KeyRegistry.

governance checkpoint witness (#666)

Anchors make a document's history tamper-evident against everything except truncation: delete its newest anchors and the surviving prefix still verifies, because nothing inside the document says how many there were. A checkpoint is the statement from outside — a signed, org-wide commitment to every document's head anchor, minted on a schedule and published to a sink the database credentials do not own. See KilnCMS.Governance.Checkpoint and KilnCMS.Governance.Witness.

VariableDefaultPurposeWhere it's read
KILN_GOVERNANCE_WITNESSnoneWhere checkpoints are published: none, file, s3, or http. none keeps the commitment in the database, which still catches an attacker who deletes anchors and forgets chain_checkpoints — and does not survive one who remembers, so it is not the property the feature claims. An unrecognized value keeps none and warns on stderr; note that is the weaker side, so the warning is the only signal a typo left you unwitnessed. Ignored under MIX_ENV=test.config/runtime.exs
KILN_GOVERNANCE_WITNESS_DIRunsetDirectory for the file adapter. Files are created exclusively, so a checkpoint is never overwritten. Point it at something the application user cannot unlink from — an append-only mount, a WORM volume, a spool an off-host agent drains — or it buys distance rather than the property.config/runtime.exs
KILN_GOVERNANCE_WITNESS_BUCKETunsetBucket for the s3 adapter. Use a different bucket from media storage, which is request-path writable and public-read by design. Objects are written with If-None-Match: *, so a re-publish cannot silently replace one; pair that with Object Lock in compliance mode, or a policy denying s3:DeleteObject to the application's principal, or the object is as deletable as the row.config/runtime.exs
KILN_GOVERNANCE_WITNESS_PREFIX""Key prefix inside that bucket.config/runtime.exs
KILN_GOVERNANCE_WITNESS_URLunsetBase URL for the http adapter — a transparency log, or a thin shim in front of one. Listings are followed across pages (Link: rel="next", or a {"keys": [], "next": ""} body) — a log that pages and says nothing about the rest would report the sink as smaller than it is, and for this audit "smaller" reads as no missing rows. Kiln POSTs each checkpoint to <url>/<key> with If-None-Match: * and requires a 201: a 2xx that is not a create is refused, because a service that silently replaces an object answers 200 and looks exactly like one that appended. Overwrite protection still has to come from the log itself; the status check is a guard against a shim that forgot — and note a log answering 200 (RFC 6962 add-chain) or 202 (queued) needs that shim, or nothing publishes.config/runtime.exs
KILN_GOVERNANCE_WITNESS_TOKENunsetBearer token for that endpoint. Read through KilnCMS.Keys' env provider, so it can be pointed at a file or a secret manager by configuring :token as a provider tuple instead. Unset means no Authorization header — an unresolvable token sends none rather than the string "nil".config/runtime.exs
KILN_GOVERNANCE_CHECKPOINT_CRON40 3 * * *Oban cron expression for the checkpoint run. This is a security parameter, not a performance one: anchors minted since the last checkpoint are not yet witnessed, so the window in which a chain can be truncated undetected is exactly one interval. A regulated deployment sets 0 * * * *. Set config :kiln_cms, :governance_checkpoint_cron, false in source to drive it from an external scheduler instead.config/runtime.exs

Publishing is only half of it. A checkpoint nobody reads back is a file. mix kiln.audit.checkpoint --audit re-fetches every published checkpoint and diffs it against the database row, so a deleted or rewritten chain_checkpoints is visible. Run it from a host the application does not control, on credentials that can read the sink and not write it — an audit run by the same role that writes the checkpoints only establishes that a system agrees with itself.

Unsigned deployments. With no KILN_PROVENANCE_PRIVATE_KEY, a checkpoint's root and links are ordinary columns an attacker can recompute, and every verdict is floored to :unsigned regardless. The structural checks still run and still report, so a truncation is still named — but treat it as advisory: it raises the cost of a forgery, it does not attest anything. Configure a signing key before relying on any of this.

upstream update check

The admin system page (/editor/system) reports whether a newer Kiln release exists. The check is made only when an admin opens the page, and asks in two legs (#1877): first the release feed on kilncms.dev — the public published-entries API every Kiln serves, filtered to its release entries — and, only if that fails in any way, the GitHub releases API. An outage of kilncms.dev therefore only changes where the answer comes from. Results are cached for 24 hours (15 minutes when both legs failed) and manual re-checks are floored at one per minute. See docs/releasing.md and Kiln.Updates.

What is sent. Each request is an unauthenticated GET with a bare KilnCMS user-agent: no version, no host name, no instance identifier, no cookie or credential. The feed request's only query parameters are its fixed type filter, field list and page size (?filter[type_name]=release&fields[entry]=custom_fields&page[limit]=100); the GitHub request has none. Nothing about this deployment is disclosed beyond the IP address the request comes from.

kilncms.dev keeps no client IPs for the feed route. The application does not log the client address for that route, and keeps it off any error report (KilnCMSWeb.SentryScrubber); the reverse proxy in front of kilncms.dev is configured not to log it either. GitHub's own logging of the fallback request is GitHub's.

To opt out, set KILN_UPDATE_CHECK=false: no request is made to either service, and the page reports the running version and the update command without the comparison. To keep the check but skip kilncms.dev, set KILN_UPDATE_FEED_URL=false (GitHub only).

This is the only outbound integration that is on by default. Unlike Meilisearch, S3, Unsplash and mail, it needs no credential to work, so there is no unset-secret that implicitly disables it — set KILN_UPDATE_CHECK=false if your deployment must make no third-party requests at all.

If you run a fork, set KILN_UPDATE_REPO. The default compares this build against The-Verscienta/kiln_cms, which for a fork is an answer about someone else's code — and the failure is silent in the direction that matters: a fork ahead of upstream compares as newer, so the page reports "Up to date" indefinitely and the fork's own security releases never surface.

Setting KILN_UPDATE_REPO (to anything but the canonical repo) or KILN_UPDATE_RELEASES_URL also stops the kilncms.dev feed from being asked, since it describes upstream's releases: a fork or an internal mirror goes straight to its own endpoint. A fork that publishes its own release entries on its own Kiln site opts back in by setting KILN_UPDATE_FEED_URL to that site's feed.

GIT_SHA and BUILD_DATE are Docker build args, not runtime variables — the Dockerfile records them as KILN_GIT_SHA / KILN_BUILD_DATE so a running instance can name the commit it was built from. Without GIT_SHA it takes the SOURCE_COMMIT build arg, which Coolify passes when the app's Include Source Commit in Build setting is on. Omitting them is harmless; the page then reports the version alone.

VariableDefaultPurposeWhere it's read
KILN_UPDATE_CHECKenabledSet to an off-spelling for an instance that must make no outbound requests: neither the kilncms.dev feed nor GitHub is asked.config/runtime/updates.exs:43
KILN_UPDATE_REPOThe-Verscienta/kiln_cmsThe owner/name this build compares itself against. Forks must set this. Left at the default, a fork is told about upstream's releases — and a fork ahead of upstream compares as newer, so the page reports "Up to date" forever and the fork's own security releases never surface. A value that isn't owner/name is rejected, not ignored.config/runtime/updates.exs:73, Kiln.Updates
KILN_UPDATE_RELEASES_URLderived from KILN_UPDATE_REPOFull releases-API endpoint, for GitHub Enterprise or an internal mirror that can't reach api.github.com. Overrides the endpoint only — set KILN_UPDATE_REPO alongside it so the release link has a fallback.config/runtime/updates.exs:79, Kiln.Updates
KILN_UPDATE_FEED_URLhttps://kilncms.dev/api/json/entries/published, only while KILN_UPDATE_REPO and KILN_UPDATE_RELEASES_URL are unsetThe release feed tried before GitHub: a Kiln site's published-entries endpoint, read for its release entries (custom_fields.version etc.); Kiln adds the query. Sends only the bare KilnCMS user-agent and a fixed query — no version or instance identifier — and kilncms.dev keeps no client IPs for it. Any failure falls back to GitHub. An off-spelling (false, off, …) makes the check GitHub-only; blank means unset. A value that isn't an absolute http(s) URL is logged and the check uses GitHub. A fork sets this to its own site to use a feed at all — see above.config/runtime/updates.exs:92, Kiln.Updates
KILN_PIN_PATHunsetPath to this project's pinned Kiln checkout (kiln/upstream, upstream, …). Display only: the update page prefixes its mix kiln.update command with a matching cd. Unset by default because the pin's path is a downstream choice — see projects/README.md.config/runtime/updates.exs:56, Kiln.Updates
KILN_GIT_SHAunsetCommit the image was built from. Set via --build-arg GIT_SHA, else --build-arg SOURCE_COMMIT (what Coolify passes).Kiln.Version
KILN_BUILD_DATEunsetISO-8601 UTC build timestamp. Set via --build-arg BUILD_DATE.Kiln.Version

referrer attribution (#619)

Coarse "where did readers come from" categories (direct / internal / search / social / other) per content item per UTC day — never a raw referrer URL or host. Off by default; enabling requires no rebuild. See docs/advanced-analytics-plan.md and docs/data-flows.md.

VariableDefaultPurposeWhere it's read
KILN_ANALYTICS_REFERRERSdisabledEnable recording classified referrer buckets (KilnCMS.Analytics.ReferrerDay). Turning it back off stops new writes and hides the dashboard breakdown/export columns, but does not delete buckets already recorded — those age out on the normal retention purge like any other bucket.config/runtime/analytics.exs:20
KILN_ANALYTICS_LOW_COUNT_THRESHOLD5Below this many hits, the dashboard breakdown (#620) and the export render a referrer category as "< n" rather than an exact number — a very small count can describe a single visitor's arrival. A second category is also rendered as hidden whenever any is naturally low, so that a single "< n" is not the sole unknown among four exact numbers (#620, #777); hidden is not "< n", since a complementary-suppressed count can be at or above the threshold. The partner is the largest of the others, which is what makes the residual split many ways instead of one (#1073) — so raising this hides bigger categories, not just more of them. Where no partner makes the split ambiguous — a handful of views with four genuine zeros — the whole breakdown is hidden, zeros included, because a published 0 is one unknown removed from an equation that has only one. That is the exactness this gives up: on the lowest-traffic days the breakdown says nothing at all rather than saying something recoverable. Must be a positive integer; an unparseable or non-positive value keeps the default and warns rather than being interpreted (e.g. 0, which would silently disable suppression).config/runtime/analytics.exs:39

environment indicator

A coloured strip across the top of every console page naming this deployment (#469). A scrubbed staging clone keeps production's content and branding, so the two consoles are otherwise byte-for-byte identical — which is what invites "I edited the wrong environment".

Unset means no strip, so production stays clean with nothing configured. It is the environment you recognise by the absence of a label; staging and development are the ones that set these.

VariableDefaultPurposeWhere it's read
KILN_ENV_LABELunsetThe name shown in the strip (staging, dev, qa). Unset ⇒ no strip at all.KilnCMS.Environment
KILN_ENV_COLORwarningWhich design-kit tone to render it in: warning, error, info, success. A tone name, not a hex — the kit pairs each tone with an -ink text token chosen to clear WCAG contrast against that tone's tint, and a supplied colour would take the tint while keeping ink picked for a different one. An unrecognized name logs a warning and falls back to warning.KilnCMS.Environment

mix kiln.staging.scrub and scripts/staging.sh up both close with a reminder to set this. Neither can set it: the scrub runs as a throwaway process against a remote DATABASE_URL, not inside the application that will serve the clone.

A demo deployment (below) is labelled demo when KILN_ENV_LABEL is unset, and its strip also says when the next reset is due.

demo mode

A public "try the editor" instance that resets to a golden snapshot on a schedule. Hard off by default. Every reset also refuses unless the database name and PHX_HOST both contain demo and the snapshot was dumped from a demo database, and none of those checks can be overridden. Demo mode also makes mail inert (logged, not delivered) and switches federation off. See demo-mode.md.

VariableDefaultPurposeWhere it's read
KILN_DEMO_RESETunsetEnables demo mode. A sentinel word, not a boolean: only confirm (trimmed, any case) enables it, so true deliberately does not, and the on/off rules above do not apply. Any other value is refused with a warning, which also appears in the boot-time config warnings.config/runtime/demo.exs:25
KILN_DEMO_GOLDEN_PATH$BACKUP_DIR/demo/golden.dumpWhere the golden snapshot lives. KilnCMS.Demo.capture_golden!/1 writes it and every reset reads it. The deferred media deletes are logged in the same directory, so it needs a persistent volume writable by nobody.config/runtime/demo.exs:28
KILN_DEMO_RESET_CRON0 * * * * (hourly)When the reset runs. Blank means the default. false keeps demo mode on but drops the schedule, so resets only run by hand. An invalid expression costs the schedule, not the boot.config/runtime/demo.exs:37

seeding and staging

Not read at boot: these are consumed by a seed script and by the staging-scrub task, so they matter only for the command that reads them.

VariableDefaultPurposeWhere it's read
ADMIN_EMAIL[email protected]Email of the admin user created by mix run priv/repo/seeds.exs.priv/repo/seeds.exs:78
ADMIN_PASSWORDkilnadmin123That user's password. The default is a published credential — override it for anything reachable from a network.priv/repo/seeds.exs:79
EDITOR_EMAIL[email protected]Email of the seeded editor user.priv/repo/seeds.exs:80
EDITOR_PASSWORDkilneditor123That user's password; same caveat as ADMIN_PASSWORD.priv/repo/seeds.exs:81
ALLOW_PROD_SEEDSunsetLets the seed script run under MIX_ENV=prod at all. A sentinel word, not a boolean: only the literal confirm opts in, and even then the script refuses unless ADMIN_PASSWORD and EDITOR_PASSWORD are both overridden (#1651). A production site's first admin comes from /setup, not from here.priv/repo/seeds.exs:21
KILN_STAGING_SCRUBunsetAuthorizes the destructive scrub in a release (the Mix task uses --yes). A sentinel word, not a boolean — only the literal confirm authorizes it, so true deliberately does not, and the on/off rules above do not apply to this one.KilnCMS.Staging
KILN_STAGING_FORCEfalseSkip the ephemeral-database-name check that stops a scrub from running against something that doesn't look like a clone. This one is a boolean and follows the shared spellings — it previously matched the literal 1, so KILN_STAGING_FORCE=true read as "not forced".KilnCMS.Staging
STAGING_ADMIN_EMAILunsetAdmin account minted in the scrubbed clone. Unset (with no --admin-email) ⇒ the scrub reports staging admin: NONE and nobody can sign in to the clone, since the scrub removes the real users.KilnCMS.Staging
STAGING_ADMIN_PASSWORDunsetThat account's password.KilnCMS.Staging

See staging-environments.md for the full clone → scrub → serve procedure.

Test / CI only

These are read by config/test.exs and config/e2e.exs and are not relevant to production.

VariableDefaultPurposeWhere it's read
MIX_TEST_PARTITIONunsetSuffix appended to the test database name for partitioned test runs — also what keeps two concurrent worktrees off the same database.config/runtime.exs:212
KILN_STRICT_TESTunsetSet to an on-spelling (true/1/yes/on) to select the strict-tenancy CI leg: :strict_tenancy is flipped on so tenancy scoping compiles fail-closed, and the suite runs only the strict_tenancy-tagged tests. Uses the same spellings as every other flag in this document, via the standalone config/strict_test_flag.exs (#646) — it can't call KilnCMS.Config.Env directly because it's read in config/test.exs, which cannot call project modules. An unrecognized value stays non-strict and warns on stderr, like every other flag: without that the strict leg runs zero tests and exits 0, which is indistinguishable from never having invoked it.config/test.exs:362
KILN_MLunsetSet to an on-spelling (true/1/yes/on) to put the optional ML stack — Bumblebee, Nx and EXLA — into the dependency tree (#1321). Unset, mix deps.get fetches 102 MB instead of 773 MB and skips a one-time 110 MB XLA archive download, because semantic search ships disabled. Read in mix.exs (it decides deps/0) and in config/dev.exs / config/test.exs (which point Nx at EXLA.Backend only when it is on), so like KILN_STRICT_TEST it goes through a standalone snippet — config/ml_flag.exs — that cannot call KilnCMS.Config.Env. It is a build-time flag: it must be set for mix deps.get and mix compile, and exporting it afterwards changes nothing on its own. KilnCMS.Search.ML.available?/0 reports what actually compiled, and mix kiln.ml.note prints it. An unrecognized value stays off and warns on stderr.config/ml_flag.exs:37
POSTGRES_USERpostgresE2E database user.config/e2e.exs:28
POSTGRES_PASSWORDpostgresE2E database password.config/e2e.exs:29
POSTGRES_HOSTlocalhostE2E database host.config/e2e.exs:30
POSTGRES_DBkiln_cms_e2e_<checkout dirname>E2E database name — partitioned per checkout by default (#1353) so sibling worktrees never share persistent E2E data.config/e2e.exs:31

How values are parsed

The shared rules behind the tables above — worth reading once, and the reason a mistyped flag can no longer flip anything in either direction.

Boolean variables

Every boolean variable in this document is parsed by one shared function, KilnCMS.Config.Env, so the rules below hold for all of them (PHX_SERVER is a partial exception — see its row):

  • Accepted spellings. true / 1 / yes / on and false / 0 / no / off. Values are trimmed and lower-cased first, so TRUE, On and " true " all work.
  • Unset or blank (FOO=, a common .env and --env-file artifact) means the variable was not set — the default in the table applies.
  • Anything else keeps the default and warns. A misspelling is never interpreted — it cannot flip a flag in either direction. For the switches that default to on (DATABASE_SSL, SMTP_TLS, SMTP_TLS_VERIFY) that means a typo can no longer turn TLS off, which is the whole point of #606. For a switch that defaults to off, the flip side holds: a typo leaves it off, so if you set KILN_AUDIT_ANCHOR_EVERY_WRITE to turn signing on, the warning is the only signal that it didn't take.

Where that warning goes

Two places, because one of them is not enough (#634):

  • stderr, at boot. Config providers run before Logger exists, so this is all that is available at the moment the value is read. In a release it lands in container stdout — docker logs — and is forwarded nowhere. If it scrolls past during a deploy it is gone.
  • Logger, once the application is up, at warning level. Every unrecognized read is carried out of config/runtime.exs in :kiln_cms, :config_warnings and replayed as soon as observability is attached, so it goes through the normal logging pipeline — formatted, timestamped, and picked up by whatever collects the application's output.
  • Sentry, as a warning-level message, when SENTRY_DSN is set. This is reported explicitly rather than left to the log line: Sentry's logger handler runs at level: :error with capture_log_messages: false, so a Logger.warning never reaches it. Issues are grouped per variable, so a flag that stays misspelled is one issue rather than a new one on every restart.

Only variables that hold a flag, a count, or a short constrained value like an enum spelling or a colour go through this. Nothing here echoes a credential; a variable carrying a secret is read elsewhere and its value is never logged.

Until #607 each variable had its own parser, and two of them matched the raw string: DATABASE_SSL=True silently gave you a plaintext Postgres connection (#606), and VISUAL_EDITING_ENABLED=False left the bridge on.

Count variables

The variables that hold a positive integer — KILN_READING_TIME_WPM, KILN_API_CACHE_MAX_AGE, KILN_API_CACHE_SWR, KILN_ANALYTICS_LOW_COUNT_THRESHOLD, KILN_EXPERIMENTS_STICKY_DAYS, BACKUP_KEEP_DAYS and BACKUP_STALE_AFTER_HOURS — go through the same module, as Env.positive_integer/1 (#1009). Before that each had hand-rolled its own Integer.parse, its own positivity check and its own warning.

  • Unset or blank (including whitespace-only) means the variable was not set, exactly as for a flag — the default in the table applies, silently.
  • Zero and negatives are refused, not read literally. Every one of these is a rate, a window or a retention, where 0 reads as "never" or "delete everything" rather than as "unset": BACKUP_KEEP_DAYS=0 taken at face value would delete the backup it had just taken.
  • A partly-numeric value is refused, not truncated. BACKUP_KEEP_DAYS=7 days keeps the default rather than quietly becoming 7 — the operator meant a week and would otherwise never learn the unit was wrong.
  • Anything above 2147483647 is refused (#1091). Elixir integers have no upper bound, so without this a digit slip was accepted where a letter slip warned — BACKUP_KEEP_DAYS=144444444444444 parsed cleanly into a four-billion-year retention. The ceiling is not a claim about a sensible value; every real one here is smaller by orders of magnitude, so what it catches is a typo.
  • Anything refused keeps the default and warns, through all three sinks above. The count case is what #1009 added to that replay: the hand-rolled parsers wrote to stderr and stopped there, so a mistyped count reached neither Logger nor Sentry. The replayed line names the shape it wanted — a count is told to write a positive integer, not offered the boolean spellings.

Fractional and list variables

The relevance-floor variables (#1936) go through the same module, on the same terms: unset or blank means unset, anything refused keeps the default and warns through all three sinks, and the warning names the shape wanted.

  • Env.float_in/4 reads a number within stated bounds — SEARCH_SEMANTIC_MAX_DISTANCE in [0, 2], SEARCH_KEYWORD_ANY_IDF_COVERAGE in (0, 1]. Unlike a count, zero is a value where the bounds allow it: a floor of 0.0 is the model-swap setting. Out-of-range values (3.5 for 0.35) and partly-numeric ones (0.35 cosine) are refused, not clamped or truncated.
  • Env.list_of/3 reads a comma-separated list of known names — SEARCH_SEMANTIC_FLOOR_LEGS. One unknown name refuses the whole value rather than being dropped, as does a list missing a required name, so a typo can never leave the floor judging less than the operator wrote.

Compile-time application config (not environment variables)

Every variable above is read from the process environment when its config file is evaluated — almost all of them by config/runtime.exs, at boot. The two keys below are not environment variables at all. They are Application.compile_env/3 reads, the same mechanism :secure_session_cookie uses, because KilnCMSWeb.Endpoint's @session_options is a module attribute: it is fixed when the endpoint compiles, not when it starts (see KilnCMSWeb.SessionCookie). Exporting a shell variable at boot changes nothing.

A downstream deployment sets them in its overlay's config/project.exs, which config/config.exs imports last so an overlay can override the core's config:

config :kiln_cms,
  session_signing_salt: "…",
  session_encryption_salt: "…"
Config keyDefaultPurpose
:session_signing_salt"Dsoh9oKb"Combined with secret_key_base (PBKDF2, via Plug.Crypto.KeyGenerator) to derive the session cookie's signing key. Not a secret by itself — secret_key_base carries the real entropy — but every deployment built from this open-source tree derives its keys from the same public value unless it overrides this. Changing it signs every user out.
:session_encryption_salt"8fso5iqxDfI"The same derivation, for the cookie's encryption key. The same sign-out warning applies.

Both must be a non-empty string, and anything else fails the build. For the encryption salt that is not pedantry: Plug.Session.COOKIE reads a nil :encryption_salt as "sign, don't encrypt", with no error or warning, so the session's contents would become readable by anyone holding the cookie. The likeliest way to get a nil is to wire the key to System.get_env/1 in config/project.exs: a release is compiled in its own build stage, where that variable is usually not set, so a hardcoded value is the safer choice here.

A third public salt of the same family, live_view: [signing_salt: …] on the endpoint in config/config.exs, is not compile-time. It is endpoint config read when the endpoint starts, so config/project.exs could always override it with no code change. It signs LiveView session tokens, not the session cookie.