CONTENT_REPO | Git URL or local path to the content repo. Defaults to ../carapace-content (local sibling checkout) if unset — fine for local dev, required (a real URL) for the Cloudflare Pages build, which can't see a local path. Also set this as a Cloudflare Pages project environment variable for the deployed build, not just here for local dev. | (unset) |
CONTENT_REPO_TOKEN | Fine-grained GitHub PAT, Contents: Read-only, scoped to just the content repo. Embedded into an https:// CONTENT_REPO URL for authenticated cloning — needed because the content repo is private and the Cloudflare Pages build container has no other credentials. Also set this as a Cloudflare Pages project environment variable for the deployed build. | (unset) |
SITE_URL | The site's real absolute origin — sitemap.xml, canonical tags, and OG tags all require absolute URLs, on any host. Defaults to http://localhost:3000 for local dev. Not the same thing as the old GitHub-Pages-era BASE_PATH/CUSTOM_DOMAIN (removed during the Cloudflare migration, a hosting-subpath quirk) — this is an unavoidable protocol requirement, not a hosting workaround. Must be set as a real Cloudflare Pages project environment variable for production, or sitemap/canonical/ OG URLs will silently point at localhost. | (unset) |
NODE_ENV | Gates draft/archived/future-dated post filtering (lib/posts.ts::isProductionEnv) — MUST be set to exactly "production" as a real Cloudflare Pages project environment variable, or draft/archived/scheduled content is served live with no filtering at all. Genuinely required, not a platform default: confirmed directly against Cloudflare's own docs that Pages only injects four system variables automatically (CI, CF_PAGES, CF_PAGES_COMMIT_SHA, CF_PAGES_BRANCH, CF_PAGES_URL) — NODE_ENV is never one of them. This was documented as a required setting in README's Hosting section from early on, but the actual Cloudflare Pages project never had it configured in practice — a real, live gap found and fixed 2026-07-22 (see spec §10 item 7), not a hypothetical. Verify it's actually set, don't just assume the docs describe reality. | (unset) |
MCP_PUSH_TOKEN | Optional. If set, MCP server pushes (publish/preview) authenticate as this token instead of ambient git credentials, so the server can push independently of whoever's running it. Also used by list_posts to derive editorial status (in review / changes requested) for posts with an open PR — reads via the GitHub API, so it needs "Pull requests: Read and write" (a fine-grained PAT's single permission level covers both). When unset, or origin isn't a GitHub remote, list_posts still works — those two statuses just fall back to "drafting". | (unset) |
PUBLISH_MODE | direct (default) pushes straight to main. pr pushes the draft branch and opens a real GitHub PR instead — needs MCP_PUSH_TOKEN scoped with "Pull requests: Write" in addition to "Contents: Write" (a separate fine-grained PAT permission; Contents access alone isn't enough). | direct |
CLOUDFLARE_DEPLOY_HOOK_URL | Cloudflare Pages deploy hook URL (Pages project > Settings > Builds & deployments > Deploy Hooks). Used by revalidate()/publish() to trigger a rebuild after content changes — POSTing to this URL is the entire mechanism, no separate token needed. Only needs to exist wherever the MCP server runs. | (unset) |
CLOUDFLARE_API_TOKEN | CLOUDFLARE_API_TOKEN: one Account API Token shared by deploy_status and (see below) the R2 image cache's account/project lookups. Create it at My Profile > API Tokens > Create Token > Custom Token, permission Account > Cloudflare Pages > Read. GOTCHA, cost real time to work out: Cloudflare has *two* separate token systems, both confusingly called "API tokens" in different parts of the dashboard. - "User API Tokens" (tied to your login) vs. "Account API Tokens" (tied to the account itself) — always use an Account API Token for anything automated/service-like (this project, CI, etc.), never a User one. Cloudflare's own token-creation UI recommends this too. - Separately: R2 object-level operations (get/put/list individual files) are NOT supported by Cloudflare's native REST API (api.cloudflare.com) at all, confirmed directly — an Account API Token with full "R2 Storage" permissions still gets a flat 403 no matter what permission level you pick. Object operations only work via R2's S3-compatible endpoint (below), a genuinely different auth system (AWS-style signed requests, not a Bearer token). The native API only covers R2 *bucket* management (list/create/delete buckets), not the objects inside them. | (unset) |
CLOUDFLARE_BUCKET_NAME | R2-backed image cache (site/image-cache-remote.ts) — persists the local .image-cache/ across Cloudflare Pages builds, which otherwise start from a fresh checkout every time and re-encode every image from scratch. Optional: unset means local-only caching (still works, just doesn't survive a fresh CI checkout). Uses R2's S3-compatible API (hand-rolled AWS SigV4 signing) — per the gotcha above, this is the only path that actually works for reading/ writing individual objects. Get CLOUDFLARE_S3_ACCESS_KEY_ID and CLOUDFLARE_S3_SECRET_ACCESS_KEY from R2 > Manage R2 API Tokens > Create API Token (permission: Object Read & Write, scoped to this one bucket) — a *different* page than the Account API Token above, and it produces this Access Key/Secret pair specifically, not a token string. CLOUDFLARE_BUCKET_NAME: the R2 bucket's name. CLOUDFLARE_ACCOUNT_ID is shared with the block above — the R2 endpoint is derived from it directly (https://<account-id>.r2.cloudflarestorage.com), no separate endpoint URL needs storing. | (unset) |
IMAGE_CACHE_PRUNE_AFTER_DAYS | Optional, opt-in — unset means image cache entries live in R2 forever. When set, deletes cache entries no longer referenced by any current post once they've gone unreferenced for this many days (a real "last confirmed still in use" tracking manifest, not R2's own last-modified timestamp — see site/image-cache-remote.ts::pruneImageCache for why that distinction matters). Try 30/60/90 depending on how much churn your content sees. | (unset) |
MCP_ALLOWED_HOSTNAMES | Comma-separated hostnames for DNS-rebinding protection on the MCP server. Defaults to localhost-only; set this if the server is ever exposed beyond your own machine. | (unset) |
MCP_TOOL_ALLOWLIST | Comma-separated MCP tool names. Unset means every tool this server has is registered (normal solo/interactive use). Set to restrict a server instance to a narrow subset — e.g. the docs-refresh CI workflow sets this so its agent structurally cannot call delete_post/rollback_content/discard_draft, not just "is told not to" in a prompt. | (unset) |
PORT | Port for the MCP server. Defaults to 3939. | (unset) |
SITE_NAME | Site name/description used in <title>, meta tags, OG tags, the RSS feed, and llms.txt. Both optional — default to "Carapace" and a generic description. | (unset) |
CLOUDFLARE_WEB_ANALYTICS | ON by default (unset = on) — this just controls the CSP allowance for Cloudflare's own Web Analytics beacon script, which Cloudflare injects at the edge, outside this site's own build, whenever Web Analytics is toggled on for this zone in the dashboard (Analytics & Logs > Web Analytics). The allowance being on by default costs nothing if you never flip that dashboard toggle. Set to exactly "false" to opt out of the CSP allowance entirely. Does not apply to Google Analytics or any other third-party analytics vendor — this is specifically for Cloudflare's own zone analytics (spec §10 item 5's own note). | (unset) |
PREVIEW_WORKER_URL | The deployed Preview Worker's base URL (workers/preview/, spec §10 Phase 2 item 6) — e.g. "https://carapace-preview.<subdomain>.workers.dev". Read by lib/content-ops.ts::preview() to build the returned preview URL. Optional: unset means preview() still pushes the draft branch, just returns { branch } with no url — the underlying draft-sharing mechanism works whether or not the Worker is deployed. | (unset) |
GITHUB_TOKEN | GITHUB_TOKEN — the Preview Worker's own credential, a fine-grained GitHub PAT, Contents: Read-only, scoped to just the content repo (same category as CONTENT_REPO_TOKEN above, but a *separate* credential: the Worker has its own secret store, distinct from both this file and Cloudflare Pages' env vars). For local `wrangler dev` testing, Wrangler has its own convention — a gitignored `.dev.vars` file (dotenv format, auto-loaded, NOT this file) at the repo root. For the real deployment, `wrangler secret put GITHUB_TOKEN`. Listed here purely for documentation consistency with every other credential in this file, not because anything actually reads GITHUB_TOKEN from .env.local/.env.example. | (unset) |
UPLOAD_SECRET | workers/image-upload — a small standalone Worker (spec item 15) that serves a paste/drop-to-URL upload page, for getting an image from your clipboard into a real fetchable URL you can paste into a Claude chat, which add_image_url then ingests. Gates its /upload route: requests need Authorization: Bearer <this>, and the page itself reads it from its own bookmarked URL's ?secret= query param. Set as a Worker secret via `wrangler secret put UPLOAD_SECRET --config workers/image-upload/wrangler.toml` (or `pnpm image-upload:deploy` after `wrangler secret put`), not read from this file at runtime — listed here for documentation consistency only, same as GITHUB_TOKEN above. | (unset) |
GITHUB_GENERATE_TOKEN | GITHUB_GENERATE_TOKEN — one-off/manual use for scripts/generate-adopter-repos.ts (spec item 19, step 1), not read by anything else. Needs a classic PAT with `repo` scope specifically — repo creation is not something a fine-grained PAT scoped to existing repos can do at all (confirmed live, not assumed: MCP_PUSH_TOKEN gets a real 403 attempting this). The real hosted install flow gets this permission the correct way, via per-adopter OAuth at sign-in, never a static token like this one. | (unset) |