Skip to content

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).

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:

Terminal window
pnpm dlx wrangler d1 create my-site-db # paste the id into wrangler.toml
pnpm dlx wrangler r2 bucket create my-site-assets
pnpm dlx wrangler d1 migrations apply my-site-db --remote
pnpm run deploy

Schema 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:

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

The --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:

Terminal window
pnpm dlx wrangler secret put CRON_SECRET

Images. Nothing to enable: the Worker resizes through the Images binding. To serve renditions from /cdn-cgi/image instead, see Image optimization.

Terminal window
pnpm build
pnpm cms:push # sync schema — fails loudly, stopping a broken deploy
node dist/server/entry.mjs

The 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:

Terminal window
CMS_DATABASE_URL=/srv/kide/shared/cms.db # default: ./data/cms.db
CMS_UPLOADS_DIR=/srv/kide/shared/uploads # default: ./public/uploads

Have 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:

server.mjs
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:

Terminal window
* * * * * 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/tasks
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.