Documentation Menu

Quick start for engineers

12 min read

Run Quillet on your machine, publish one page and render it from your own code in about fifteen minutes.

This walk is for engineers. You start the CMS with Docker Compose, create the first account and a project, describe a page with a component, write and publish one page, mint a delivery key, and read the page three ways: with curl, with the SDK, and as HTML in a browser. Every block below runs as pasted, in one terminal, in the order given. Replace only what is in <angle brackets>.

Before you start

You need Docker with Compose, Node 22.12 or later, curl and openssl, and nothing listening on ports 3000 and 4000. The CMS itself runs in containers, so Node is only for the last two steps.

Start the CMS

Make an empty folder and work in it:

mkdir quillet && cd quillet

Save this as docker-compose.yml. It runs the CMS image from Docker Hub, a one-node Mongo replica set, Redis, and a volume for uploaded files. A comment above each line says what it does.

# Quillet 1.1 on one machine: the CMS, Mongo, Redis, a volume for files.
# The two secrets come from the .env file beside this one, which Compose reads by itself.

# The CMS's settings, shared by `migrate` and `cms` below.
x-cms-env: &cms-env
  # Mongo, as a replica set: the CMS needs transactions.
  MONGO_URI: mongodb://mongo:27017/quillet?replicaSet=rs0
  # Sessions, rate-limit counters and the delivery cache.
  REDIS_URL: redis://redis:6379
  # Signs the session cookie: 32 characters or more, from .env.
  SESSION_SECRET: ${SESSION_SECRET:?set it in .env}
  # Seals webhook secrets: 32 bytes, base64, from .env. Required.
  WEBHOOK_SECRET_KEY: ${WEBHOOK_SECRET_KEY:?set it in .env}
  # The address the browser uses, port included.
  BASE_URL: http://localhost:3000
  # Lets the first account sign up. Set it to closed once your team is in.
  AUTH_REGISTRATION: open
  # Uploaded files go to a directory rather than a bucket...
  ASSET_BACKEND: disk
  # ...this one, inside the container: the `assets` volume.
  ASSET_DISK_ROOT: /data/assets

# Six containers. Three run once and exit: mongo-init, assets-init, migrate.
services:
  # The database.
  mongo:
    # Mongo 7; any later version works too.
    image: mongo:7
    # Start as a member of the replica set rs0.
    command: ["--replSet", "rs0", "--bind_ip_all"]
    # Healthy once it answers a ping,
    healthcheck:
      # which is this command,
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok ? 0 : quit(1)"]
      # tried every five seconds,
      interval: 5s
      # each try failing if it takes longer than five,
      timeout: 5s
      # unhealthy after twenty failures in a row,
      retries: 20
      # not counting any in the first ten seconds.
      start_period: 10s
    # Keep the data when the container is replaced:
    volumes:
      # Mongo's data directory, on the named volume `mongo-data`.
      - mongo-data:/data/db

  # Runs once: makes the lone Mongo a one-node replica set.
  mongo-init:
    # The same image, for its shell.
    image: mongo:7
    # Wait for the database
    depends_on:
      mongo:
        # to answer its ping.
        condition: service_healthy
    # Try again if Mongo was not ready yet.
    restart: on-failure
    # Run one line of Mongo shell...
    entrypoint: ["mongosh", "--host", "mongo:27017", "--quiet", "--eval"]
    # ...which initiates the replica set unless that is already done.
    command: ["try { rs.status() } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }] }) }"]

  # The cache and the session store.
  redis:
    # Redis 7. Nothing in it needs to survive a restart: losing it signs everyone out, nothing more.
    image: redis:7

  # Runs once: a new volume belongs to root, and the CMS runs as `node`.
  assets-init:
    # The CMS image, which has the `node` user.
    image: iegork/quillet:1.1
    # As root, for this one command:
    user: "0"
    # give the files directory to `node`.
    entrypoint: ["chown", "node:node", "/data/assets"]
    # The volume the CMS writes files to,
    volumes:
      # mounted where ASSET_DISK_ROOT points.
      - assets:/data/assets

  # Runs once: builds Mongo's indexes before the CMS takes traffic.
  migrate:
    # The CMS image.
    image: iegork/quillet:1.1
    # The index migration. Safe to run again on every start.
    command: ["node", "apps/api/dist/.scripts/scripts/migrate.js"]
    # The same settings as the server.
    environment: *cms-env
    # It checks the files directory on start, as the server does,
    volumes:
      # so it mounts the same volume.
      - assets:/data/assets
    # Only after
    depends_on:
      # the replica set exists
      mongo-init:
        condition: service_completed_successfully
      # and the files directory is writable.
      assets-init:
        condition: service_completed_successfully

  # The CMS: the dashboard, the management API and the delivery API.
  cms:
    # Quillet CMS 1.1 from Docker Hub. Move to a new tag deliberately.
    image: iegork/quillet:1.1
    # Come back after a crash or a reboot.
    restart: unless-stopped
    # A stop waits up to fifteen seconds, longer than the CMS's own ten-second drain.
    stop_grace_period: 15s
    # Published on the host
    ports:
      # at http://localhost:3000.
      - "3000:3000"
    # The settings at the top.
    environment: *cms-env
    # Uploaded files
    volumes:
      # live on the `assets` volume.
      - assets:/data/assets
    # Start only once
    depends_on:
      # the indexes are built
      migrate:
        condition: service_completed_successfully
      # and Redis is running.
      redis:
        condition: service_started

