Overview
A modern, high-performance headless + traditional CMS built on Phoenix · Ash · LiveView · Tailwind · Elixir, with the Ash Framework at its core. Client interactivity is handled by LiveView + colocated JS hooks — no Surface, no Alpine.js.
See KilnCMS_Project_Plan.md for the full vision, architecture, and
the resolved architectural decisions (D1–D8), and docs/path-forward.md
for where Kiln is going from 1.1: live content delivery plus provable, governed publishing.
Status & maturity
Stable (v1.5.1), single maintainer, and consumed as a source overlay
rather than a package. If you are evaluating KilnCMS for a team, read this
section before the feature list.
How it is consumed. KilnCMS is not published on Hex. A downstream site
pins this repository as a git submodule, adds its own content types under
projects/<name>/, and moves the pin between tagged releases with
mix kiln.update (see
Releasing). Your code
layers onto the core; you do not fork it and you do not mix deps.get it. The
interface that layering is written against — which surfaces are promised, and
what a minor release may still do to you — is the overlay contract
(docs/overlay-contract.md).
A prebuilt container image is published to GHCR on every release tag, if you want to run the core without a checkout:
docker pull ghcr.io/the-verscienta/kiln_cms:latest # linux/amd64latest is for trying it out. For a site you keep, pin the exact version
(:1.5.1), or the floating major (:1) if you want patches and
minor releases without editing the tag;
which tag to pin has the
trade-offs.
That image is the project-agnostic core. An overlay builds its own from the
same Dockerfile
with --build-arg PROJECT=<name>.
What 1.0 means for API stability. From 1.0.0 the covered surfaces below
are a promise: removing one, or changing what it means, takes a major release
and goes through the deprecation path. Semver here is interpreted against the
overlay contract, not against every module: major = an overlay needs code
changes to compile, minor = new capability plus possible migrations,
patch = fixes (the full definition is at the top of
CHANGELOG.md). Every surface carries exactly one label; the
detail behind each is in
docs/overlay-contract.md, which carries this same
table:
| Label | What it means | Surfaces |
|---|---|---|
| Covered | A rename, a removal or a change of meaning needs a major release and goes through the deprecation path. Additions are not breaking | The overlay contract: the KilnCMS.CMS.Content options and the two *_pattern token vocabularies; the __kiln_*__/0 functions; the workflow action names on a content resource; the merge-argument convention; Kiln.Plugin callbacks; Kiln.Block and its DSL, the _type and _version attributes, Kiln.Block.Renderer and Kiln.Block.Info; Kiln.FieldType; Kiln.Advisory and Kiln.Forms.SpamCheck; Kiln.SafeFetch; Kiln.Plugins, Kiln.Version, Kiln.Updates and Kiln.Tokens; KilnCMS.Blocks, KilnCMS.Blocks.Upcaster and KilnCMS.CMS.ContentTypes; KilnCMS.Firing.refire/3 and refire!/3; KilnCMS.Migrations; KilnCMS.SchemaExport and KilnCMS.Branding; KilnCMSWeb.PluginRouter and KilnCMSWeb.AshJsonApiRouter; the config keys; the build conventions; the public-* CSS hook classes; the documented environment variables; mix kiln.update with its documented flags, mix kiln.plugins.doctor and mix kiln.search.check. Over HTTP, under the API guide's own, stricter policy: /api/json and /gql, reads and documented writes; /api/auth/sign_in; /api/content/:type/:slug and /api/content/:type/:id/revisions; /api/sync; /api/search (the request and the response, not the order); /api/resolve; /api/menus and /api/menus/:key; /api/ask; /api/locales; /api/schema; /api/provenance/verify/:type/:slug, /api/provenance/:type/:slug and /api/provenance/public-key (provenance is on by default since 1.7); /api/media, /api/media/import-url and /api/media/uploads[/complete]; /api/forms/:slug and embed.js; /mcp; bridge.js; /sitemap.xml, /news-sitemap.xml, /feed.xml and /feed.json; /preview/:token; image transform URLs, /media/:id/t/:ops; and webhook deliveries: event names, payloads, and the x-kilncms-webhook-signature scheme |
| Covered, off by default | Not a fourth label. These ship switched off and are supported when enabled: bugs in them are fixed like any other, and "off" is a default, not a stability rating. Each one's switch is a covered environment variable or config key, its console screens are internal like every other admin path, and its HTTP surface, where it has one, is covered | AI block assist; the SEO generator; content experiments; oEmbed cards; demo mode; the compliance gates; referrer analytics; SSO (OIDC) and two-factor authentication, both compile-gated; the per-site integrations for AI, search, storage, push, SSO and mail |
| Internal | May change in any release, including a patch, without a deprecation | Everything under KilnCMSWeb.* except the two routers above: LiveViews, components, the component kit's class names, and the /editor/... admin paths, including the per-site integration screens; AshAdmin at /admin; KilnCMS.* internals not named above (changes, calculations, workers, and anything @doc false); the internal write actions a content resource carries for its own bookkeeping; the console's side-* classes and admin design tokens; core Oban queue names; search ranking behaviour and its tuning defaults; the generated GraphQL schema module itself, as distinct from the schema it produces; seeds, fixtures, projects/example/ and test/support/; to_markdown/1 on a block module; a hand-rolled @behaviour when a callback is added; every other mix kiln.* task, and the code mix kiln.gen.content and mix kiln.gen.plugin write |
| Experimental | Incomplete, or resting on a dependency that is not stable yet. May change or be withdrawn in a minor | Semantic and hybrid search and the Meilisearch backend (both feature-flagged off, and the models need a KILN_ML=1 build); /api/content/:type/:slug/related, which is empty without semantic search; the Bumblebee cross-encoder reranker (rerank: false; its model path is not exercised in CI); the CRDT collaborative-editing prototype (:collab_prototype, dev and test only); GraphQL subscriptions over /ws/gql (on, but built on ash_graphql's subscription DSL, which is still beta) |
Bus factor is one. KilnCMS has a single maintainer
(@The-Verscienta), and 1.0 did not wait
for a second one. There is one merged external contribution
(#1445), and a
large share of the commit history is AI-pair-programmed (every such commit is
Co-Authored-By-attributed).
The part of the system where a mistake costs most had an outside review before 1.0 (#1536, 2026-09-29). It covered the authentication surface: sign-in with the first-factor hold, TOTP, passkeys and remember-me; the session cookie; password reset and confirmation; SSO (OIDC); API keys and bearer tokens; and tenant resolution. Five of the six were found sound. The sixth had two P1 findings, both about password rotation: changing or resetting a password did not revoke existing sessions and remember-me tokens, and a reset did not disconnect open LiveViews. Both were fixed in #1764 before the first release candidate.
Beyond that, what offsets a bus factor of one is mechanical rather than
social: a mix precommit gate and a CI suite that includes dialyzer, sobelow,
mix deps.audit and mix hex.audit, a policy-coverage guard that fails the build for an Ash
resource with no authorizer, an overlay-drift job, and a release-image build.
The issue tracker is the
project's real backlog, including its own audit findings. If that bus factor is
a blocker for you, it should be — say so in an issue; the surfaces most worth a
second pair of eyes are named in
docs/threat-model.md. Concerns about the project's
direction or maintenance go in an
issue; a suspected
vulnerability goes through the private report described in
.github/SECURITY.md,
never an issue.
Getting help, and reporting problems. Questions and bugs belong in
issues; see
.github/SUPPORT.md
for what goes where and what response time to actually expect. Security
problems never go in an issue — report them privately
(.github/SECURITY.md).
Stack
| Concern | Choice |
|---|---|
| Domain modeling | Ash (ash, ash_postgres, ash_phoenix) |
| Admin | AshAdmin at /admin (dev/CRUD tool, not the editor UI) |
| Headless APIs | AshJsonApi (/api/json) + AshGraphql (/gql) |
| Versioning | AshPaperTrail (full history on Page/Post) |
| Workflow | AshStateMachine (draft → in_review → published → archived) |
| Real-time | Native Phoenix.PubSub — no external broker (decision D1) |
| Database | PostgreSQL via AshPostgres |
| Optional infra | Dragonfly / Meilisearch / MinIO behind Compose profiles (D2) |
Content model
Resources live in lib/kiln_cms/cms/:
Page/Post— strongly-modeled content carrying an embedded block tree (blocksis an array of the embeddedBlockresource — decision D3, not a separate table). Both have paper-trail history and the publishing state machine.Block— an embedded resource with atype(:rich_text,:heading,:image,:quote,:embed,:divider,:columns,:custom),content, a flexibledatamap,order, and nestedchildrenfor composable slices.MediaItem— media-library metadata (binary lives in object storage).
Getting started
Prerequisites: Elixir 1.20+ / OTP 29+, Node.js 22, Docker (for Postgres). .tool-versions
holds the exact pair CI and the release image build on, so asdf install (or
mise install) gives you that toolchain; anything newer works for development.
# 1. Start Postgres (only required service; optional infra via profiles)
docker compose up -d postgres
# docker compose --profile cache up -d # + Dragonfly
# docker compose --profile search up -d # + Meilisearch
# docker compose --profile storage up -d # + MinIO
# 2. Install deps, create DB, run Ash migrations
mix setup # deps.get + ash.setup + assets.setup
# (or: mix deps.get && mix ash.setup && mix assets.setup)
# 3. Run
mix phx.serverOptional: semantic search (KILN_ML=1)
mix setup used to fetch 773 MB of dependencies. It now fetches 102 MB.
The difference is Bumblebee, Nx and EXLA — deps/exla alone is 666 MB, 86%
of the old tree, and a machine that has never built it also downloads a 110 MB
prebuilt XLA archive. Those three exist to back semantic search, which ships
disabled (config :kiln_cms, KilnCMS.Search, semantic: false), so the
default build no longer fetches them at all (#1321). mix setup prints one line
saying so.
Everything else works unchanged. The semantic path degrades rather than
disappearing: the adapters still exist and return {:error, …}, hybrid search
falls back to its keyword legs, and an install that has set semantic: true
without the stack is told so once at boot.
To build with it — this is the 671 MB the default build skips, and about a minute of extra compiling:
KILN_ML=1 mix deps.get
KILN_ML=1 mix compileKeep KILN_ML=1 exported for every mix invocation afterwards (including
mix phx.server and mix test), then turn the feature on with
semantic: true and run mix kiln.embed_all once. See
docs/semantic-search-plan.md.
Then visit:
- App: http://localhost:4000
- Admin: http://localhost:4000/admin
- GraphQL playground: http://localhost:4000/gql/playground
- JSON:API Swagger UI: http://localhost:4000/api/json/swaggerui
Creating an admin user
The role attribute (:admin/:editor/:viewer) can not be set through
self-registration — register_with_password always lands on :viewer so signup
can never escalate privileges. Admins are seeded directly or promoted by an
existing admin.
Development. The seed script (priv/repo/seeds.exs)
creates a pre-confirmed admin and editor. It runs automatically as part of
mix setup, or on its own:
mix run priv/repo/seeds.exsDefault dev credentials are [email protected] / kilnadmin123. Override them
(this is how you bootstrap a real admin) with env vars — the script is
idempotent, so it's safe to re-run:
[email protected] ADMIN_PASSWORD='a-strong-password' \
mix run priv/repo/seeds.exs| Variable | Default | Purpose |
|---|---|---|
ADMIN_EMAIL | [email protected] | Email for the seeded admin account. |
ADMIN_PASSWORD | kilnadmin123 | Password for the seeded admin account. |
EDITOR_EMAIL | [email protected] | Email for the seeded editor account. |
EDITOR_PASSWORD | kilneditor123 | Password for the seeded editor account. |
Production — bootstrap the first admin. Visit /setup on a fresh
instance: a three-step wizard creates the first admin (and, optionally, names
the site and picks its theme), then disappears permanently once any admin
exists. No shell required.
The release-shell path below still works — for headless bootstrap, or an
instance whose /setup was raced shut. A production OTP release has no
iex/mix — drive it through the generated bin/kiln_cms scripts (in Docker
they live at /app/bin/kiln_cms). With no existing admin to authorize the
promotion, run the admin-only :manage_access action with authorize?: false.
Attach an IEx shell to the running node (remote connects to the live,
fully-started app — repo included):
/app/bin/kiln_cms remotealias KilnCMS.Accounts
user = Accounts.get_user_by_email!("[email protected]", authorize?: false)
Accounts.manage_user_access!(user, %{role: :admin}, authorize?: false)Or run it one-shot without an interactive shell via rpc (executes on the live
node, prints the result, exits):
/app/bin/kiln_cms rpc 'KilnCMS.Accounts.manage_user_access!(KilnCMS.Accounts.get_user_by_email!("[email protected]", authorize?: false), %{role: :admin}, authorize?: false)'Use straight ASCII quotes (
"[email protected]") — smart/curly quotes (“ ”) are a syntax error in Elixir.
Both remote and rpc need a running node — they connect to the live release.
On Docker, docker exec -it <container> /app/bin/kiln_cms remote; on Fly.io,
fly ssh console then the same command. (bin/kiln_cms eval boots a separate,
non-serving instance and does not auto-start the repo, so prefer remote/rpc
for this.)
Once one admin exists, promote further users through the editor UI / API as that
admin actor — no authorize?: false needed.
Delivery
KilnCMS serves its public website itself, with Phoenix LiveView and
controllers — KilnCMSWeb.ContentController renders pages (/<slug>), the blog
(/blog, /blog/<slug>), on-site search (/search), and locale-prefixed
variants (/fr/...) straight from published content. There is no separate
frontend build to deploy; the app is the site.
The same content is also world-readable over headless APIs for external or
mobile consumers: GET /api/content/:type/:slug (the v2 fired-artifact API —
structured json, json_ld, or pre-rendered web HTML), GET /sitemap.xml
for enumeration, POST /gql (GraphQL), and the JSON:API at /api/json. See
docs/headless-consumer-guide.md for which
surface to use, and examples/ for a runnable headless integration
(an optional Astro example — not the reference frontend; the LiveView site
above is).
API docs: a published OpenAPI 3 spec (/api/json/open_api) and interactive
Swagger UI (/api/json/swaggerui), served in dev and off in production by
default — set API_DOCS_ENABLED=true to publish them (#567). Start at
docs/api.md — the full reference for authentication, the JSON:API
content endpoints, GraphQL, webhooks, preview tokens and rate limits. New to the
headless surfaces? docs/headless-consumer-guide.md
is a decision tree for picking the right surface and knowing what JSON shape each
one returns.
Working with Ash
mix ash.codegen <name> # generate migrations after changing resources
mix ash.migrate # run pending migrations
mix ash.setup # create DB + run migrationsDeployment
One-click:
— or Railway, Fly.io and DigitalOcean. Each runs the published image with a
database and persistent media; docs/deploy-platforms.md
has the steps, costs and caveats for all four.
docs/deploy.md is the deploy guide — required
environment (DATABASE_URL, SECRET_KEY_BASE, TOKEN_SIGNING_SECRET raise
on boot; PHX_HOST/PHX_SERVER), building the image, what runs at boot
(migrations, then the server), which health endpoint to point what at (/live
for restarts, /up for routing), the first-admin bootstrap, backups, and the
optional infrastructure. docker-compose.prod.yml
is a reference production stack (app + Postgres, with MinIO / Meilisearch /
Dragonfly behind profiles).
The multi-stage Dockerfile
builds an OTP release and includes libvips in the runtime image for image
processing; it runs anywhere that runs a container — this project's own
production is a manual Coolify Redeploy on one VPS, and Fly.io/Render/Kubernetes
work the same way.
Performance SLOs, Oban queue/POOL_SIZE tuning, and load-test recipes are in
docs/performance.md.
Staging / preview environments
Stand up a throwaway copy of production content to rehearse an upgrade or review a
change, then throw it away. ./scripts/staging.sh up clones the production database,
migrates it, and scrubs it of personal data and outbound secrets (reusing the
GDPR-erasure :anonymize action) so the copy is safe to run somewhere less
locked-down. See docs/staging-environments.md for
the one-command flow, the Docker/Coolify recipe, and the safety guards, and
docs/deploy-staging.md for the deploy/operator checklist.
Write API + visual editing
The write-capable headless APIs (#330) and the visual-editing bridge / Presentation
console (#355) deploy on a plain Redeploy — no migration, no POOL_SIZE change, no
required secret. Two conscious calls: the headless surface is now writable by
:read_write API keys (audit them), and the bridge is inert cross-origin until you
set CORS_ORIGINS (+ PRESENTATION_PREVIEW_URL). See
docs/deploy-write-visual-editing.md for the
checklist and docs/visual-editing-bridge.md for
the front-end integration guide.
Production hardening checklist
dev_routesmust stay off. It is only set inconfig/dev.exs;config/prod.exssets itfalseexplicitly and the app refuses to boot if a:prodrelease has it enabled (it would expose/admin, LiveDashboard, and the Swoosh mailbox unauthenticated). The same goes formailbox_preview, the narrower flagconfig/e2e.exsuses to mount only the Swoosh mailbox for the browser suite.- Database TLS is on by default. The Postgres connection uses
ssl: true; setDATABASE_SSL=falseonly for a provider that cannot offer TLS. PointDATABASE_SSL_CACERTFILEat the provider CA bundle to verify the server certificate (otherwise the connection is encrypted but usesverify_none). - Behind a reverse proxy — Coolify, Traefik, nginx, a cloud load balancer — set
TRUSTED_PROXIES(comma-separated CIDRs, e.g.10.0.0.0/8) so rate limiting keys on the real client IP fromX-Forwarded-Forinstead of the proxy address. Leave it unset only when the app is internet-facing directly, where the header is spoofable. Unset behind a proxy, every request looks like it came from the proxy, so all rate-limit bucket collapses into one counter for the entire internet: one noisy client exhausts:auth(40/min) for everybody, and the per-IP brute-force protection on/sign-instops being per-IP. Nothing errors — the app logs a warning once per node the first time a forwarded request arrives while this is unset (#564). - Invite-only mode: set
config :kiln_cms, :registration_enabled, falseto disable open/registerself-signup (the registration action is gated, not just the UI link).
Security
Found a vulnerability? Do not open a public issue. Report it privately
through this repository's Security tab
→ Report a vulnerability. The full policy — what is in scope, which
release lines are supported, and what to expect after
you report — is .github/SECURITY.md,
also published at kilncms.dev/security with
every security notice issued for Kiln. Follow the
security notices feed to hear
about the next one.
Hardening a deployment is a different question, answered by the
production hardening checklist above.
Background on the trust boundaries and the attacks the design assumes:
docs/threat-model.md.
Status & next steps
Bootstrapped & verified (compiling, migrating, serving):
- Phoenix 1.8 + Ash 3 core, CMS domain (Page/Post/MediaItem + embedded blocks)
- AshPaperTrail history + AshStateMachine publishing workflow
- JSON:API + GraphQL + AshAdmin
- AshAuthentication — Accounts domain, User/Token, password strategy,
/sign-in/register, plus aroleattribute (:admin/:editor/:viewer, defaults to:viewer) for RBAC
- Oban (Postgres-backed, no Redis) + AshOban wired into the supervision tree
- Docker infra (Postgres default; Dragonfly/Meilisearch/MinIO optional profiles)
- Search — Postgres full-text + optional semantic/hybrid (pgvector) and an
optional Meilisearch backend, both feature-flagged off by default
(
docs/meilisearch.md)
Now wired: magic-link auth, Ash policies enforcing the RBAC roles on every
resource, the media upload/variant pipeline (libvips), the TipTap LiveView editor
with real-time visual preview and collaborative locking, and headless GraphQL + JSON:API
delivery. Remaining cleanup: replace the temporary DaisyUI auth-override scaffolding in
router.ex/auth_overrides.ex with custom components (the plan specifies no DaisyUI).
Note: clone this project to a path without spaces. One dependency,
picosat_elixir, cannot compile under a spaced path such as iCloud Drive'sMobile Documents(fixed upstream in bitwalker/picosat_elixir#14, not yet released).mix setupchecks for this before it starts.