Skip to main content

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/amd64

latest 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:

LabelWhat it meansSurfaces
CoveredA rename, a removal or a change of meaning needs a major release and goes through the deprecation path. Additions are not breakingThe 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 defaultNot 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 coveredAI 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
InternalMay change in any release, including a patch, without a deprecationEverything 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
ExperimentalIncomplete, or resting on a dependency that is not stable yet. May change or be withdrawn in a minorSemantic 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

ConcernChoice
Domain modelingAsh (ash, ash_postgres, ash_phoenix)
AdminAshAdmin at /admin (dev/CRUD tool, not the editor UI)
Headless APIsAshJsonApi (/api/json) + AshGraphql (/gql)
VersioningAshPaperTrail (full history on Page/Post)
WorkflowAshStateMachine (draft → in_review → published → archived)
Real-timeNative Phoenix.PubSub — no external broker (decision D1)
DatabasePostgreSQL via AshPostgres
Optional infraDragonfly / 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 (blocks is an array of the embedded Block resource — decision D3, not a separate table). Both have paper-trail history and the publishing state machine.
  • Block — an embedded resource with a type (:rich_text, :heading, :image, :quote, :embed, :divider, :columns, :custom), content, a flexible data map, order, and nested children for 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.server

Optional: 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 compile

Keep 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:

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.exs

Default 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
VariableDefaultPurpose
ADMIN_EMAIL[email protected]Email for the seeded admin account.
ADMIN_PASSWORDkilnadmin123Password for the seeded admin account.
EDITOR_EMAIL[email protected]Email for the seeded editor account.
EDITOR_PASSWORDkilneditor123Password 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 remote
alias 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 migrations

Deployment

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_routes must stay off. It is only set in config/dev.exs; config/prod.exs sets it false explicitly and the app refuses to boot if a :prod release has it enabled (it would expose /admin, LiveDashboard, and the Swoosh mailbox unauthenticated). The same goes for mailbox_preview, the narrower flag config/e2e.exs uses to mount only the Swoosh mailbox for the browser suite.
  • Database TLS is on by default. The Postgres connection uses ssl: true; set DATABASE_SSL=false only for a provider that cannot offer TLS. Point DATABASE_SSL_CACERTFILE at the provider CA bundle to verify the server certificate (otherwise the connection is encrypted but uses verify_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 from X-Forwarded-For instead 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-in stops 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, false to disable open /register self-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 a role attribute (: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's Mobile Documents (fixed upstream in bitwalker/picosat_elixir#14, not yet released). mix setup checks for this before it starts.