Deployment
CaretCMS runs anywhere Astro does. Pick a delivery mode first, then match storage to your host.
- Static delivery —
caret({ delivery: 'static' }), bake at build, deploy to a CDN (guide) - Server delivery —
output: 'server'+ adapter, per-request rewrite in production - Storage adapter must match the host’s filesystem semantics
- Editor secrets as environment variables
Pre-flight checklist
Section titled “Pre-flight checklist”Run through this before any production deploy:
Static delivery (CDN / static host)
-
caret({ delivery: 'static' })inastro.config.mjs - CI runs
astro buildafter publish (webhook or git pull) - Optional:
CARET_GIT_ON_PUBLISH=trueto commit.caret/data/on publish - Build environment can read storage (
.caret/data/in repo or mounted) -
CARET_EDIT_PASSWORD/CARET_SESSION_SECRETset where authoring runs (dev/staging)
Server delivery (Node / Workers)
-
output: 'server'inastro.config.mjs(+ adapter) -
CARET_EDIT_PASSWORDset in host env (or editing disabled) -
CARET_SESSION_SECRETset to a long random value, not committed to git - HTTPS enforced on your domain
- Storage adapter matches the host (see table below)
- If you don’t want editing on this environment, set
enableAdmin: falseandenableInlineEditor: false
Adapter per host
Section titled “Adapter per host”| Host | Adapter | Notes |
|---|---|---|
| Static CDN (Netlify, Vercel static, S3, Pages) | Filesystem (default) | Static delivery — bake at build; no CMS routes in prod |
| Node (Fly, Railway, Render, your VPS) | Filesystem (default) | Server delivery; mount a persistent volume |
| Cloudflare Workers | cloudflareStorage + r2Uploads | Server delivery; KV + R2 bindings required |
| Vercel / Netlify (serverless) | Custom (Postgres / KV) | Server delivery; no writable filesystem |
Pick your host
Section titled “Pick your host”The default path for marketing and brochure sites. No SSR adapter.
-
Configure static delivery
astro.config.mjs import caret from '@caretcms/core';export default defineConfig({integrations: [caret({delivery: {mode: 'static',publish: {webhookUrl: process.env.CARET_REBUILD_WEBHOOK_URL,},},}),],}); -
Author in dev —
npm run dev, sign in at/admin, edit and publish. -
Build and deploy
Terminal window npm run buildConfirm
[caretcms] static delivery bake completein the logs. Deploydist/. -
Wire CI — on publish, trigger a build that has access to
.caret/data/(committed to git or fetched from staging).
See Static delivery for the full flow.
The simplest path. Filesystem storage works as long as you have one process and a persistent disk.
-
Install the Node adapter
Terminal window npm install @astrojs/node -
Configure Astro
astro.config.mjs import { defineConfig } from 'astro/config';import node from '@astrojs/node';import caret from '@caretcms/core';export default defineConfig({output: 'server',adapter: node({ mode: 'standalone' }),integrations: [caret()],}); -
Mount a persistent volume
CMS edits land in
.caret/data/. If your host wipes the filesystem on each deploy, mount a volume there.fly.toml [mounts]source = "cms_data"destination = "/app/.caret/data"Attach a Volume to the service, mount at
/app/.caret/data.Declare a volume on
/app/.caret/datain your compose file or service config. -
Set environment variables
Terminal window CARET_EDIT_PASSWORD=...CARET_SESSION_SECRET=...CARET_TRUST_PROXY=true # if you terminate TLS at a proxy/load balancerSet these in your host’s dashboard or CLI. Never commit them. Behind Nginx, a load balancer, or Cloudflare’s proxy, set
CARET_TRUST_PROXY=trueso theSecurecookie flag is derived fromX-Forwarded-Protorather than the (plain HTTP) connection to the origin.
Use @caretcms/cloudflare for KV storage and R2 uploads. Edge-distributed, no servers to manage.
-
Install
Terminal window npm install @astrojs/cloudflare @caretcms/cloudflare -
Configure Astro
astro.config.mjs import { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';import caret from '@caretcms/core';import { cloudflareStorage, r2Uploads } from '@caretcms/cloudflare';export default defineConfig({output: 'server',adapter: cloudflare(),integrations: [caret({storage: cloudflareStorage({ binding: 'CMS_KV' }),uploads: r2Uploads({ binding: 'CMS_R2' }),}),],}); -
Configure
wrangler.tomlwrangler.toml name = "my-site"compatibility_date = "2025-01-01"compatibility_flags = ["nodejs_compat"]# No `main` / `[assets]` — @astrojs/cloudflare v13+ generates the worker# entry and asset config at build time (dist/server/wrangler.json) and# merges these bindings into it.[[kv_namespaces]]binding = "CMS_KV"id = "<your-kv-namespace-id>"[[r2_buckets]]binding = "CMS_R2"bucket_name = "my-site-uploads"See the Cloudflare Astro framework guide for the authoritative, up-to-date Workers config.
-
Set secrets (not in
wrangler.toml)Terminal window wrangler secret put CARET_EDIT_PASSWORDwrangler secret put CARET_SESSION_SECRET -
Deploy
Terminal window npm run buildwrangler deploy
Quirks
Section titled “Quirks”- Eventual consistency — KV is eventually consistent (~60s). After a save, an immediate read might return the old value from another POP. Studio handles this by reading from the same edge that wrote.
- R2 public URLs need a custom domain or
r2.devenabled — check the bucket settings. SetR2_PUBLIC_DOMAIN(or passpublicBaseUrltor2Uploads()) so uploaded files resolve to the right public base URL; otherwise upload URLs may point at an unreachable host. - CPU limits — Workers have a 30s CPU limit per request. The CMS routes are well under this; if you build heavy custom adapters, keep them async-light.
Vercel and Netlify don’t give you a writable filesystem. Filesystem adapter is out. You need a database-backed custom adapter.
-
Implement a custom adapter
src/cms/postgres-storage.ts import type { StorageAdapter } from '@caretcms/core';class PostgresStorageAdapter implements StorageAdapter {// ...all required methods}export default function postgresStorage() {return new PostgresStorageAdapter(/* config from env */);} -
Wire it up
astro.config.mjs caret({storage: defineStorageProvider({entrypoint: './src/cms/postgres-storage.ts',exportName: 'default',}),}) -
Push uploads to blob storage — S3, R2, or your provider’s blob storage. Implement
UploadHandlersimilarly.
See Storage Adapters → Custom adapter for the full interface.
You can run static delivery for the public site and keep a separate server
environment for authoring only (staging). Editors publish on staging; CI rebuilds
and deploys static dist/ to production. No split-origin CORS workarounds needed.
Alternatively, use server delivery on one host if you want production inline editing.
Hardening checklist
Section titled “Hardening checklist”Once deployed:
- Visit
https://your-site/adminover HTTPS — confirm cookie hasSecureflag (devtools → Application) - Try logging in with the wrong password — should reject
- Try
POST /api/cms/mutatewithout the CSRF header — should return 403 - Try
POST /api/cms/mutatewithout a session cookie — should return 401 - Confirm
/adminis gated (no editor JS on public pages without the cookie) - If Cloudflare: check
wrangler tailfor errors after a save - If Node: check the host’s logs for
[caretcms]messages on boot
Content Security Policy
Section titled “Content Security Policy”The inline editor runs cleanly under Astro’s strict CSP with no extra configuration:
export default defineConfig({ output: 'server', security: { csp: true }, integrations: [caret()],});Astro auto-hashes the editor’s injected bootstrap, and every other editor asset — editor.js, its module imports, and editor.css — is served same-origin from /__caret/, so the default script-src 'self' and style-src 'self' cover them. The editor’s API calls hit same-origin /api/cms/*, which Astro’s CSP doesn’t restrict. This path is covered by an end-to-end test that asserts zero CSP violations through a full edit-and-save.
Two Astro CSP limitations to know:
- Production only — Astro emits the policy for
astro build(+preview/deploy), notastro dev. Test CSP against a preview build, not the dev server. - No
<ClientRouter />— Astro’s view transitions are not supported with CSP.
If you set CSP yourself via response headers (e.g. at the Cloudflare edge) instead of Astro’s API, allow same-origin sources: script-src 'self', style-src 'self', and connect-src 'self' for the editor’s API calls.
Disabling editing in prod
Section titled “Disabling editing in prod”If your prod environment shouldn’t be editable (you only edit on staging, then promote):
caret({ enableAdmin: import.meta.env.MODE === 'staging', enableInlineEditor: import.meta.env.MODE === 'staging',})In prod with server delivery, no admin routes are mounted when disabled; the rewrite middleware still runs. With static delivery, production is plain static HTML — disable authoring on any server-side preview by setting the toggles from import.meta.env.
Performance
Section titled “Performance”The only CaretCMS code on the hot path of every HTML response is the rewrite engine — the order: 'pre' middleware runs it over the rendered body to inject stored overrides. It’s string-based (no DOM parser) and fast: on commodity hardware it adds single-digit microseconds to a page with no editable content, well under 0.1 ms to a typical page (a dozen editable fields), and ~1 ms to a large page with 150 bindings.
Measure it on your own hardware with npm run bench. A regression tripwire in the unit suite fails the build if a large-page rewrite ever blows past a generous ceiling, so a super-linear regression can’t slip in unnoticed.