quickstart

From docker run to your first published entry, fetched over REST and the SDK.

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

Bring it up and check the API is live:

docker compose up -d
curl http://localhost:4000/health
# ok

The 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 it

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