Run with Docker
Run the Quillet CMS from its Docker image, with Mongo and Redis beside it, and create the first account.
Why the copy you try is the copy you deploy
A CMS is easier to judge, and to trust, when you can start the real thing on your own machine in a few minutes. Quillet ships as one image for that reason. The image on your laptop and the one behind your production ingress are byte for byte the same, and the only thing that differs between them is the environment you hand each one. Running it yourself also means your content stays in a database you own, and an upgrade is a tag you change when you are ready, not before.
What one container runs
The image is iegork/quillet on Docker Hub. One container carries all three surfaces of the CMS. The dashboard that content managers work in is served at /, the management API sits under /api/v1, and the cached, read-only delivery API that your sites read from sits under /d/v1. All of them answer on port 3000.
You will meet three tags:
1.1follows the newest 1.1 release. This page uses it.1.1.1names one release exactly. Pin it where a deployment must not change under you.latestfollows the newest release of any version. Avoid it outside a quick trial.
Each tag is built for linux/amd64 and linux/arm64, so the same tag runs on an Intel or AMD server and on Apple silicon.
Inside the container, the CMS runs as an unprivileged account named node (uid 1000). The image keeps only what the CMS needs at run time, and it polls its own /healthz as a Docker health check. It bakes in three settings, NODE_ENV=production, PORT=3000 and the dashboard's location, and reads everything else from the environment when it starts. Five variables have no default, and the CMS refuses to start without them: MONGO_URI, REDIS_URL, SESSION_SECRET, BASE_URL and WEBHOOK_SECRET_KEY.
The CMS needs two services next to it:
- Mongo, as a replica set. The CMS writes a new project and every CLI push in one transaction, and a standalone Mongo refuses transactions. A replica set of one member is enough.
- Redis, for sessions, rate-limit counters and the delivery cache. Nothing in it needs a backup, because losing it only signs everyone out and empties the cache.
The same image runs two commands. The default one is the server. The other, node apps/api/dist/.scripts/scripts/migrate.js, builds the database indexes and exits. In production the CMS does not build indexes on its own, so you run the migration before the server takes traffic, both on the first start and on every upgrade.
How to
Choose Compose to run everything on one machine. Choose docker run when Mongo and Redis already exist somewhere else, such as a managed database and a managed cache.
Docker Compose
- In an empty directory, save this file as
docker-compose.yml. It starts Mongo and turns it into a replica set of one, starts Redis, prepares a volume for uploaded files, runs the migration, and only then starts the CMS. A comment above each line says what that line does. The migration mounts the files volume too, because it checks the files directory on start. The CMS restarts after a crash and gets fifteen seconds to stop, which is longer than its own ten-second drain.
# 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:- Write the two secrets into a
.envfile beside it. Compose reads that file by itself and refuses to start while either value is missing.
printf 'SESSION_SECRET=%s\nWEBHOOK_SECRET_KEY=%s\n' "$(openssl rand -base64 48)" "$(openssl rand -base64 32)" > .env- Start the CMS and everything it depends on. The command returns once the CMS reports itself healthy, which takes under a minute when the images are already pulled.
docker compose up -d --wait cmsdocker run
- Write the settings into a file called
quillet.env, oneNAME=valueper line. Replace each value in angle brackets with your own: generateSESSION_SECRETwithopenssl rand -base64 48andWEBHOOK_SECRET_KEYwithopenssl rand -base64 32. The Mongo and Redis addresses must be reachable from inside the container:localhostthere is the container itself, not your machine.
MONGO_URI=<mongodb://mongo.example.internal:27017/quillet?replicaSet=rs0>
REDIS_URL=<redis://redis.example.internal:6379>
SESSION_SECRET=<at least 32 random characters>
WEBHOOK_SECRET_KEY=<32 bytes in base64>
BASE_URL=http://localhost:3000
AUTH_REGISTRATION=open
ASSET_BACKEND=disk
ASSET_DISK_ROOT=/data/assets- Create a volume for uploaded files and hand it to the image's
nodeaccount. A new volume belongs to root, so without this stepnodecannot write to it.
docker volume create quillet-assets
docker run --rm -u 0 -v quillet-assets:/data/assets --entrypoint chown iegork/quillet:1.1 node:node /data/assets- Run the migration. It prints one JSON line per collection and ends with
{"event":"migrate.complete"}.
docker run --rm --env-file quillet.env -v quillet-assets:/data/assets iegork/quillet:1.1 node apps/api/dist/.scripts/scripts/migrate.js- Start the CMS.
docker run -d --name quillet --restart unless-stopped --stop-timeout 15 --env-file quillet.env -v quillet-assets:/data/assets -p 3000:3000 iegork/quillet:1.1Whichever way you started it, check the two probes. /healthz answers for as long as the process runs, while /readyz answers 200 only when Mongo and Redis are both reachable.
curl -s http://localhost:3000/healthz
curl -s http://localhost:3000/readyz
The second should print {"status":"ok","mongo":"up","redis":"up"}. If the CMS did not start at all, its log names every variable that is wrong. Read it with docker compose logs cms, or with docker logs quillet.
Then create the first account:
- Open
http://localhost:3000and choose Create one under the sign-in form. - Enter your name, your email address and a password of at least 12 characters, and choose Create account.
- Sign in with them. Creating an account does not sign you in by itself.
- Create a project. Whoever creates a project is its owner. The owner adds the rest of the team in the project's Settings, under Members, once each person has an account.
Registration starts open so that this first account can exist. Close it once your team has its accounts, or once you have turned on sign-in with GitHub or Google: set AUTH_REGISTRATION to closed in the compose file or in quillet.env, and start the CMS again. With Compose, docker compose up -d --wait cms recreates the container with the new value. With docker run, stop and remove the quillet container (docker stop quillet, then docker rm quillet) and run step 4 again.
Limits
- The compose file is for one machine. Mongo has a single member and no backups, Redis keeps nothing you could not lose, and nothing terminates TLS. Before other people rely on it, put it behind a proxy with TLS and set up backups.
BASE_URLmust be the address the browser uses. If you leave it athttp://localhost:3000on a server, provider sign-in sends people back tolocalhost, and every file URL in delivered content points there too.- The replica set's member is named
mongo:27017, which only resolves inside the compose network, and the file publishes no Mongo or Redis port. Tools on your machine cannot reach either store. - Uploaded files live on the
assetsvolume. LeaveASSET_DISK_ROOTpointing at it. Without it, the CMS writes files inside the container, and they are lost when the container is replaced. More than one CMS container needs a shared volume or a bucket; see File storage. - Anyone who can reach the CMS while registration is open can create an account and their own projects.
docker compose down -vdeletes the volumes, and with them every document and file. Plaindocker compose downkeeps them.latestmoves to a new version without warning. Change tags deliberately and run the migration as part of it; see Upgrades, migrations and health.
Related
- Quick start goes on from a running CMS to a published page read by your own code.
- Configuration reference lists all 54 variables, with what breaks when one is wrong.
- Sign-in with GitHub or Google closes the instance to strangers.
- Kubernetes with Helm runs the same image with managed Mongo and Redis.
- How the parts fit explains what the dashboard, the two APIs and your site each do.