Skip to content

Deploy

Kide CMS builds on Astro, so you can deploy it anywhere Astro runs. The setup script provides installation helpers for two targets: Node.js (local SQLite) and Cloudflare Workers (D1 + R2), configuring the database, storage, and image optimization for your choice.

Terminal window
pnpx create-kide-app my-site
# Select "Cloudflare" when prompted

This generates a wrangler.toml with D1 and R2 bindings. The database_id is pre-filled with a local placeholder so pnpm dev works immediately — you only need a real id before deploying (next step).

Terminal window
cd my-site
pnpm dlx wrangler d1 create my-site-db
pnpm dlx wrangler r2 bucket create my-site-assets

Replace the placeholder database_id in wrangler.toml with the one from the wrangler d1 create output. wrangler deploy will reject the placeholder, so this step is required before your first deploy.

Terminal window
pnpm dlx wrangler d1 migrations apply my-site-db --remote

This applies all pending migrations from src/cms/migrations/ and tracks what’s been applied, so it’s safe to run on every deploy.

Terminal window
pnpm run deploy

This builds the Astro app and deploys it as a Cloudflare Worker.

Add the migrations command before the build in your CI/CD or Cloudflare Pages build settings:

Terminal window
pnpm dlx wrangler d1 migrations apply my-site-db --remote && pnpm build

The --remote flag is required — without it wrangler migrates the local D1, leaving production on an unmigrated schema. Wrangler tracks applied migrations in a d1_migrations table, so only new migrations run. This works automatically in Cloudflare Pages builds since the environment is already authenticated.

When you change your collection schema, generate a new migration:

Terminal window
pnpm db:generate

This regenerates the CMS schema and creates a new SQL migration file in src/cms/migrations/. Commit the migration file — it will be applied on the next deploy.

The generated wrangler.toml:

name = "my-site"
compatibility_date = "2026-08-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "CMS_DB"
database_name = "my-site-db"
database_id = "" # from wrangler d1 create
migrations_dir = "src/cms/migrations"
[triggers]
crons = ["* * * * *"] # scheduled publishing
[[r2_buckets]]
binding = "CMS_ASSETS"
bucket_name = "my-site-assets"

On Cloudflare, assets are stored in R2 instead of the local filesystem. Both implementations live in the tree at src/cms/platform/{node,cloudflare}/storage.ts; src/cms/adapters/storage.ts is a one-line selector that re-exports whichever profile is active:

  • Local/Node.js: Files go to public/uploads/, served by Astro’s static file handling
  • Cloudflare: Files go to R2 via the CMS_ASSETS binding, served by a dynamic route at /uploads/[...path].ts

No code changes needed. The setup script flips the selector (and the equivalent one for the database) to point at the right profile — it never overwrites source files.

  • Local/Node.js: SQLite via better-sqlite3, stored at data/cms.db
  • Cloudflare: D1 (Cloudflare’s distributed SQLite), accessed via the CMS_DB binding

Same mechanism as storage: both implementations live at src/cms/platform/{node,cloudflare}/database.ts, and src/cms/adapters/db.ts selects between them.

Kide CMS supports scheduled publish/unpublish for content. The approach differs by target:

A cron trigger fires every minute. The CMS integration injects a scheduled handler into the built worker entry that calls both /api/cms/cron/publish (scheduled publish/unpublish) and /api/cms/cron/tasks (draining the durable task queue) internally.

Set the CRON_SECRET env var on your worker to secure the endpoint:

Terminal window
pnpm dlx wrangler secret put CRON_SECRET

Set up an external cron job to poll both cron endpoints — /api/cms/cron/publish handles scheduled publish/unpublish, and /api/cms/cron/tasks drains the durable task queue (webhooks, integrations). Self-hosted Node deployments must poll both, or background/durable tasks never run:

Terminal window
# crontab
* * * * * curl -s -H "Authorization: Bearer YOUR_SECRET" http://localhost:4321/api/cms/cron/publish
* * * * * curl -s -H "Authorization: Bearer YOUR_SECRET" http://localhost:4321/api/cms/cron/tasks

Set CRON_SECRET in your .env to match.

In dev mode, the middleware handles scheduled publishing on every request (no external cron needed).

  • Local/Node.js: Sharp-based on-demand transformation via /api/cms/img/. Images are resized, converted to WebP, and cached in .cms-cache/. The admin UI uses thumbnails automatically.
  • Cloudflare: Cloudflare Image Transformations via /cdn-cgi/image/. Sharp is not used.

