Deployment
Deploy CMS to production with Docker, migrations, and environment configuration.
Docker
CMS is a framework, not a deployable app. You ship a consumer project — your cms.config.ts, your package.json depending on @cms/engine, and your Dockerfile. The engine binary starts via cms start at runtime.
Minimal reference Dockerfile to drop next to your cms.config.ts:
FROM node:22-alpine
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm cms build # builds admin SPA to .cms/admin-dist/ (skip if ADMIN_UI=false)
EXPOSE 4000
CMD ["pnpm", "cms", "start"]
Build and run:
docker build -t my-cms .
docker run -p 4000:4000 --env-file .env my-cms
Always install with --frozen-lockfile in production builds — it fails fast if package.json and the lockfile are out of sync, preventing silent dep drift.
Run cms migration apply as a separate pre-deploy job — not inside the container's startup. See the Migrations section below.
Environment variables
See Installation — Environment variables for the full list. At minimum, set:
DATABASE_URL— Postgres connection stringNODE_ENV=production— production modeCMS_CONFIG_PATH— path to your config fileCMS_SEED_ADMIN_EMAIL— email for the first admin account (inserted on first boot if_useris empty)
Migrations
cms migration apply is an operator step — never auto-run on boot. Generate the migration locally, commit it, then run cms migration apply as a pre-deploy job before rolling out new code.
cms migration generate add_excerpt # locally, against your laptop DB
# review the SQL, commit it
cms migration apply # in CI/deploy job, against prod DB
Recommended CI checks:
cms migration generate --check # PR-time: did you forget to commit a migration?
cms migration check-drift # pre-deploy: has anything been ALTERed manually?
Graceful shutdown
The engine handles SIGTERM (Docker/k8s) and SIGINT (Ctrl+C) for graceful shutdown. In-flight requests are drained with a 5-second forced-exit timeout.
Production checklist
- Set
NODE_ENV=production - Set
CMS_SEED_ADMIN_EMAILto the real admin email — inserted on first boot if no users exist yet - Generate and commit migration files —
cms migration pushis dev-only and accepts data loss - Put a reverse proxy (nginx, Cloudflare) in front for TLS and rate limiting — the built-in rate limiter is in-memory, per-process
- Set
ADMIN_UI=falseif you don't need the admin panel on this instance
Multi-service deployments
Some consumer projects ship two services: the CMS engine and a separate frontend (e.g. a TanStack Start, Astro, or Next.js storefront). The CMS engine must be healthy before the frontend deploys.
Health check endpoints
The engine exposes three health routes out of the box:
| Endpoint | What it checks |
|----------|----------------|
| GET /healthz/engine | Process is up |
| GET /healthz/db | Postgres connection |
| GET /healthz/admin | Admin SPA asset is present |
All return {"status":"ok"} on success. Wire GET /healthz/db as the readiness gate in your deploy pipeline — it confirms the database is reachable and migrations have run.
Configure health checks in your platform's deployment config (e.g. [[http_service.checks]] in fly.toml), not in the Dockerfile.
Runtime environment injection
If your frontend uses environment variables that need to rotate without a rebuild (API keys, CMS URL), serve them from an endpoint rather than baking them into the bundle at build time.
Pattern: a server route (e.g. /public-runtime-envs.js) reads process.env at request time and returns a JS snippet that sets window.__ENV. The frontend reads from window.__ENV instead of import.meta.env. Rotating a key then only requires a process restart — no rebuild, no redeployment.
// src/routes/public-runtime-envs[.]js.ts
export const ServerRoute = createServerRoute({
methods: {
GET: createServerRouteHandler(async () => {
const env = {
CMS_URL: process.env.VITE_PUBLIC_CMS_URL,
CMS_API_KEY: process.env.VITE_PUBLIC_CMS_API_KEY,
}
return new Response(`window.__ENV = ${JSON.stringify(env)};`, {
headers: { 'content-type': 'application/javascript' },
})
}),
},
})
Load it in your root <head> before any other scripts:
<script src="/public-runtime-envs.js"></script>
Fly.io
Both services deploy from the same directory. Install flyctl and authenticate first:
brew install flyctl
fly auth login
CMS engine
cd examples/ecommerce
# Create app (first time only)
fly apps create my-cms-engine
# Set secrets
fly secrets set \
DATABASE_URL="postgres://..." \
S3_BUCKET="my-bucket" \
S3_REGION="us-east-1" \
S3_ACCESS_KEY_ID="..." \
S3_SECRET_ACCESS_KEY="..." \
S3_ENDPOINT="..." \
CMS_SEED_ADMIN_EMAIL="admin@example.com" \
CORS_ORIGINS="https://my-storefront.fly.dev" \
--app my-cms-engine
fly deploy --config fly-cms.toml
The release_command in fly-cms.toml runs cms migration apply in an ephemeral VM before the new version goes live. A failed migration blocks the deploy — the old version keeps serving traffic. Fix the migration and redeploy.
CMS engine environment variables
| Variable | Required | Description |
|----------|----------|-------------|
| DATABASE_URL | ✓ | Postgres connection string |
| PORT | | HTTP port (default 4000) |
| S3_BUCKET | ✓ | S3/MinIO bucket name |
| S3_REGION | ✓ | S3 region (us-east-1 for MinIO) |
| S3_ACCESS_KEY_ID | ✓ | S3 access key |
| S3_SECRET_ACCESS_KEY | ✓ | S3 secret key |
| S3_ENDPOINT | | Override endpoint for MinIO or custom S3-compatible storage |
| CMS_SEED_ADMIN_EMAIL | ✓ | Email for the first admin account (inserted on first boot if no users exist) |
| CORS_ORIGINS | ✓ | Comma-separated allowed origins |
| LOG_LEVEL | | Pino log level (default info) |
Storefront
Deploy after the CMS engine is healthy (GET /healthz/db returns {"status":"ok"}).
cd examples/ecommerce
# Create app (first time only)
fly apps create my-storefront
# Set secrets
fly secrets set \
VITE_PUBLIC_CMS_URL="https://my-cms-engine.fly.dev" \
VITE_PUBLIC_CMS_API_KEY="pk_..." \
VITE_PUBLIC_SHOPIFY_STORE_NAME="my-store" \
VITE_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN="..." \
VITE_PUBLIC_SHOPIFY_STOREFRONT_API_VERSION="2025-01" \
--app my-storefront
fly deploy --config fly.toml
Storefront environment variables
These are injected at runtime via /public-runtime-envs.js — no rebuild needed to rotate.
| Variable | Required | Description |
|----------|----------|-------------|
| VITE_PUBLIC_CMS_URL | ✓ | Full URL of the CMS engine |
| VITE_PUBLIC_CMS_API_KEY | ✓ | Public read-only CMS API key |
| VITE_PUBLIC_SHOPIFY_STORE_NAME | ✓ | Shopify store subdomain (no .myshopify.com) |
| VITE_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN | ✓ | Shopify Storefront API token |
| VITE_PUBLIC_SHOPIFY_STOREFRONT_API_VERSION | ✓ | Shopify API version (e.g. 2025-01) |
Rotating secrets
Because the storefront reads env vars at request time from /public-runtime-envs.js, rotating a key only requires a machine restart — no redeployment:
fly secrets set VITE_PUBLIC_CMS_API_KEY="pk_new..." --app my-storefront
Fly drains existing machines and restarts them with the new value.
Deployment order
- Deploy CMS engine → wait for
GET /healthz/db→{"status":"ok"} - Deploy storefront
Notes
- CORS:
CORS_ORIGINSon the engine must include the storefront's public URL. Add the storefront's.fly.devdomain before going live. - Rate limiting: The built-in limiter is in-memory and per-process. For multi-instance deployments, a Redis-backed limiter is required.
- Migrations: A failed
release_commandmigration blocks the deploy — the previous version keeps serving. Fix the migration, commit, and redeploy.
Previous
Live Preview
Next
REST API