# Named volumes, kept until `docker compose down -v`.
volumes:
  # Mongo's data.
  mongo-data:
  # Uploaded files.
  assets:

The two secrets come from a .env file beside it, which Compose reads on its own. Generate them, then start everything:

printf 'SESSION_SECRET=%s\nWEBHOOK_SECRET_KEY=%s\n' "$(openssl rand -base64 48)" "$(openssl rand -base64 32)" > .env
docker compose up -d

The first start pulls the images. Compose then initiates the replica set, builds the indexes and starts the CMS. Wait until it says it is ready:

CMS=http://localhost:3000
until curl -sf "$CMS/readyz"; do sleep 2; done

When it is up, the loop ends on the readiness answer:

{"status":"ok","mongo":"up","redis":"up"}

/readyz answers 200 only once the CMS is listening and both Mongo and Redis are up, so a loop on it is the honest "started". The dashboard is now at http://localhost:3000.

Create the first account

Registration is open on this instance, so the first account can sign itself up. In a browser that is Create one under the Sign in form. Here it is over the API, which also signs you in and keeps the session cookie in cookies.txt:

curl -s -X POST "$CMS/api/v1/auth/register" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse battery staple","name":"Ada Lovelace"}'

curl -s -X POST "$CMS/api/v1/auth/login" -c cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse battery staple"}'

Both answer with the account:

{"id":"…","email":"ada@example.com","name":"Ada Lovelace"}

A password needs at least 12 characters. cookies.txt is a signed-in session: treat it like a password and delete it when you are done.

Create a project

A project is one site: its own documents, environments and keys. Whoever creates it becomes its owner.

curl -s -X POST "$CMS/api/v1/projects" -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"slug":"handbook","name":"Handbook","defaultLocale":"en-US","locales":["en-US"]}'
{"id":"…","slug":"handbook","name":"Handbook","defaultLocale":"en-US","locales":["en-US"],…,"role":"owner",…}

With no environments named, the project gets one, prod. Every content request below is addressed to it.

Describe a page with a component

A component is a JSON Schema stored as the document _schemas/<name>. This one is a page with a title and a Markdown body. page: true lists its documents under Pages in the dashboard and opens them with Content preview beside the form, and the roles tell that preview which field is the heading and which the body. In the dashboard the same thing is Components → New → Component.

E="$CMS/api/v1/projects/handbook/envs/prod"

curl -s -X PUT "$E/documents/_schemas/page?locale=en-US" -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","title":"Page","content":{
       "type":"object","title":"Page","required":["title"],"additionalProperties":false,
       "properties":{
         "title":{"type":"string","x-quillet":{"role":"heading"}},
         "body":{"type":"string","x-quillet":{"widget":"markdown","role":"body"}}},
       "x-quillet":{"page":true,"preview":"title","order":["title","body"]}}}'
{"key":"_schemas/page","locale":"en-US","type":"json","title":"Page",…,"version":1,…,"publishedVersion":null,…}

Write the page

A page is an ordinary document bound to a page component. schema binds it, and the save is checked against the component: a body without a title is refused with a 422 that names the field.

curl -s -X PUT "$E/documents/pages/home?locale=en-US" -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"type":"json","schema":"page","title":"Home","content":{
       "title":"Hello from Quillet",
       "body":"This page was **published** from the CMS.\n\n- Saved as a version\n- Served by the delivery API"}}'
{"key":"pages/home",…,"schema":"page","effectiveSchema":"page",…,"version":1,…,"publishedVersion":null,…}

"version":1 and "publishedVersion":null: the save kept a version and published nothing.

Publish

Publishing is a separate act. One request publishes the component and the page together, as one decision:

curl -s -X POST "$E/publish" -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"documents":[{"key":"_schemas/page","locale":"en-US"},{"key":"pages/home","locale":"en-US"}]}'
{"results":[{"key":"_schemas/page","locale":"en-US","version":1,…,"status":"published"},
            {"key":"pages/home","locale":"en-US","version":1,…,"status":"published"}]}

