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.
Cloudflare Setup
Section titled “Cloudflare Setup”1. Create the project
Section titled “1. Create the project”pnpx create-kide-app my-site# Select "Cloudflare" when promptedThis 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).
2. Create Cloudflare resources
Section titled “2. Create Cloudflare resources”cd my-sitepnpm dlx wrangler d1 create my-site-dbpnpm dlx wrangler r2 bucket create my-site-assetsReplace 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.
3. Push the database schema
Section titled “3. Push the database schema”pnpm dlx wrangler d1 migrations apply my-site-db --remoteThis applies all pending migrations from src/cms/migrations/ and tracks what’s been applied, so it’s safe to run on every deploy.
4. Deploy
Section titled “4. Deploy”pnpm run deployThis builds the Astro app and deploys it as a Cloudflare Worker.
Running migrations in CI
Section titled “Running migrations in CI”Add the migrations command before the build in your CI/CD or Cloudflare Pages build settings:
pnpm dlx wrangler d1 migrations apply my-site-db --remote && pnpm buildThe --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.
Generating new migrations
Section titled “Generating new migrations”When you change your collection schema, generate a new migration:
pnpm db:generateThis 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.
Wrangler Configuration
Section titled “Wrangler Configuration”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 createmigrations_dir = "src/cms/migrations"
[triggers]crons = ["* * * * *"] # scheduled publishing
[[r2_buckets]]binding = "CMS_ASSETS"bucket_name = "my-site-assets"Storage
Section titled “Storage”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_ASSETSbinding, 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.
Database
Section titled “Database”- Local/Node.js: SQLite via
better-sqlite3, stored atdata/cms.db - Cloudflare: D1 (Cloudflare’s distributed SQLite), accessed via the
CMS_DBbinding
Same mechanism as storage: both implementations live at src/cms/platform/{node,cloudflare}/database.ts, and src/cms/adapters/db.ts selects between them.
Scheduled Publishing
Section titled “Scheduled Publishing”Kide CMS supports scheduled publish/unpublish for content. The approach differs by target:
Cloudflare
Section titled “Cloudflare”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:
pnpm dlx wrangler secret put CRON_SECRETNode.js
Section titled “Node.js”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:
# 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/tasksSet CRON_SECRET in your .env to match.
In dev mode, the middleware handles scheduled publishing on every request (no external cron needed).
Image Optimization
Section titled “Image Optimization”- 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
Section titled “Cloudflare Image Transformations”Cloudflare Image Transformations must be enabled on your zone for image resizing to work in production:
- Go to your Cloudflare dashboard → Images → Transformations
- 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.
Environment Variables
Section titled “Environment Variables”| 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 |
Setting variables
Section titled “Setting variables”Cloudflare – set secrets with Wrangler and non-secret values in wrangler.toml:
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-secretRESEND_API_KEY=re_xxxCMS_TRUSTED_ORIGIN=https://cms.example.comReading variables in code
Section titled “Reading variables in code”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.
Local Development
Section titled “Local Development”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.
Node.js Deployment
Section titled “Node.js Deployment”For Node.js targets, the build output is a standalone server:
pnpm buildpnpm db:migratenode dist/server/entry.mjsThe 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.
Persistent storage
Section titled “Persistent storage”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:
CMS_DATABASE_URL=/srv/kide/shared/cms.dbCMS_UPLOADS_DIR=/srv/kide/shared/uploadsHave 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).