Deploy
Kide builds on Astro and deploys anywhere Astro runs. The scaffolder wires one of two targets: Node.js (SQLite + local files) or Cloudflare Workers (D1 + R2). Both distribution modes (embedded or package) deploy identically.
| Node.js | Cloudflare | |
|---|---|---|
| Database | SQLite at data/cms.db |
D1 (CMS_DB binding) |
| Assets | public/uploads/ |
R2 (CMS_ASSETS binding) |
| Images | Sharp, on-demand + cached | Images binding in the Worker, edge-cached (/cdn-cgi/image opt-in) |
Two one-line adapter files handle the difference: src/cms/adapters/db.ts and storage.ts. The scaffolder already sets them (see Project-owned files).
Cloudflare
Section titled “Cloudflare”The scaffolder writes a wrangler.toml with these bindings:
| Binding | Type | Set by |
|---|---|---|
CMS_DB |
D1 database | [[d1_databases]] with migrations_dir = "src/cms/migrations" |
CMS_ASSETS |
R2 bucket | [[r2_buckets]] |
IMAGES |
Images | Not in wrangler.toml — @astrojs/cloudflare declares it on every build |
| cron | [triggers] |
crons = ["* * * * *"] — the Worker’s scheduled() handler calls the publish and tasks endpoints |
The binding names are fixed; src/cms/platform/cloudflare/ reads them by name.
create-kide-app offers to provision everything during scaffolding: D1 database, R2 bucket, migrations, and the first deploy. If you accepted, you’re already live. This section covers the manual path and day-2 operations.
Skipped provisioning? Four commands:
pnpm dlx wrangler d1 create my-site-db # paste the id into wrangler.tomlpnpm dlx wrangler r2 bucket create my-site-assetspnpm dlx wrangler d1 migrations apply my-site-db --remotepnpm run deploySchema changes. After editing collections, run pnpm db:generate and commit the migration file it writes to src/cms/migrations/. In CI, apply migrations before the build:
pnpm dlx wrangler d1 migrations apply my-site-db --remote && pnpm buildThe --remote flag matters: without it, wrangler migrates your local D1 and production stays on the old schema.
Scheduled publishing works without setup. A cron trigger fires every minute and the worker handles publishing and background tasks. Secure the endpoints:
pnpm dlx wrangler secret put CRON_SECRETImages. Nothing to enable: the Worker resizes through the Images binding. To serve renditions from /cdn-cgi/image instead, see Image optimization.
Node.js
Section titled “Node.js”pnpm buildpnpm cms:push # sync schema — fails loudly, stopping a broken deploynode dist/server/entry.mjsThe server does no schema work at boot, so cms:push belongs in every deploy. Additive changes apply directly. An ambiguous rename/drop is refused with guidance: run pnpm cms:push --recreate=<slugs> --allow-data-loss to drop and recreate the named tables (plus their _translations/_versions tables — data loss, fine for a DB you’re repopulating), or hand-write a migration to preserve data.
Persistent storage. If deploys replace the app directory (containers, fresh releases), point these at a location outside it, and back that one place up:
CMS_DATABASE_URL=/srv/kide/shared/cms.db # default: ./data/cms.dbCMS_UPLOADS_DIR=/srv/kide/shared/uploads # default: ./public/uploadsHave your web server (Caddy, nginx) serve /uploads/* straight from CMS_UPLOADS_DIR.
Compression. The default node({ mode: "standalone" }) adapter doesn’t compress responses. That’s fine behind a CDN or a reverse proxy that compresses. With nothing in front, switch to node({ mode: "middleware" }) in astro.config.mjs, run pnpm add express compression, and serve the handler from a small Express server:
import express from "express";import compression from "compression";import { handler } from "./dist/server/entry.mjs";
const app = express();app.use(compression());app.use("/_astro", express.static("dist/client/_astro", { immutable: true, maxAge: "1y" }));app.use(express.static("dist/client"));app.use(handler);app.listen(4321);Scheduled publishing. Nothing polls the cron endpoints on Node — that’s the Cloudflare Worker’s scheduled() handler. Self-hosted deployments must poll both endpoints once a minute or scheduled publishing and background tasks never run:
* * * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://example.com/api/cms/cron/publish* * * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://example.com/api/cms/cron/tasksEnvironment variables
Section titled “Environment variables”| Variable | Required | Description |
|---|---|---|
CRON_SECRET |
Required | Bearer token for /api/cms/cron/publish and /api/cms/cron/tasks. Unset: open in development, 401 in a production build |
CMS_TRUSTED_ORIGIN |
Recommended | Canonical public origin used by admin CSRF checks, e.g. https://cms.example.com |
CMS_DATABASE_URL |
Node only | SQLite file path (default ./data/cms.db) |
CMS_UPLOADS_DIR |
Node only | Upload directory (default ./public/uploads) |
CMS_LOG_LEVEL |
Optional | silent, error, warn, info (default), or debug |
RESEND_API_KEY |
Optional | Enables invite, password-reset, and form-notification emails via Resend |
RESEND_FROM_EMAIL |
Optional | Email sender address (default Kide CMS <noreply@example.com>) |
AI_PROVIDER |
Optional | AI provider for content generation (openai) |
AI_API_KEY |
Optional | AI provider API key |
AI_MODEL |
Optional | AI model name (default gpt-4o-mini) |
WEBHOOK_SECRET_<PROVIDER> |
Optional | Secret for inbound webhooks at /api/cms/webhooks/<provider> — see Inbound webhooks |
KIDE_MCP_USER_ID, KIDE_MCP_USER_ROLE, KIDE_MCP_USER_EMAIL, KIDE_MCP_ALLOW_AUTH_COLLECTIONS |
Optional | MCP server actor and safety switches — see MCP |
Node.js: put them in .env. Cloudflare: secrets via pnpm dlx wrangler secret put <NAME>, non-secret values under [vars] in wrangler.toml. Put CRON_SECRET and every WEBHOOK_SECRET_* in as secrets.