quickstart
This is the whole loop: run ChiliCMS, define a content type in code, publish an entry in the Studio, and read it back over REST and the SDK. You need Docker and a terminal. No account, no card.
By the end you'll have a release content type, one published entry, and the
exact same row coming back from the public REST delivery path and the SDK helper.
1. run it
ChiliCMS is one container plus Postgres 16+. Drop this compose.yml in an empty
directory — Postgres and the API on one network:
name: chilicms
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: chilicms_owner
POSTGRES_PASSWORD: change_me
POSTGRES_DB: chilicms
healthcheck:
test: ["CMD-SHELL", "pg_isready -U chilicms_owner -d chilicms"]
interval: 2s
timeout: 5s
retries: 20
api:
image: ghcr.io/a14a-org/chilicms:latest
depends_on:
db:
condition: service_healthy
ports:
- "4000:4000"
environment:
PORT: 4000
DATABASE_URL: postgres://chilicms_owner:change_me@db:5432/chilicms
APP_DATABASE_URL: postgres://chilicms_app:change_me_too@db:5432/chilicmsBring it up and check the API is live:
docker compose up -d
curl http://localhost:4000/health
# okThe API serves REST at /api/v1/*, GraphQL at /graphql, and the Studio. Open
it, enter your email, and click the magic link printed to the container logs
(docker compose logs -f api) to create the first admin. Postgres runs in
production; SQLite is dev-only. The two database roles — an owner role for
migrations and a NOSUPERUSER/NOBYPASSRLS app role for requests — are what
make row-level security the law; the self-host guide sets
them up.
2. define a content type
Schema is code. Content types live in cms/*.ts and ship in pull requests — the
Studio is generated from them and never owns the schema. Create
cms/release.ts:
import {
defineContentType,
defineField,
richTextField,
slugField,
titleField,
} from "@chilicms/db/schema"
import { jsonb, timestamp, varchar } from "drizzle-orm/pg-core"
export default defineContentType({
modelName: "Release",
label: "Releases",
userFields: {
title: titleField(varchar("title", { length: 200 }).notNull()),
slug: slugField(varchar("slug", { length: 220 }).notNull(), "title"),
body: richTextField(jsonb("body")),
// An optional datetime field gives the type a draft -> published
// lifecycle. Until publishedAt is set and in the past, the entry is a
// draft and the public delivery surface won't return it.
publishedAt: defineField({
column: timestamp("published_at", { withTimezone: true }),
meta: { kind: "datetime", required: false, label: "Published at" },
}),
},
})titleField, slugField, and richTextField are thin helpers; slug
auto-derives from title. Every type also gets id, tenantId, createdAt,
updatedAt, and locale system fields for free — you don't declare them.
Push it. pepper is the ChiliCMS CLI, bundled in the image. Run it once with no
flags to preview the generated SQL, then again with --apply to write the
migration and execute it against DATABASE_URL in a single transaction:
docker compose exec api pepper push # preview the CREATE TABLE + RLS
docker compose exec api pepper push --apply # write the migration and run itThe migration creates the releases table with row-level security forced on,
so the type is tenant-isolated from the first row.
3. publish an entry
Open the Studio, pick Releases, and write. Every save is a draft until you press publish — published content carries a dedicated green “Live” state with a label, so you always know what's visible.
Set a title like “v1.0”, write a body, and publish. A publishedAt in the past
makes the entry live now; a future value schedules it.
4. read it back
Reads go through the public delivery surface, scoped by a per-tenant delivery token — read-only, and it only ever returns published content for one tenant. Mint one in the Studio (Settings → Delivery tokens) or over the provisioning API, then export it:
export CHILI_TOKEN="<your delivery token>"Now fetch the same entry from the public delivery surface.
REST
GET /api/v1/<plural> returns the published entries for the token's tenant in a
{ data: [...] } envelope. The token goes in the X-ChiliCMS-Delivery-Token
header (a bare Authorization: Bearer works too):
curl http://localhost:4000/api/v1/releases \
-H "X-ChiliCMS-Delivery-Token: $CHILI_TOKEN"{
"data": [
{
"id": "f1c0b3a2-...",
"title": "v1.0",
"slug": "v1-0",
"body": { "type": "doc", "content": [] },
"publishedAt": "2026-06-28T10:00:00.000Z",
"locale": "en",
"createdAt": "2026-06-28T09:58:00.000Z",
"updatedAt": "2026-06-28T10:00:00.000Z"
}
]
}Drafts and not-yet-due scheduled entries are absent — the public surface only
ever returns what's live. Add ?locale=de to filter to one locale.
GraphQL
POST /graphql serves the built-in Post and Page types. Unlike REST,
GraphQL is the authenticated session surface — it uses your admin session, not a
delivery token — so it's the right transport for server-side reads where you
already hold a session, and editors see drafts there too:
curl http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-H "Cookie: chilicms_session=$SESSION" \
-d '{"query":"{ posts { id title publishedAt } }"}'{ "data": { "posts": [{ "id": "...", "title": "hello", "publishedAt": "..." }] } }GraphQL covers the built-in posts/pages today; custom types like releases
are served over REST and the SDK. We'd rather tell you that than pretend the
GraphQL surface is wider than it is.
SDK
@chilicms/sdk is a typed fetch client over the same REST delivery surface.
Point it at the API and read by plural slug:
import { createClient } from "@chilicms/sdk"
const cms = createClient({
baseUrl: process.env.CHILICMS_API_URL ?? "http://localhost:4000",
})
const releases = await cms.list("releases")
const latest = await cms.getBySlug("releases", "v1-0")The first argument is the URL plural — releases for the Release type, the
same segment as the REST path. .list() returns the published entries;
.getBySlug() and .getById() fetch one. The reads hit the same
{ data: [...] } endpoint as the curl above.
That's the loop: schema in code, edit in the Studio, read over whichever transport fits. Next, read the concepts behind schema-as-code and the delivery model, or the sdk reference.