The cmsImage() and cmsSrcset() helpers detect the runtime and generate the correct URLs automatically. The admin UI uses 480px thumbnails for asset grids and image pickers.

Cloudflare Image Transformations must be enabled on your zone for image resizing to work in production:

  1. Go to your Cloudflare dashboard → ImagesTransformations
  2. Enable Resize images from any origin

Once enabled, URLs like /cdn-cgi/image/width=480,format=webp,quality=80/uploads/photo.jpg are handled automatically by Cloudflare’s edge network.

In local development with pnpm dev, images are served from R2 without transformation (wrangler doesn’t emulate /cdn-cgi/image/). This is fine for development — transformations only apply in production.

If Image Transformations are not available on your Cloudflare plan, images will be served at full size. The admin UI and public site will still work — just without resizing.

Variable Required Description
CRON_SECRET Required Secures the cron endpoints — they return 401 in production until it is set
RESEND_API_KEY Optional Enables automatic invite emails via Resend
RESEND_FROM_EMAIL Optional Email sender address
CMS_TRUSTED_ORIGIN Recommended Canonical public origin used by admin CSRF checks, e.g. https://cms.example.com
AI_PROVIDER Optional AI provider for content generation
AI_API_KEY Optional AI provider API key
AI_MODEL Optional AI model name

Cloudflare – set secrets with Wrangler and non-secret values in wrangler.toml:

Terminal window
pnpm dlx wrangler secret put CRON_SECRET
[vars]
CF_BEACON_TOKEN = "your-token"

Node.js – add variables to a .env file in the project root:

CRON_SECRET=your-secret
RESEND_API_KEY=re_xxx
CMS_TRUSTED_ORIGIN=https://cms.example.com

On Cloudflare Workers, runtime secrets are not available via import.meta.env. Astro only exposes build-time and PUBLIC_-prefixed variables there. Instead, use the env export from cloudflare:workers:

import { env as cfEnv } from "cloudflare:workers";
const env = (key: string) =>
(cfEnv as Record<string, string>)[key] ?? import.meta.env[key];

The cloudflare:workers import works both locally (via wrangler emulation during pnpm dev) and on deployed Workers. The import.meta.env fallback is a safety net but not strictly needed since the Cloudflare runtime always provides the bindings.

Inside the CMS, readEnv() is the runtime helper that wraps this — it reads secrets from cloudflare:workers (not import.meta.env), so prefer it over rolling your own accessor.

Using import.meta.env alone will silently return undefined for secrets on Cloudflare, causing features like AI and email to appear disabled even when the variables are set.

For Cloudflare projects, pnpm dev uses Astro’s dev server with local D1/R2 emulation via miniflare. Your wrangler.toml bindings work locally without any extra setup.

For Node.js targets, the build output is a standalone server:

Terminal window
pnpm build
pnpm db:migrate
node dist/server/entry.mjs

The SQLite database and uploads are stored locally. Run pnpm db:migrate as an explicit step in your deploy so schema changes are applied — and observed — before the server starts; it exits non-zero on failure, so a broken migration stops the deploy. The server also applies pending migrations on first boot as a safety net, but in production it aborts startup on any migration error rather than serving a half-migrated schema, so surfacing failures in the deploy step is the better place to catch them.

Because SQLite is a single file, a schema-changing migration may need a brief maintenance window on a single-server deploy. Prefer additive, backward-compatible migrations (new tables and columns) so old and new code can run against the same schema and no downtime is needed. Back up data/cms.db before migrating.

If each deploy is a fresh directory, container, or release — anything that replaces the app tree — put the database and uploads outside it, or they are lost on every deploy. Two env vars control this; both default to in-tree paths so local development needs no configuration:

  • CMS_DATABASE_URL — path to the SQLite file (default ./data/cms.db).
  • CMS_UPLOADS_DIR — directory holding uploaded files (default ./public/uploads).

Point them at a persistent location and back that one place up:

Terminal window
CMS_DATABASE_URL=/srv/kide/shared/cms.db
CMS_UPLOADS_DIR=/srv/kide/shared/uploads

Have your web server (Caddy, nginx) serve /uploads/* directly from CMS_UPLOADS_DIR. That keeps media off the Node process and avoids the stale build-time copy of public/ that the standalone server would otherwise serve. Kide only needs the directory to be readable and writable at those paths; it takes no position on how you deploy. For horizontal scaling, swap local uploads for S3-compatible object storage by replacing the storage adapter (the Cloudflare target already does this with R2).