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 string
  • NODE_ENV=production — production mode
  • CMS_CONFIG_PATH — path to your config file
  • CMS_SEED_ADMIN_EMAIL — email for the first admin account (inserted on first boot if _user is 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_EMAIL to the real admin email — inserted on first boot if no users exist yet
  • Generate and commit migration files — cms migration push is 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=false if 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

  1. Deploy CMS engine → wait for GET /healthz/db{"status":"ok"}
  2. Deploy storefront

Notes

  • CORS: CORS_ORIGINS on the engine must include the storefront's public URL. Add the storefront's .fly.dev domain 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_command migration blocks the deploy — the previous version keeps serving. Fix the migration, commit, and redeploy.

Previous

Live Preview

Next

REST API