Deployment
Deploy CMS to production with Docker, migrations, health checks, and runtime configuration.
CMS is a framework, not a deployable app. You ship a consumer project — your cms.config.ts, a package.json that depends on @cms/engine, your committed migrations, and a Dockerfile. In production the engine runs with cms start, serving the API and (optionally) the admin SPA on a single port.
This guide covers the engine itself. Storage, CORS, and email are configured in your cms.config.ts from whatever environment variables you choose — the framework never reads them directly.
Docker
A minimal reference Dockerfile to drop next to your cms.config.ts:
FROM node:24-alpine
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm cms build # builds the admin SPA to .cms/admin-dist/
EXPOSE 4000
CMD ["pnpm", "cms", "start"]
Build and run:
docker build -t my-cms .
docker run -p 4000:4000 --env-file .env my-cms
A few rules:
- Use
--frozen-lockfileso the build fails fast ifpackage.jsonand the lockfile have drifted. - Don’t apply migrations on container startup —
cms startnever does. Runcms migration applyas a separate pre-deploy step (see Database migrations). - For headless instances, set
ADMIN_UI=false.cms buildbecomes a no-op and the admin SPA is never mounted, so the same image serves an API-only deployment without code changes.
Smaller production images
The minimal image above ships the full dependency tree, including build tooling. To trim it, build the admin SPA in a throwaway stage, then run a fresh pnpm install --prod in the runtime stage so only production dependencies remain. Two things to keep in mind:
tsxmust be a runtimedependency, not adevDependency. The engine compilescms.config.tson boot through tsx, so--prodhas to keep it.- Never
COPYnode_modulesbetween stages. pnpm lays outnode_modulesas a symlink farm over a virtual store, and the engine’s config loader depends on that exact layout. A cross-stage copy breaks it, and config load fails withERR_MODULE_NOT_FOUND. Always let each stage build its own tree with a nativepnpm install.
Copy only what the runtime needs: the built admin (/app/.cms), your config and its source, your migrations, and the tsconfig.json tsx uses for path resolution. Keep custom admin components self-contained so the image never has to ship frontend code.
Run dumb-init (or tini) as PID 1 so docker stop’s SIGTERM reaches the engine and it drains cleanly:
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "node_modules/@cms/engine/bin/cms.js", "start"]
Environment variables
These are read by the engine itself:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | — | Postgres connection string. Required. |
PORT | 4000 | HTTP listen port. |
NODE_ENV | — | Set to production to enable secure cookies and JSON logs. |
ADMIN_UI | true | Set to false (or 0/off/no) to run headless. |
CMS_CONFIG_PATH | auto-detected | Path to your config file. Override only if it isn’t at cwd/cms.config.ts (or .tsx). |
CMS_SEED_ADMIN_EMAIL | admin@cms.local | Email for the first admin, seeded on boot only when the _user table is empty. Auth is OTP-only — no password. |
LOG_LEVEL | info | Pino log level (debug, info, warn, error). |
MIGRATIONS_PATH | ./migrations | Directory holding committed migration files. |
Everything else is consumer configuration. Storage credentials, CORS origins, and email settings are fields on the object you pass to config({ … }), typically read from process.env:
// cms.config.ts
storage: {
adapter: "s3",
bucket: process.env.S3_BUCKET ?? "",
region: process.env.S3_REGION ?? "us-east-1",
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
endpoint: process.env.S3_ENDPOINT, // optional — MinIO / custom S3
},
cors: {
origins: (process.env.CORS_ORIGINS ?? "").split(",").filter(Boolean),
},
Name these variables whatever you like.
Database migrations
cms start never auto-runs migrations. Treat applying them as an explicit operator step: generate locally, commit the SQL, then apply as a pre-deploy job before the new code rolls out.
cms migration generate add_excerpt # locally — review and commit the SQL
cms migration apply # in CI/deploy, against the production DB
apply is idempotent. A single-instance deployment can chain it into the entrypoint (cms migration apply && cms start); for multi-replica deployments, run it as a dedicated pre-cutover step (e.g. Fly’s release_command) so replicas never race to apply the same migration. If an apply fails, the deploy should block and the previous version should keep serving traffic.
Useful CI checks:
cms migration generate --check # PR-time: fails if a config change has no committed migration
cms migration check-drift # pre-deploy: fails if the live DB was ALTERed out of band
cms migration status # show which migrations are applied/pending
cms migration push syncs the schema directly from config without files. It’s dev-only and accepts data loss — never use it in production.
Health checks
The engine exposes these out of the box:
| Endpoint | What it checks |
|---|---|
GET /api/health | Process is up; includes DB status when fully wired. |
GET /healthz/engine | Process is up. |
GET /healthz/db | Postgres is reachable. |
GET /healthz/admin | The admin SPA asset is present. |
Each returns {"status":"ok"} with 200 on success; the db and admin checks return 503 with a degraded status when unhealthy.
Use GET /healthz/db as the readiness gate in your deploy pipeline — it confirms the database is reachable (and therefore that migrations have run) before the instance takes traffic. Configure it in your platform’s deployment config, not the Dockerfile.
Graceful shutdown
The engine handles SIGTERM and SIGINT. In-flight requests drain, then the database pool closes, with a 5-second forced-exit timeout as a backstop. Set your platform’s kill timeout to at least that (e.g. kill_timeout = '10s') so drains complete cleanly.
Runtime configuration
Custom admin components sometimes need values that rotate without a rebuild — an API base URL, a public key, a feature flag. Build-time env (VITE_*) bakes these into the bundle, so changing one means rebuilding.
Instead, the engine exposes any process.env variable prefixed CMS_PUBLIC_ to the admin SPA at runtime, served from GET /admin/runtime-env.js with Cache-Control: no-store. A process restart is enough to pick up a new value. Read them with the @cms/client helpers:
import { env, ensureEnv } from "@cms/client";
const apiUrl = ensureEnv("CMS_PUBLIC_API_URL", "url"); // throws if missing/invalid
const flag = env("CMS_PUBLIC_BETA_BANNER"); // string | undefined
The prefix is never stripped, so the same name resolves identically in the browser and on the server. Only prefixed variables are exposed — secrets without the prefix stay server-side. Override the prefix with admin.publicEnvPrefix in cms.config.ts if you want to reuse an existing convention.
A prefixed variable must be set on the CMS instance that runs the admin, not just on your frontend service. It’s a common mistake to set such a value on the frontend and forget the CMS — the component then fails because
ensureEnvthrows on the missing value.
Production checklist
NODE_ENV=productionandDATABASE_URLpointed at the production database.CMS_SEED_ADMIN_EMAILset to a real email (or create accounts later withcms user create <email>).- Migrations generated, committed, and applied as a pre-deploy step — never
cms migration pushin production. - A reverse proxy or platform TLS in front. The built-in rate limiter is in-memory and per-process; for multi-instance deployments, put rate limiting at the edge.
- For API-only instances, set
ADMIN_UI=false.
Behind a separate frontend
Many projects run two services: the CMS engine and a separate frontend (Astro, TanStack Start, Next.js, …) that reads from the API.
- Deployment order. Deploy the engine first and wait for
GET /healthz/db→{"status":"ok"}before deploying the frontend, so the frontend never boots against an unreachable or unmigrated database. - CORS. Add the frontend’s production origin to your engine’s
cors.originsbefore going live. - Frontend runtime config. The frontend has the same rebuild-free-rotation need as the admin. Have its server read
process.envat request time and hand the values to the browser instead of baking them in — for example, a small route that returns a JS snippet assigningwindow.__ENV, loaded in the document<head>before other scripts.
Example: Fly.io
A fly.toml for the engine. release_command applies migrations in an ephemeral VM before the new version goes live; a failed migration blocks the deploy while the old version keeps serving.
app = 'my-cms-engine'
primary_region = 'cdg'
kill_signal = 'SIGTERM'
kill_timeout = '10s'
[deploy]
strategy = 'canary'
release_command = 'pnpm exec cms migration apply'
[http_service]
internal_port = 4000
force_https = true
[[http_service.checks]]
grace_period = '20s'
interval = '10s'
timeout = '5s'
method = 'GET'
path = '/healthz/db'
Create the app, set secrets, and deploy:
fly apps create my-cms-engine
fly secrets set \
DATABASE_URL="postgres://…" \
CMS_SEED_ADMIN_EMAIL="admin@example.com" \
CORS_ORIGINS="https://my-frontend.example.com" \
--app my-cms-engine
fly deploy
Further reference
The examples/ecommerce project carries a complete two-service setup — engine plus frontend — including the full multi-stage Dockerfile that applies everything above.
Previous
Live Preview
Next
REST API