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:
- Required (3) — the only variables without which a production release refuses to start. There are three.
- Common (10) — not required, but what a real deployment usually sets. A short list, with a one-line reason for each.
- 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 amix release/MIX_ENV=prodbuild). In dev and test, sensible defaults fromconfig/dev.exsandconfig/test.exsare used instead, so you do not need to set these locally. The exceptions — read in every environment, because they sit outside theconfig_env() == :prodfragments — are:
PHX_SERVER,PORT,CSP_IMG_SRC,UNSPLASH_ACCESS_KEY,CORS_ORIGINS,KILN_READING_TIME_WPM,VISUAL_EDITING_ENABLED, theKILN_API_CACHE*andKILN_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, theKILN_UPDATE_*group (plusKILN_PIN_PATH),KILN_ANALYTICS_REFERRERS,KILN_ANALYTICS_LOW_COUNT_THRESHOLD,SENTRY_DSN/SENTRY_ENV/RELEASE_VSN, and theOTEL_*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, theKILN_GOVERNANCE_WITNESS*group (plusKILN_GOVERNANCE_CHECKPOINT_CRON), and theKILN_PROVENANCE_*group, and theKILN_DEMO_*group.MIX_TEST_PARTITIONandKILN_STRICT_TESTare the reverse — test-only.KILN_MLis neither: it is read at build time, bymix.exsitself.
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.
| Variable | Purpose | Where it's read |
|---|---|---|
DATABASE_URL | Postgres connection string, e.g. ecto://USER:PASS@HOST/DATABASE. Raises if missing. | config/runtime/prod/database.exs:11 |
SECRET_KEY_BASE | Signs/encrypts session cookies and other secrets. Generate with mix phx.gen.secret. Raises if missing. | config/runtime/prod/web.exs:16 |
TOKEN_SIGNING_SECRET | Signs 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.
| Variable | Why you probably want it | Details |
|---|---|---|
PHX_HOST | Your 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_SERVER | A 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 |
PORT | The port to bind, when 4000 is not what your platform routes to. | server & networking |
POOL_SIZE | Postgres pool size. The default of 10 is shared by web requests and Oban workers, so a busy instance wants more. | server & networking |
TRUSTED_PROXIES | Behind 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_SSL | On by default, and worth knowing before you turn it off: it is the switch a typo used to silently disable (#606). | database TLS |
MAIL_MODE | Without 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_EMAIL | The address mail is sent from; its domain is the sending and DKIM-signing domain under MAIL_MODE=direct. | outbound email |
S3_BUCKET | Object 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_DSN | Error 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
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
PHX_SERVER | unset ⇒ no HTTP | Set 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_HOST | the platform's hostname, else example.com | Public 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_URL | https://<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 |
PORT | 4000 | HTTP listen port the Bandit server binds to. | config/runtime.exs:72 |
CHECK_ORIGINS | unset | Comma-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_ORIGINS | unset | Comma-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_ORIGINS | unset ⇒ same-origin only | Comma-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_LOCKED | unset (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_ENABLED | true | Set 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_STRIP | false | Refuse 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_HOST | unset | Serve 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_MODE | sync | deferred 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_CRON | 20 * * * * | 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_HOURS | 24 | How 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_KEY | unset ⇒ derived from SECRET_KEY_BASE | HMAC 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_UNSIGNED | true | Set 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_AVIF | false | Let 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_URL | unset | The 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_SIZE | 10 | Ecto database connection pool size. See the pool-sizing formula in docs/performance.md. | config/runtime/prod/database.exs:50 |
ECTO_IPV6 | unset | Set to an on-spelling to connect to Postgres over IPv6. | config/runtime/prod/database.exs:17 |
PREVIOUS_SECRET_KEY_BASE | unset | Set 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_PROXIES | unset | Comma-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_HEADER | unset | Fly.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_QUERY | unset | DNS query for libcluster-style node discovery. | config/runtime/prod/web.exs:81 |
KILN_READING_TIME_WPM | 230 | Words 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_SRC | unset | Space-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 onPORT. The expected topology is a TLS-terminating reverse proxy on 443 forwarding to the app onPORT. Public links (PUBLIC_BASE_URL) follow the same rule unless you set that variable.
background jobs (Oban)
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_OBAN_RESCUE_AFTER_MINUTES | 180 | How 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
API_DOCS_ENABLED | on outside prod, off in prod | Serve 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_ENABLED | on outside prod, off in prod | Answer 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_API_CACHE | true | Set 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_AGE | 60 | max-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_SWR | 60 | stale-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_URL | unset | When 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_TOKEN | unset | Credential 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_HEADER | authorization | Header 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
TENANT_BASE_HOST | PHX_HOST | The 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_HOST | unset (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
/uploadsunder the local storage adapter. BothPlug.Staticmounts run earlier in the endpoint and halt on a match, so an asset URL answers on anyHost. 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 asHostkeeps 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 orcustom_domain, and thePHX_HOSTapex 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
503withretry-after, not a404. 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
SITE_NAME | KilnCMS | Instance 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_URL | unset | Logo 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_URL | unset | Favicon URL. Same CSP_IMG_SRC caveat as the logo. | config/runtime/prod/web.exs:197 |
BRAND_PRIMARY_COLOR | unset | Hex 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)
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
UNSPLASH_ACCESS_KEY | unset | The 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
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
DATABASE_SSL | true | Encrypt 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_CACERTFILE | unset | Path 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_MEDIA_ROOT | unset | A 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_BUCKET | unset | Enables 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_REGION | us-east-1 | Region. Use auto for Cloudflare R2; a real region for B2/Wasabi/AWS. | config/runtime/prod/storage.exs:73 |
S3_ACL | unset | Per-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_BUCKET | unset | A 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_HOST | unset | Custom endpoint host for non-AWS stores (R2/B2/Wasabi/MinIO). Leave unset for AWS S3. | config/runtime/prod/storage.exs:77 |
S3_ENDPOINT_SCHEME | https:// | Scheme for the custom endpoint. | config/runtime/prod/storage.exs:79 |
S3_ENDPOINT_PORT | 443 | Port 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
BACKUP_ENABLED | true | Set 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/kiln | Where backups land, for both paths. | config/runtime/prod/backups.exs:47 |
BACKUP_KEEP_DAYS | 14 | Local 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_HOURS | 36 | How 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_DIR | KILN_MEDIA_ROOT, else unset | Uploads 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 installspostgresql-client-17, and the major version must match your Postgres server —pg_dumprefuses to run against a newer one. Bump the pin in theDockerfilealongside 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.
| Variable | Purpose | Where it's read |
|---|---|---|
OIDC_CLIENT_ID | Client id registered at the IdP | config/runtime.exs |
OIDC_CLIENT_SECRET | Client secret (client_secret_basic) | config/runtime.exs |
OIDC_ISSUER | Provider base URL (OIDC discovery) | config/runtime.exs |
OIDC_REDIRECT_URI | This site's callback base, e.g. https://cms.example.com/auth | config/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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
MAIL_MODE | unset | smtp = 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_EMAIL | unset | From 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_NAME | KilnCMS | Display name for the From address. | config/runtime/prod/mailer.exs:143 |
SMTP_HOST | unset | Relay host. Required when MAIL_MODE=smtp; setting it without MAIL_MODE also selects smtp mode. | config/runtime/prod/mailer.exs:68 |
SMTP_PORT | 587 | Relay 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_USERNAME | unset | Relay 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_PASSWORD | unset | Relay password; see SMTP_USERNAME for where it comes from. | config/runtime/prod/mailer.exs:101 |
SMTP_TLS | true | STARTTLS to the relay. Set to an off-spelling only for a local dev/test relay. | config/runtime/prod/mailer.exs:85 |
SMTP_TLS_VERIFY | true | Verify 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_HOST | PHX_HOST | Direct 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_KEY | unset | Direct 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
MEILI_URL | unset | Meilisearch server URL. Enables the backend when set. | config/runtime/prod/search.exs:13 |
MEILI_MASTER_KEY | unset | Meilisearch API master key. | config/runtime/prod/search.exs:17, lib/kiln_cms/search/meilisearch.ex:14 |
MEILI_INDEX | kiln_content | Index name. | config/runtime/prod/search.exs:18 |
search (chunked document embeddings)
Semantic search only (config :kiln_cms, KilnCMS.Search, semantic: true).
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
SEARCH_CHUNK_LONG_DOCUMENTS | unset (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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
SEARCH_SEMANTIC_MAX_DISTANCE | unset (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_LEGS | unset (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_COVERAGE | unset (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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_HNSW_EF_SEARCH | unset (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_EXACT | unset (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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
SEO_MODEL | unset | req_llm model spec, e.g. ollama:llama3.1 or anthropic:claude-sonnet-5. Enables drafting when set. | config/runtime/prod/ai.exs:22 |
SEO_GENERATOR | KilnCMS.Seo.Generator.ReqLLM | Override the adapter module with your own KilnCMS.Seo.Generator. | config/runtime/prod/ai.exs:24 |
ANTHROPIC_API_KEY, OPENAI_API_KEY, … | unset | Provider 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
ASSIST_MODEL | unset | req_llm model spec, e.g. ollama:llama3.1. Enables block assist when set. | config/runtime/prod/ai.exs:45 |
ASSIST_GENERATOR | KilnCMS.Assist.Generator.ReqLLM | Override 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
ASK_MODEL | unset | req_llm model spec, e.g. ollama:llama3.1. Enables generated answers when set. | config/runtime/prod/ai.exs:73 |
ASK_GENERATOR | KilnCMS.Ask.Generator.ReqLLM | Override the adapter module with your own KilnCMS.Ask.Generator. | config/runtime/prod/ai.exs:75 |
ASK_RERANK | unset (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_MODE | unset (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_MODE | unset (SEARCH_RERANK_MODE's) | As SEARCH_RERANK_MODE, for /api/ask alone. | config/runtime/prod/ai.exs:128 |
ASK_RERANK_SCOPE | unset (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_PIN | unset (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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
OEMBED_ENABLED | false | Resolve oEmbed metadata so embeds render as cards. | config/runtime/prod/oembed.exs:27 |
OEMBED_PROVIDERS | unset (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 |
outbound link checking (#474)
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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_LINK_CHECK_CRON | 20 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_AGENT | KilnCMS-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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_TASK_DIGEST_CRON | 0 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_VAPID_PUBLIC_KEY | unset | base64url, 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_KEY | unset | base64url, 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_SUBJECT | the deployment's public base URL | A 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_OCCURRENCE_SWEEP_CRON | 50 * * * * | 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_CRON | 40 * * * * | 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_CRON | 30 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_BOOT | true | Whether 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
SENTRY_DSN | unset | Sentry 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_ENV | config_env() | Environment name tag for Sentry events. | config/runtime/observability.exs:18 |
RELEASE_VSN | unset | Release 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | unset | OTLP 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_NAME | kiln_cms | Service 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_PROTOCOL | http_protobuf | OTLP protocol: http_protobuf, http_json or grpc. | config/runtime/observability.exs:41 |
OTEL_EXPORTER_OTLP_HEADERS | unset | Standard 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_METRICS_ENABLED | false | Starts the metrics reporter and its /metrics listener. Unrecognized values keep it off and warn. | config/runtime/observability.exs:51 |
KILN_METRICS_PORT | 9568 | Port 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_BIND | loopback | loopback (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_TOKEN | unset | When 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_AUDIT_ANCHORS_ENABLED | true | The 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_WRITE | false | Set 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_KEY | unset | PKCS#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_FILE | unset | Path 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_FILES | unset | Comma-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_ENABLED | true | Signed 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_ENABLED | false | Set 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_ENABLED | false | Set 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_STICKY | false | Set 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_DAYS | 30 | Lifetime 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_name | Human-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_url | Origin 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_DISCLOSURE | human | Default 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, orretired_keysin 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. SeeKilnCMS.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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_GOVERNANCE_WITNESS | none | Where 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_DIR | unset | Directory 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_BUCKET | unset | Bucket 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_URL | unset | Base 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_TOKEN | unset | Bearer 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_CRON | 40 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 --auditre-fetches every published checkpoint and diffs it against the database row, so a deleted or rewrittenchain_checkpointsis 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:unsignedregardless. 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_UPDATE_CHECK | enabled | Set 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_REPO | The-Verscienta/kiln_cms | The 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_URL | derived from KILN_UPDATE_REPO | Full 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_URL | https://kilncms.dev/api/json/entries/published, only while KILN_UPDATE_REPO and KILN_UPDATE_RELEASES_URL are unset | The 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_PATH | unset | Path 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_SHA | unset | Commit the image was built from. Set via --build-arg GIT_SHA, else --build-arg SOURCE_COMMIT (what Coolify passes). | Kiln.Version |
KILN_BUILD_DATE | unset | ISO-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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_ANALYTICS_REFERRERS | disabled | Enable 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_THRESHOLD | 5 | Below 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_ENV_LABEL | unset | The name shown in the strip (staging, dev, qa). Unset ⇒ no strip at all. | KilnCMS.Environment |
KILN_ENV_COLOR | warning | Which 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
KILN_DEMO_RESET | unset | Enables 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.dump | Where 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_CRON | 0 * * * * (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.
| Variable | Default | Purpose | Where 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_PASSWORD | kilnadmin123 | That 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_PASSWORD | kilneditor123 | That user's password; same caveat as ADMIN_PASSWORD. | priv/repo/seeds.exs:81 |
ALLOW_PROD_SEEDS | unset | Lets 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_SCRUB | unset | Authorizes 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_FORCE | false | Skip 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_EMAIL | unset | Admin 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_PASSWORD | unset | That 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.
| Variable | Default | Purpose | Where it's read |
|---|---|---|---|
MIX_TEST_PARTITION | unset | Suffix 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_TEST | unset | Set 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_ML | unset | Set 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_USER | postgres | E2E database user. | config/e2e.exs:28 |
POSTGRES_PASSWORD | postgres | E2E database password. | config/e2e.exs:29 |
POSTGRES_HOST | localhost | E2E database host. | config/e2e.exs:30 |
POSTGRES_DB | kiln_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/onandfalse/0/no/off. Values are trimmed and lower-cased first, soTRUE,Onand" true "all work. - Unset or blank (
FOO=, a common.envand--env-fileartifact) 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 setKILN_AUDIT_ANCHOR_EVERY_WRITEto 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
Loggerexists, 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, atwarninglevel. Every unrecognized read is carried out ofconfig/runtime.exsin:kiln_cms, :config_warningsand 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, whenSENTRY_DSNis set. This is reported explicitly rather than left to the log line: Sentry's logger handler runs atlevel: :errorwithcapture_log_messages: false, so aLogger.warningnever 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
0reads as "never" or "delete everything" rather than as "unset":BACKUP_KEEP_DAYS=0taken at face value would delete the backup it had just taken. - A partly-numeric value is refused, not truncated.
BACKUP_KEEP_DAYS=7 dayskeeps 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=144444444444444parsed 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
Loggernor 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/4reads a number within stated bounds —SEARCH_SEMANTIC_MAX_DISTANCEin[0, 2],SEARCH_KEYWORD_ANY_IDF_COVERAGEin(0, 1]. Unlike a count, zero is a value where the bounds allow it: a floor of0.0is the model-swap setting. Out-of-range values (3.5for0.35) and partly-numeric ones (0.35 cosine) are refused, not clamped or truncated.Env.list_of/3reads 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 key | Default | Purpose |
|---|---|---|
: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.