Mint a delivery key

A delivery key reads published content, from the one environment it names, and nothing else. It is the credential you give a website. In the dashboard: Settings → Keys → New key, Kind Delivery.

curl -s -X POST "$CMS/api/v1/projects/handbook/keys" -b cookies.txt \
  -H 'Content-Type: application/json' \
  -d '{"name":"Website","kind":"delivery","scopes":["content:read"],"env":"prod"}'
{"id":"…","keyId":"qk_…","name":"Website","kind":"delivery",…,"env":"prod",…,"secret":"qs_…"}

The secret is in this response and nowhere else: the CMS stores only its hash. Copy both halves into the terminal now:

export QUILLET_KEY_ID=<keyId>
export QUILLET_KEY_SECRET=<secret>

Read it with curl

The delivery API is /d/v1/<project>/<environment>, authenticated with HTTP Basic: the key id as the user, the secret as the password.

curl -s -i -u "$QUILLET_KEY_ID:$QUILLET_KEY_SECRET" "$CMS/d/v1/handbook/prod/content/pages/home"

The answer, with most headers left out:

HTTP/1.1 200 OK
ETag: W/"3c72f3c8…"
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60

{"key":"pages/home","locale":"en-US","type":"json",
 "content":{"title":"Hello from Quillet","body":"This page was **published** from the CMS.\n\n…"},
 "_meta":{"resolvedLocale":"en-US","fallbackLocale":null,"fallbacks":[],"version":1,…,"refs":[],"assets":[]}}

content is the body as you saved it. _meta says how the answer was made: which locale answered, what fell back to another locale, and which version is live.

Read it with the SDK

Make a folder for the site inside this one and install the SDK from npm:

mkdir site && cd site
npm init -y
npm install @quilletcms/sdk
export QUILLET_API_URL="$CMS" QUILLET_PROJECT=handbook QUILLET_ENV=prod

The client takes its settings from those variables and the two you exported with the key. Save this as read.mjs:

// read.mjs: read the published page through the delivery API.
import { quillet } from '@quilletcms/sdk/node'

// The client reads QUILLET_API_URL, QUILLET_PROJECT, QUILLET_ENV,
// QUILLET_KEY_ID and QUILLET_KEY_SECRET from the environment.
const q = quillet()

const page = await q.get('pages/home')

console.log('title:   ', page.content.title)
console.log('version: ', page._meta.version)
console.log('locale:  ', page._meta.resolvedLocale)
console.log('body:    ', page.content.body)
node read.mjs
title:    Hello from Quillet
version:  1
locale:   en-US
body:     This page was **published** from the CMS.

- Saved as a version
- Served by the delivery API

Render it in a page

The same read, turned into HTML by the SDK's Markdown renderer, which sanitises its output. Save this as server.mjs:

// server.mjs: render the published page as HTML on http://localhost:4000.
import { createServer } from 'node:http'
import { quillet } from '@quilletcms/sdk/node'

const q = quillet()
const escape = (text) => text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)

createServer(async (request, response) => {
  // Every request reads the published version, so a new publish shows on reload.
  const page = await q.get('pages/home')
  const body = await q.renderMarkdown(page.content.body ?? '')
  response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  response.end(`<!doctype html>
<title>${escape(page.content.title)}</title>
<h1>${escape(page.content.title)}</h1>
${body}
<p><small>Version ${page._meta.version}</small></p>`)
}).listen(4000, () => console.log('http://localhost:4000'))
node server.mjs

It prints http://localhost:4000. Open it: the heading, the paragraph with published in bold, the list, and "Version 1". Leave it running.

Change it, and publish again

Now see the difference between saving and publishing, in the dashboard.

  1. Open http://localhost:3000 and sign in as ada@example.com with the password above. You land on Projects; press Handbook.
  2. In the Content tab, open Home (pages/home). It opens on a form with title and body.
  3. Change the title field in the form, not Title under Details, to Hello from the dashboard and press Save. The status beside the breadcrumb reads Changed since published.
  4. Reload http://localhost:4000. The old title is still there: a save is a new version, not a change for readers.
  5. Press Publish. The dialog says Version 2 goes live for readers. Press Publish in it.
  6. Reload http://localhost:4000. The new title, and "Version 2".

The server asks the CMS on every request and the CMS clears its own cache on publish, so the change shows on the next reload. A real site caches harder; Integrating a site covers the three ways it learns that something changed.

Stop it

Press Ctrl+C to stop server.mjs. Then go back to the quillet folder and stop the containers. The data stays in the volumes, and docker compose up -d brings it all back:

cd ..
docker compose down

To delete everything this walk stored, the documents, the account and the files with it, remove the volumes too:

docker compose down -v

Where next

Last updated