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-lockfile so the build fails fast if package.json and the lockfile have drifted.
  • Don’t apply migrations on container startup — cms start never does. Run cms migration apply as a separate pre-deploy step (see Database migrations).
  • For headless instances, set ADMIN_UI=false. cms build becomes 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:

  • tsx must be a runtime dependency, not a devDependency. The engine compiles cms.config.ts on boot through tsx, so --prod has to keep it.
  • Never COPY node_modules between stages. pnpm lays out node_modules as 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 with ERR_MODULE_NOT_FOUND. Always let each stage build its own tree with a native pnpm 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:

VariableDefaultDescription
DATABASE_URLPostgres connection string. Required.
PORT4000HTTP listen port.
NODE_ENVSet to production to enable secure cookies and JSON logs.
ADMIN_UItrueSet to false (or 0/off/no) to run headless.
CMS_CONFIG_PATHauto-detectedPath to your config file. Override only if it isn’t at cwd/cms.config.ts (or .tsx).
CMS_SEED_ADMIN_EMAILadmin@cms.localEmail for the first admin, seeded on boot only when the _user table is empty. Auth is OTP-only — no password.
LOG_LEVELinfoPino log level (debug, info, warn, error).
MIGRATIONS_PATH./migrationsDirectory 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:

EndpointWhat it checks
GET /api/healthProcess is up; includes DB status when fully wired.
GET /healthz/engineProcess is up.
GET /healthz/dbPostgres is reachable.
GET /healthz/adminThe 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 ensureEnv throws on the missing value.

Production checklist

  • NODE_ENV=production and DATABASE_URL pointed at the production database.
  • CMS_SEED_ADMIN_EMAIL set to a real email (or create accounts later with cms user create <email>).
  • Migrations generated, committed, and applied as a pre-deploy step — never cms migration push in 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.origins before going live.
  • Frontend runtime config. The frontend has the same rebuild-free-rotation need as the admin. Have its server read process.env at request time and hand the values to the browser instead of baking them in — for example, a small route that returns a JS snippet assigning window.__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