Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

This page describes every configuration file of an Ocre app (cloudflare.config.ts, wrangler.config.ts, package.json, tsconfig.json, .dev.vars, Cargo.toml, rust-toolchain.toml), the binding names Ocre requires, and each Worker variable and secret the ocre crate reads, with its format, default, where to set it in development and production, and the error when it is missing.

Before you start

  • An Ocre app created with ocre new (see CLI commands). The files below are shown as ocre new and the generators write them; docs-app stands for your app name.
  • Its npm packages installed: ocre new runs npm install (skip it with --no-install, then run npm install yourself). The TypeScript files import their types from node_modules.
  • Setting production secrets needs a Cloudflare login (ocre login, which runs cf auth login).
  • Values of the free-plan limits quoted in comments are dated September 2026; see Free-plan limits for the sources.

Files at a glance

FileWritten byCommittedPurpose
cloudflare.config.tsocre new, then generators (ocre g job, schedule, cache, auth, --realtime, attachments) and ocre deploy (KV ids)yesWorker name, logs, bindings (D1, R2, KV, Queues, Durable Objects, rate limiting, email), plain-text variables, queue consumers and cron schedules, exported Durable Object classes
wrangler.config.tsocre newyesThe build command (worker-build) and the static-assets directory
package.json, package-lock.jsonocre new and its npm installyesThe pinned cf, wrangler and typescript versions
tsconfig.jsonocre newyesType checking of the two .ts files, for your editor and npx tsc -p .
.dev.varsocre newno (.gitignore)Secrets and variable overrides for ocre dev only
Cargo.tomlocre new, then ocre g api --graphql and --realtime (features)yesRust dependencies, Ocre features, API-only mode
rust-toolchain.tomlocre newyesStable Rust with the wasm32-unknown-unknown target
rustfmt.tomlocre newyesThe formatting style generators and ocre ci use

Every ocre command that runs inside an app looks for the nearest cloudflare.config.ts in the current directory or its parents. An app that still has only a wrangler.toml is refused with a hint pointing to Upgrading from wrangler.toml.

cloudflare.config.ts

The app’s Cloudflare configuration, read by Cloudflare’s cf CLI (cf dev, cf deploy) and by Ocre. ocre new docs-app writes:

// The app's Cloudflare configuration: bindings, triggers, exported classes.
// Types come from `cf/config`, so your editor shows every option; check the
// file with `npx tsc -p .` (or `ocre doctor`). `ocre g ...` adds entries at the
// `// ocre:` markers: keep them, and keep Ocre's entries as literals.
import { bindings, defineConfig, exports, triggers } from "cf/config";

export default defineConfig({
	worker: {
		name: "docs-app",
		compatibilityDate: "2026-09-01",
		// Built by worker-build (see wrangler.config.ts); `ocre dev` builds with --dev.
		entrypoint: "build/index.js",
		// Workers Logs: every request (method, URL, status, CPU time) and every
		// console line, searchable in the dashboard. Free plan: 200,000 events a
		// day, kept 3 days; beyond that, logs are sampled, never billed.
		observability: { enabled: true },
		// Files in public/ (robots.txt, images, CSS...) are served by Cloudflare
		// before the Worker runs: free, and not counted as Worker requests.
		// public/_headers sets their headers (e.g. long caching); a single-page
		// app would add `assets: { notFoundHandling: "single-page-application" },`.
		env: {
			// The first `ocre deploy` creates this database; no id needed.
			DB: bindings.d1({ name: "docs-app" }),
			// Plain-text variables. Secrets go in .dev.vars for `ocre dev` and in
			// `ocre secrets push NAME --file <file>` for production; .dev.vars
			// overrides these locally.
			// Sender for `ocre::mail::send`: "noreply@yourdomain.com" or "Name <noreply@yourdomain.com>".
			MAIL_FROM: bindings.text("docs-app <noreply@example.com>"),
			// How production sends mail (`ocre dev` only prints it: MAIL_ADAPTER=log in .dev.vars):
			// "resend" needs the RESEND_API_KEY secret (free: 100 emails/day, 3,000/month);
			// "cloudflare" needs the EMAIL binding below (any recipient needs the
			// Workers Paid plan; the free plan only reaches verified addresses of the account).
			// MAIL_ADAPTER: bindings.text("resend"),
			// Cloudflare Email Service binding, for MAIL_ADAPTER "cloudflare". `ocre dev`
			// simulates it and prints each message.
			// EMAIL: bindings.sendEmail(),
			// ocre:env
		},
		// Receiving email: run `ocre g mailbox`, deploy, then in the Cloudflare
		// dashboard Email Routing > Routing rules, send an address to this Worker.
		triggers: [
			// ocre:triggers
		],
		exports: {
			// ocre:exports
		},
	},
});

defineConfig, bindings, triggers and exports come from the cf/config module of the cf package, so an editor with TypeScript support completes and checks every option once npm install has run.

How Ocre reads and edits it

  • Markers. Generators insert their entries on the line right after one of three marker comments, which must stay on their own line:

    MarkerInsideEntries added there
    // ocre:envworker.envbindings and variables: DB, JOBS, STORAGE, CACHE, CHANNELS, AUTH_RATE_LIMITER, EMAIL
    // ocre:triggersworker.triggersqueue consumers (ocre g job) and cron schedules (ocre g schedule)
    // ocre:exportsworker.exportsthe OcreChannel Durable Object class (first --realtime scaffold)

    A generator that needs a marker which is gone stops with cloudflare.config.ts is missing the `// ocre:env` marker (or the other marker) and a hint saying where to put it back.

  • Literals only. Ocre reads the file itself, without running Node: it looks for the KEY: bindings.<kind>(...), triggers.<kind>(...) and KEY: exports.<kind>(...) calls inside defineConfig({ worker: { ... } }) and parses their arguments as object literals (unquoted keys and trailing commas are fine; comments are skipped). Write the values Ocre needs (the worker name, DB’s name, queue, bucket and cron values, KV ids) as string or number literals. A variable, a template string or a spread in one of them is an error naming the canonical form, for example cloudflare.config.ts defines `DB` in a form Ocre cannot read with the hint write `DB: bindings.d1({ name: "docs-app" }),`. Values Ocre does not read can be any TypeScript.

  • Checking. npx tsc -p . type-checks both .ts files against cf’s types (a wrong period: 30 on a rate limiter gives Type '30' is not assignable to type '10 | 60'). ocre doctor runs it too, and validates the file with cf’s own loader.

Canonical entries

What each generator inserts, for an app named docs-app (most come with a comment on the free-plan limits, inserted above them):

Added byMarkerEntry
ocre new(written in place)DB: bindings.d1({ name: "docs-app" }),
ocre g cache// ocre:envCACHE: bindings.kv(),; ocre deploy rewrites it to CACHE: bindings.kv({ id: "<id>" }),
first ocre g job// ocre:envJOBS: bindings.queue({ name: "docs-app-jobs" }),
first ocre g job// ocre:triggerstriggers.queue({ name: "docs-app-jobs", deadLetterQueue: "docs-app-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
ocre g job <Name> --queue urgentbothJOBS_URGENT: bindings.queue({ name: "docs-app-jobs-urgent" }), and its own triggers.queue({ name: "docs-app-jobs-urgent", deadLetterQueue: "docs-app-jobs-urgent-failed", maxBatchSize: 10, maxBatchTimeout: 1, maxRetries: 5 }),
ocre g schedule// ocre:triggerstriggers.scheduled({ schedule: "0 3 * * *" }),, one per expression
first attachment field// ocre:envSTORAGE: bindings.r2({ name: "docs-app-storage" }),
first --realtime scaffold// ocre:envCHANNELS: bindings.durableObject({ worker: "docs-app", exportName: "OcreChannel" }),
first --realtime scaffold// ocre:exportsOcreChannel: exports.durableObject({ storage: "sqlite" }),
ocre g auth// ocre:envAUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "2964407", simple: { limit: 10, period: 60 } }),
you// ocre:envEMAIL: bindings.sendEmail(), (uncomment it for MAIL_ADAPTER "cloudflare")
you// ocre:envKEY: bindings.text("value"), for a plain-text variable

ocre g mailbox adds nothing to the file. ocre destroy never edits cloudflare.config.ts (like Cargo.toml): remove the entries yourself.

Top-level and worker keys

KeyValueNotes
accountId (top level)a 32-character account idWritten before worker: as accountId: "<id>", when you pass ocre new --account-id <id> or pick an account in the interactive ocre new; needed when your login has several accounts. Without it, cf uses the logged-in account (or CLOUDFLARE_ACCOUNT_ID).
worker.namethe app nameThe Worker name; the URL is https://<name>.<your-subdomain>.workers.dev. ocre deploy also names the KV namespaces after it (<name>-cache).
worker.compatibilityDate2026-09-01The workerd behavior the Worker runs with. Change it only on purpose.
worker.entrypointbuild/index.jsThe JavaScript shim worker-build writes next to the WebAssembly module.
worker.observability{ enabled: true }Workers Logs: each request (method, URL, status, CPU time) and each line the Worker logs, including Ocre’s [ocre] error lines, is kept and searchable in the dashboard (Workers & Pages > your Worker > Logs). On the free plan: 200,000 log events a day, kept 3 days; above that, events are sampled, never billed (September 2026). Remove it or set enabled: false to keep only live logs. Ocre reads nothing from it. See Deployment.
worker.assetsabsent{ notFoundHandling: "single-page-application" } for a single-page app. The directory itself is set in wrangler.config.ts.

env: DB (D1)

DB: bindings.d1({ name: "docs-app" }),, written by ocre new.

  • The key must be DB: ctx.db() looks the binding up by this name, and every ocre database command looks for it.
  • name is the database name. ocre deploy creates the database on the first deploy when cf d1 list --name does not find it; remote commands resolve the name to the database id the same way, so no id is needed.
  • Migrations are the numbered SQL files of migrations/ (cf’s default directory; there is no migrations_dir key).

Without a DB entry, every ocre command that needs the database stops before running anything:

error: cloudflare.config.ts has no D1 database bound to `DB`
hint: add `DB: bindings.d1({ name: "docs-app" }),` inside `worker.env`

If the Worker runs without the binding anyway (a hand-written cf deploy of another config), ctx.db()? fails with a 500 and logs [ocre] D1 binding `DB` is missing (...) followed by the fix.

env: plain-text variables

KEY: bindings.text("value"), entries are plain-text Worker variables, deployed with the code on every ocre deploy. ocre new sets MAIL_FROM and leaves MAIL_ADAPTER commented out. Add ALLOWED_ORIGINS when another site calls the app, ALLOWED_HOSTS to answer only on your own host names, and your own variables (read them with ctx.env().var("NAME"), see Reading your own variables). Never put secrets here: the file is committed.

env: EMAIL (send_email)

EMAIL: bindings.sendEmail(),

The Cloudflare Email Service binding used when MAIL_ADAPTER is "cloudflare". ocre new writes it commented out; uncomment it to use it. The key must be EMAIL. Without it, sending fails with a 500 whose log says cannot send email: the send_email binding `EMAIL` is missing (...) followed by the fix. See Email.

env: CHANNELS and exports: OcreChannel (Durable Objects)

Added by the first ocre g scaffold <Model> ... --realtime:

// in worker.env, after // ocre:env
// The realtime channels' Durable Objects (`ocre::realtime`).
CHANNELS: bindings.durableObject({ worker: "docs-app", exportName: "OcreChannel" }),

// in worker.exports, after // ocre:exports
// Realtime channels (`ocre::realtime`): one Durable Object per channel holds the
// browsers' WebSockets, hibernated between broadcasts so idle connections cost
// no duration. The free plan only accepts SQLite-backed classes.
OcreChannel: exports.durableObject({ storage: "sqlite" }),

The binding key must be CHANNELS and the export OcreChannel (the class Ocre’s realtime feature exports); worker is the app’s own Worker name. ocre deploy needs no extra step: cf deploy creates the namespace from the export. Without the binding, ocre::realtime::broadcast and WebSocketUpgrade::connect fail with a 500 whose log names the entries to add. See Realtime.

env: STORAGE (R2)

Added by the first generator with an attachment field:

// Files (`ocre::storage`, `attachment` fields): an R2 bucket. `ocre dev` keeps a
// local copy under .wrangler/state; `ocre deploy` creates the bucket if needed.
// Free plan: 10 GB stored, 1M writes and 10M reads a month; deletes are free.
STORAGE: bindings.r2({ name: "docs-app-storage" }),

The key must be STORAGE. ocre deploy checks each R2 name with cf r2 buckets get and creates the missing ones with cf r2 buckets create. R2 must be enabled once in the dashboard (it asks for a payment method even for the free tier); when it is not, the deploy stops with a hint saying so (Cloudflare API code 10042). See File storage.

env: JOBS and triggers.queue (Queues)

Added by the first ocre g job:

// in worker.env
// Background jobs (`ocre g job`): the Worker sends jobs to this queue and runs
// them (src/jobs/). `ocre deploy` creates it and its dead-letter queue. Free
// plan: 10,000 Queues operations a day (a job costs 3: write, read, delete; a
// retry one more read), messages kept 24 hours, 128 KB each.
JOBS: bindings.queue({ name: "docs-app-jobs" }),

// in worker.triggers
// Runs the jobs of docs-app-jobs: up to 10 messages per run, waiting at most 5 s
// to fill a batch. Each run is one Worker request with 10 ms of CPU on the free
// plan: lower maxBatchSize for CPU-heavy jobs. A failing job is retried with a
// growing delay (30 s, 1 min, 3 min, 9 min, 27 min), then moved to
// docs-app-jobs-failed, where it stays 24 hours (dashboard: Queues > docs-app-jobs-failed).
triggers.queue({ name: "docs-app-jobs", deadLetterQueue: "docs-app-jobs-failed", maxBatchSize: 10, maxBatchTimeout: 5, maxRetries: 5 }),
KeyValueMeaning
binding keyJOBSRequired name: ocre::jobs::enqueue looks it up (JOBS_<NAME> for a named queue)
name<app>-jobsThe same queue for the binding (producer) and the trigger (consumer): the app’s Worker sends and runs its own jobs
maxBatchSize10Messages per consumer run (Cloudflare allows up to 100)
maxBatchTimeout5Seconds to wait to fill a batch (up to 60)
maxRetries5Deliveries after the first before the message goes to the dead-letter queue
deadLetterQueue<app>-jobs-failedWhere failed messages are kept (24 hours on the free plan)

ocre deploy lists the account’s queues (cf queues list) and creates every queue named here (bindings, triggers and dead-letter queues) that is missing, before deploying. Without the binding, enqueue fails with a 500 whose log says to run ocre g job <Name>. See Background jobs and schedules.

triggers.scheduled (Cron Triggers)

Added by ocre g schedule, one entry per expression, run by src/schedules/:

triggers.scheduled({ schedule: "0 3 * * *" }),

Cron expressions are in UTC. The free plan allows 5 Cron Triggers per account (all Workers together); ocre g schedule counts the triggers.scheduled entries of the file and warns when the app goes past 5.

env: CACHE (Workers KV)

Added by ocre g cache:

// Cached values for `ocre::cache` (Workers KV), added by `ocre g cache`. The
// first `ocre deploy` creates the namespace and writes its id here; `ocre dev`
// uses a local one. Free plan: 100,000 reads and 1,000 writes a day, 1 GB.
CACHE: bindings.kv(),

The key must be CACHE. For each KV binding without an id, ocre deploy looks for a namespace titled <name>-<binding> (lowercased, _ becomes -: docs-app-cache), creates it when missing, and rewrites the entry to CACHE: bindings.kv({ id: "<id>" }),. Commit that change so every machine deploys to the same namespace. Without the binding, ocre::cache functions fail with a 500 whose log says to run ocre g cache. See Caching.

env: AUTH_RATE_LIMITER (rate limiting)

Added by ocre g auth:

// `ocre g auth`: login, sign-up, token and emailed-link routes allow 10 attempts
// a minute per IP address and Cloudflare location (Workers Rate Limiting,
// free plan, no storage used). `period` is 10 or 60 seconds.
AUTH_RATE_LIMITER: bindings.rateLimit({ namespace: "2964407", simple: { limit: 10, period: 60 } }),
KeyValueMeaning
binding keyAUTH_RATE_LIMITERThe binding the generated throttle (in src/auth_api.rs) passes to ocre::security::rate_limit
namespacean integer as a string, derived from the app nameBindings with the same namespace share counters across the account’s Workers: keep it unique per app
simple.limit10Requests allowed per key and period; the next one gets 429 Too Many Requests
simple.period60The window in seconds: 10 or 60 only (the type rejects anything else)

Add more bindings.rateLimit(...) entries with other keys for your own routes and call ocre::security::rate_limit(&ctx, "NAME", &key).await? (see Sessions, flash and security). The binding is on the free plan and uses no D1, KV or Durable Object operation; counters are per Cloudflare location and approximate; ocre dev simulates it. Without the binding, rate_limit fails with a 500 whose log names the entry to add.

Binding names Ocre requires

Bindingcloudflare.config.ts entryUsed byAdded by
DBbindings.d1ctx.db(), models, ocre migrate, ocre sql, ocre db ...ocre new
STORAGEbindings.r2ocre::storage, attachment fieldsfirst generator with an attachment field
JOBSbindings.queue (plus triggers.queue)ocre::jobs::enqueue, enqueue_in, ocre::mail::deliver_laterfirst ocre g job
CACHEbindings.kvocre::cacheocre g cache
CHANNELSbindings.durableObject (export OcreChannel)ocre::realtimefirst --realtime scaffold
EMAILbindings.sendEmailocre::mail with MAIL_ADAPTER "cloudflare"commented out by ocre new

The names are constants of the crate (ocre::storage::STORAGE_BINDING, ocre::jobs::QUEUE_BINDING, ocre::cache::CACHE_BINDING, ocre::realtime::CHANNELS_BINDING, ocre::mail::EMAIL_BINDING); they cannot be renamed. Every missing-binding error is an Error::Internal: the visitor gets a 500 page (or {"error": {"status": 500, "message": ...}} from JSON handlers), and the Worker log gets the full message prefixed with [ocre], including the fix.

Rate limiting bindings are the exception: ocre::security::rate_limit takes the binding name as an argument, so AUTH_RATE_LIMITER is only the name the ocre g auth code uses (RATE_LIMITER in src/auth_api.rs).

wrangler.config.ts

cf dev, cf build and the build step of cf deploy hand the build to the app’s own wrangler (the wrangler devDependency), which reads this file:

// How the Rust code is built and where the static files are, read by cf
// (`cf dev`, `cf deploy`) through the app's wrangler. Bindings and triggers
// live in cloudflare.config.ts.
import { defineWranglerConfig } from "wrangler/experimental-config";

export default defineWranglerConfig({
	// `ocre dev` sets OCRE_BUILD=--dev (fast, unoptimized); deploys build --release.
	build: { command: 'cargo install -q "worker-build@^0.8" && worker-build ${OCRE_BUILD:---release}' },
	assetsDirectory: "public",
	types: { generate: false },
});

build.command builds the Rust crate to WebAssembly with worker-build (installed with cargo install on first use). ${OCRE_BUILD:---release} makes the mode depend on the OCRE_BUILD environment variable:

CommandOCRE_BUILDBuild
ocre dev--devUnoptimized, fast to compile
ocre deploy and every other ocre command that builds--releaselto = true, opt-level = "z" (from Cargo.toml)
npx cf deploy run by handunset--release (the default after :-)

Chain a CSS or JavaScript bundler before worker-build in build.command (see Assets).

assetsDirectory: "public": files in public/ are served by Workers Static Assets before the Worker runs. Requests that match a file cost no Worker request and no CPU (billing). Limits on the free plan: 20,000 files per Worker version, 25 MiB per file (limits, September 2026).

package.json

{
  "name": "docs-app",
  "private": true,
  "type": "module",
  "devDependencies": {
    "cf": "1.0.0-beta.5",
    "typescript": "5.9.3",
    "wrangler": "4.144.0"
  }
}
  • The versions are pinned exactly, and ocre new’s npm install writes package-lock.json: commit both, so every machine and CI run the same cf. ocre doctor warns when the installed cf differs from the version Ocre expects, or wrangler is older than 4.136.
  • cf runs dev, deploy and every Cloudflare API call; wrangler is what cf delegates the build to, and what Ocre uses for local D1 commands (see Why wrangler still appears); typescript checks the config files.
  • "type": "module" lets cf load cloudflare.config.ts without a module-type warning on every call.
  • Node.js 22 or newer is required (cf’s engines).

tsconfig.json

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["cloudflare.config.ts", "wrangler.config.ts"]
}

It only covers the two config files: without it, editors and tsc cannot resolve cf/config. Run npx tsc -p . after editing cloudflare.config.ts by hand; nothing is emitted.

.dev.vars

ocre new writes a git-ignored .dev.vars with a random local secret and the log mail adapter:

SECRET_KEY_BASE=2db19cad9ab790ae4ef78858a74ecbc0300efd7e700b6513da53bcbd89e2b69e4eb28523c9cd31ea53617afc4495996fac76e80bc29233a1ae1dd464bfe3dd80
MAIL_ADAPTER=log
  • Format: one NAME=value per line (dotenv).
  • Only ocre dev (cf dev) reads it; it never reaches Cloudflare. A name in both .dev.vars and a bindings.text(...) entry takes the .dev.vars value locally, which is how MAIL_ADAPTER=log keeps development from sending real mail.
  • .gitignore lists .dev.vars, .dev.vars.*, .prod.vars, .env and .env.*. Each clone needs its own: copy the lines above with a value from ocre secret.
  • ocre dev prints what the Worker receives; secrets are hidden (output of an app named ftapp with an attachment field):
Using secrets defined in .dev.vars
Your Worker has access to the following bindings:
Binding                                                  Resource                  Mode
env.DB (ftapp)                                           D1 Database               local
env.STORAGE (ftapp-storage)                              R2 Bucket                 local
env.MAIL_FROM ("ftapp <noreply@example.com>")            Environment Variable      local
env.SECRET_KEY_BASE ("(hidden)")                         Environment Variable      local
env.MAIL_ADAPTER ("(hidden)")                            Environment Variable      local

Variables and secrets

Cloudflare gives a Worker two kinds of text values, read the same way in code:

Variables (bindings.text)Secrets
Wherecloudflare.config.ts, committedEncrypted on Cloudflare; never in the repository
Set in productionedit cloudflare.config.ts, then ocre deployocre secrets push NAME --file .prod.vars (values read from that git-ignored file, never from the command line)
Set for ocre devbindings.text(...), or .dev.vars to override.dev.vars
Use forSender address, adapter names, allowed origins and hostsSECRET_KEY_BASE, API keys, OAuth client secrets
# .prod.vars: NAME=value lines, git-ignored, never committed
ocre secrets push RESEND_API_KEY --file .prod.vars          # uploads the value from the file
ocre secrets list                                           # names only, never values
ocre secret                                                 # a new SECRET_KEY_BASE value for .prod.vars

The free plan allows 64 variables and secrets per Worker, 5 KB each (limits, September 2026).

Code reads a secret with ctx.secret("NAME").await?, wherever it is kept: a Worker secret, a .dev.vars value, or a Secrets Store secret (below). Ocre’s own secrets that it reads asynchronously (RESEND_API_KEY, VAPID_PRIVATE_KEY, OAuth client secrets) go through it too.

Secrets Store: shared secrets

Secrets Store (open beta, 100 secrets per account on every plan) keeps secrets at the account level: one Resend or RunPod key for every app of the account, rotated once. A Worker reads one through a binding:

// worker.env in cloudflare.config.ts
RESEND_API_KEY: bindings.secretsStoreSecret({ storeId: "<store id>", secretName: "RESEND_API_KEY" }),
ocre secrets push RESEND_API_KEY --store --file .prod.vars   # stores it, adds the binding
ocre deploy
  • In code nothing changes: ctx.secret("RESEND_API_KEY").await? reads the binding (one call to the store per read).
  • ocre dev and ocre test --e2e copy the .dev.vars value of each such binding into the local store before starting (cf dev gives the binding the local store’s value and ignores .dev.vars), and print RESEND_API_KEY: .dev.vars value copied into the local Secrets Store. Without a .dev.vars value, ctx.secret fails with the fix.
  • SECRET_KEY_BASE and the R2_* settings stay Worker secrets: Ocre reads them without waiting (sessions, cookies, presigned URLs), and ocre secrets push --store refuses them.
  • A binding and a Worker secret cannot share a name: delete the Worker secret (dashboard: Workers > the Worker > Settings > Variables and Secrets) after moving it to the store.
  • Like a Worker secret, a store secret cannot be read back: keep its value in .prod.vars or a password manager.

The ocre crate reads the following names.

SECRET_KEY_BASE

  • Kind: secret. Required as soon as a request touches the session, the flash or a JWT.
  • Meaning: the root key of the app. The session cookie’s AES-256-GCM key is derived from it, and ocre::jwt derives its HS256 signing key from it with a different label, so one secret covers cookies and tokens.
  • Format: at least 64 characters. ocre secret prints a new random value of 128 hex characters (--json: {"command":"secret","ok":true,"secret":"..."}).
  • Development: ocre new writes one to .dev.vars.
  • Production: ocre deploy checks cf workers secrets list; when the Worker has no SECRET_KEY_BASE (first deploy), it uploads the one of .prod.vars, or generates one, uploads it and appends it to .prod.vars (back that file up: Cloudflare never returns a secret, and encrypted columns are lost with it); it is uploaded with the deploy (cf deploy --secrets-file, from a temporary .wrangler/ocre-secrets.json readable only by you and deleted afterwards). Every deploy passes that file, {} when there is no secret to add: cf keeps a Worker’s existing secrets (including those of ocre secrets push) only when --secrets-file is passed; deployed without it, the new version has none. A deploy that created the secret prints Created the SECRET_KEY_BASE secret on Cloudflare. An existing secret is never replaced. If the secrets list fails for another reason than a missing Worker, the deploy stops rather than risk overwriting it.
  • Rotation: uploading a new value (ocre secret, put it in .prod.vars as SECRET_KEY_BASE=..., then ocre secrets push SECRET_KEY_BASE --file .prod.vars) alone makes every session cookie and JWT signed with the old value invalid: everyone is signed out. To rotate without signing anyone out, keep the old value in SECRET_KEY_BASE_PREVIOUS first.
  • Errors: when it is missing or shorter than 64 characters, requests that read an existing session cookie or change the session answer 500 and log (captured from ocre dev with the line removed from .dev.vars):
✘ [ERROR] [ocre] the SECRET_KEY_BASE secret is not set. Fix: run `ocre secret`, put the value in .dev.vars as SECRET_KEY_BASE=... for `ocre dev` (`ocre new` does this), and deploy with `ocre deploy`, which uploads it

The short-value message is SECRET_KEY_BASE is shorter than 64 characters. followed by the same fix. Requests that do not use the session (no cookie, no flash) still work, which is why a missing secret can go unnoticed until the first form submission. See Sessions, flash and security.

SECRET_KEY_BASE_PREVIOUS

  • Kind: secret. Optional; set only while rotating SECRET_KEY_BASE.
  • Meaning: previous SECRET_KEY_BASE values (Rails’ cookies_rotations). A session cookie encrypted with one of them is still read, then re-encrypted with the current key in the same response; a JWT signed with one of them still verifies until it expires.
  • Format: comma-separated, newest first; spaces around values are ignored; each value at least 64 characters.
  • Production: Worker secrets cannot be read back, so upload the value you are about to replace as SECRET_KEY_BASE_PREVIOUS together with the new one. In .prod.vars (git-ignored):
SECRET_KEY_BASE_PREVIOUS=<the current SECRET_KEY_BASE>
SECRET_KEY_BASE=<a new value from `ocre secret`>
ocre secrets push SECRET_KEY_BASE_PREVIOUS SECRET_KEY_BASE --file .prod.vars   # one upload
  • Removal: delete SECRET_KEY_BASE_PREVIOUS in the dashboard (Workers & Pages > your Worker > Settings > Variables and Secrets) once the longest session lifetime (two weeks for ocre g auth) and the JWT lifetime (one hour) have passed; visitors who did not come back by then start with an empty session.
  • Errors: a value shorter than 64 characters makes every request that uses the session or a JWT answer 500 and log SECRET_KEY_BASE_PREVIOUS has a value shorter than 64 characters. Fix: list old SECRET_KEY_BASE values, comma-separated, newest first.

ALLOWED_ORIGINS

  • Kind: variable (bindings.text). Optional.
  • Meaning: other origins (a separate frontend, an admin app) allowed to call this app from a browser. Listed origins get CORS headers (methods GET, POST, PUT, PATCH, DELETE; headers Content-Type, Authorization, Accept; credentials allowed) and pass the CSRF check that otherwise refuses cross-site unsafe requests with 403.
  • Format: comma-separated origins, scheme and host (and port if any). Spaces and a trailing / are ignored; invalid entries are skipped.
// in worker.env of cloudflare.config.ts
ALLOWED_ORIGINS: bindings.text("https://app.example.com, https://admin.example.com"),
  • Default: unset or empty, no CORS layer at all: only same-origin browser requests may change data.
  • Development: add it to worker.env (or .dev.vars) with the frontend’s local origin, such as http://localhost:5173.
  • Errors: none; a malformed entry is dropped silently, so check the spelling when a request still gets 403. See Sessions, flash and security.

ALLOWED_HOSTS

  • Kind: variable (bindings.text). Optional.
  • Meaning: the host names the app answers to (Rails’ config.hosts). A request for any other Host gets a plain-text 403 Forbidden: blocked host. Add it to ALLOWED_HOSTS to allow it. before the session, CSRF check or any handler runs.
  • Format: comma-separated host names, case-insensitive, without scheme or port. An entry starting with . also allows every subdomain: .example.com allows example.com and www.example.com.
ALLOWED_HOSTS: bindings.text("example.com, .example.com"),
  • Default: unset or empty, every host is allowed.
  • Always allowed: localhost, 127.0.0.1 and [::1], with any port, so ocre dev keeps working.
  • Why on Workers: Cloudflare only routes your own host names to the Worker, but the same Worker also answers on <name>.<subdomain>.workers.dev and on preview URLs. Listing only your custom domain keeps visitors and search engines on it (list the workers.dev host too while you still use it).
  • Errors: none at startup; a typo blocks your own domain with the 403 above.

OAuth client secrets

  • Names: GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (client_id_secret and client_secret_secret of ocre::oauth::GITHUB and GOOGLE).
  • Kind: secrets. Required for each provider given to ocre g auth --oauth.
  • Values: the client id and secret of the OAuth app you register with the provider, with the callback URL https://<your host>/auth/<provider>/callback (and http://localhost:8787/auth/<provider>/callback for ocre dev, in a second OAuth app for GitHub).
  • Development: ocre g auth --oauth appends commented lines to .dev.vars; uncomment them and fill in the values.
  • Production: put both in .prod.vars and run ocre secrets push GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET --file .prod.vars; ocre secrets list shows which are set.
  • Errors (500, logged) when one is missing: pressing “Continue with GitHub” logs the GITHUB_CLIENT_ID secret is not set. Fix: add it to .dev.vars, and `ocre secrets push GITHUB_CLIENT_ID --file .prod.vars` ; the callback logs the GITHUB_CLIENT_SECRET secret is not set. Fix: put it in .dev.vars for `ocre dev` and run `ocre secrets push GITHUB_CLIENT_SECRET --file .prod.vars` for production. See Authentication.

MAIL_ADAPTER

  • Kind: variable. Required to send mail (ocre::mail::send, deliver_later, the auth emails).
  • Values: log (print the whole email to the Worker log between [ocre mail] lines, send nothing), resend (Resend’s HTTP API, needs RESEND_API_KEY), cloudflare (Email Service through the EMAIL binding). Surrounding spaces are ignored.
  • Default: none. Nothing is guessed from which keys exist, so a development machine holding a real key never sends by accident.
  • Development: ocre new writes MAIL_ADAPTER=log to .dev.vars.
  • Production: uncomment MAIL_ADAPTER: bindings.text("resend"), (or "cloudflare") in worker.env, then ocre deploy.
  • Errors (500, logged):
    • unset: cannot send email: MAIL_ADAPTER is not set. Fix: set MAIL_ADAPTER to "resend" (with the RESEND_API_KEY secret) or "cloudflare" (with the EMAIL: bindings.sendEmail() binding) in worker.env of cloudflare.config.ts, as MAIL_ADAPTER: bindings.text("resend"); `ocre new` puts MAIL_ADAPTER=log in .dev.vars so `ocre dev` only logs mail
    • another value, for example smtp: cannot send email: unknown MAIL_ADAPTER "smtp" (expected log, resend or cloudflare). Fix: ... (same fix).

See Email.

MAIL_FROM

  • Kind: variable. Required to send mail, with every adapter.
  • Format: noreply@yourdomain.com or Name <noreply@yourdomain.com> (the name may be quoted). With Resend or Cloudflare, the domain must be verified with that provider.
  • Default: ocre new writes MAIL_FROM: bindings.text("<app> <noreply@example.com>"); replace example.com with your domain before sending real mail.
  • Errors (500, logged): unset: cannot send email: MAIL_FROM is not set. Fix: add MAIL_FROM: bindings.text("App <noreply@yourdomain.com>") to worker.env in cloudflare.config.ts; unparsable: cannot send email: MAIL_FROM "<value>" is not an address. Fix: use "noreply@yourdomain.com" or "App <noreply@yourdomain.com>".

RESEND_API_KEY

  • Kind: secret. Required when MAIL_ADAPTER = "resend".
  • Format: the API key from resend.com/api-keys, sent as Authorization: Bearer <key> to https://api.resend.com/emails.
  • Production: ocre secrets push RESEND_API_KEY --file .prod.vars.
  • Development: not needed with MAIL_ADAPTER=log. To send real mail from ocre dev, put RESEND_API_KEY=... and MAIL_ADAPTER=resend in .dev.vars.
  • Errors (500, logged) when missing or blank: cannot send email: the RESEND_API_KEY secret is not set. Fix: create a key at https://resend.com/api-keys and run `ocre secrets push RESEND_API_KEY --file .prod.vars` (and put it in .dev.vars to send from `ocre dev`).
  • Free plan of Resend: 100 emails a day, 3,000 a month (quotas, September 2026).

R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY

  • Kind: R2_ACCOUNT_ID and R2_BUCKET are variables (bindings.text); R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are secrets. Required by presigned URLs and direct uploads (storage::presign_get, presign_put, serve_redirect, direct_upload, attach_direct_upload); nothing else reads them.
  • Values: the account ID (R2 overview page), the bucket name (<app>-storage), and the two keys of an R2 API token with “Object Read & Write” on that bucket (dashboard: R2 > Manage API tokens). The secret key also signs the keys handed out by direct_upload: replacing it invalidates uploads in progress.
  • Production: the variables in worker.env, the secrets with ocre secrets push R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY --file .prod.vars. Development: all four in .dev.vars (the URLs point to the real bucket, not ocre dev’s simulation).
  • Errors (500, logged) when one is missing or blank: presigned R2 URLs need R2_ACCOUNT_ID, R2_BUCKET (not set). Fix: add `R2_ACCOUNT_ID: bindings.text("<account id>"),` and ..., naming every missing one. See File storage.

STORAGE_PUBLIC_URL

  • Kind: variable. Optional; required by storage::public_url.
  • Value: the base URL of a public bucket, an r2.dev URL or a custom domain connected to it (dashboard: R2 > bucket > Settings > Public access), e.g. https://files.example.com.
  • Errors (500, logged) when missing: public file URLs need the STORAGE_PUBLIC_URL variable. Fix: .... See File storage.

D1_REPLICAS

  • Kind: variable. Optional.
  • Value: on routes each request’s queries through a D1 session (read replicas, each visitor reading their own writes); anything else, or no variable, queries the database directly. Enable read replication on the database too. See Read replicas.

CACHE_STORE

  • Kind: variable. Optional.
  • Value: kv (default) stores ocre::cache values in the CACHE namespace; null turns caching off without code changes. ocre dev --no-cache writes CACHE_STORE=null into .dev.vars and --cache removes it. See Caching.

APP_URL

  • Kind: variable. Optional; required by ocre::mail::url.
  • Value: the app’s public base URL (https://www.example.com, http://localhost:8787 in .dev.vars), joined to paths for links in emails sent from jobs, where there is no request to read the host from.

LOG_LEVEL

  • Kind: variable. Optional.
  • Value: debug, info, warn, error or off (warning and fatal are accepted): the lowest level ctx.log() and Ocre write. Default: debug in ocre dev (debug build), info after ocre deploy (release build). An unknown value is the default.
  • Read on every request, job batch and cron run. See Errors, logging and debugging.

LOG_FORMAT

  • Kind: variable. Optional.
  • Value: json (one JavaScript object per line, whose fields Workers Logs indexes) or text (INFO message key=value ...). Default: text in ocre dev, json after ocre deploy.

SENTRY_DSN

  • Kind: secret. Optional; read only when the app registers ocre::errors::Sentry (see Reporting errors).
  • Value: the project’s DSN, https://<key>@<host>/<project id>, from Sentry or a Sentry-compatible service.
  • When missing, reports are only logged. A value that is not a DSN is logged as [ocre] SENTRY_DSN is not a Sentry DSN (...) and nothing is sent. The optional SENTRY_RELEASE variable names the release in each event.

Reading your own settings

Declare the app’s own variables and secrets as a struct and read it with ctx.config() (Loco’s settings, Rails’ config.x and credentials). Each field reads the variable of the same name in upper case, or else the secret; values are converted to the field’s type (numbers, bool as true/false/1/0, Vec from a comma-separated list, unit enums by name). Option and #[serde(default)] fields may be missing:

// src/support.rs
use axum::{Router, extract::State, routing::get};
use ocre::{Ctx, Result};
use serde::Deserialize;

/// `SUPPORT_EMAIL: bindings.text(...)` in cloudflare.config.ts, `SUPPORT_HOURS`
/// optional, `HELPDESK_TOKEN` a secret (`ocre secrets push`).
#[derive(Deserialize)]
pub struct Settings {
    pub support_email: String,
    pub support_hours: Option<String>,
    #[serde(default)]
    pub helpdesk_token: String,
}

pub fn routes() -> Router<Ctx> {
    Router::new().route("/support", get(support))
}

async fn support(State(ctx): State<Ctx>) -> Result<String> {
    let settings: Settings = ctx.config()?;
    let hours = settings.support_hours.unwrap_or_else(|| "9-17 UTC".to_owned());
    Ok(format!("Write to {} ({hours})", settings.support_email))
}

Register the module in src/lib.rs (mod support; under // ocre:modules, .merge(support::routes()) under // ocre:routes). A missing required field answers 500 and logs the fix:

[ocre] Worker variable or secret `SUPPORT_EMAIL` is missing. Fix: add `SUPPORT_EMAIL: bindings.text("..."),` to worker.env in cloudflare.config.ts, or for a secret run `ocre secrets push SUPPORT_EMAIL --file .prod.vars`; in `ocre dev`, add `SUPPORT_EMAIL=...` to .dev.vars

Reading costs one environment lookup per field, no binding call. Unit tests build the struct with ocre::config::from_vars([("SUPPORT_EMAIL", "help@example.com")]). ctx.env() stays available for bindings Ocre does not wrap (AI, Vectorize…).

Environments

An Ocre app has two environments, chosen by the build, not by a variable (Rails’ RAILS_ENV, Loco’s LOCO_ENV):

DevelopmentProduction
Builddebug: ocre dev, cargo test, ocre testrelease: ocre deploy
ocre::config::Environment::current()DevelopmentProduction
Settings.dev.vars overrides cloudflare.config.tscloudflare.config.ts variables and secrets
Logsdebug, textinfo, JSON
Errorsdevelopment error page, Server-Timinggeneric error page; details in logs and reporters
Databaselocal D1 in .wrangler/statethe remote D1 database

Rails’ test environment is the development build run natively by cargo test. A staging copy is a second Worker: a copy of the app directory with another worker.name and database name in cloudflare.config.ts, deployed with ocre deploy. Ocre reads cloudflare.config.ts as literal values, so it does not use cf’s defineConfig((ctx) => ...) form with --mode.

Initializers: the start function

ocre new writes a start function in src/lib.rs, run once when a Worker instance starts, before its first request, job or cron run (Rails’ config/initializers, Loco’s Hooks::before_run and initializers):

#[event(start)]
fn start() {
    ocre::errors::subscribe(ocre::errors::Sentry);
}

It has no environment (bindings and variables come with each request), so it registers things: error subscribers, static values built with std::sync::LazyLock. Its CPU time counts against the first request (10 ms on the free plan). Per-request setup belongs in axum middleware on routes(), which sees the Ctx.

Environment variables of the ocre CLI

These are read by the ocre command (and cf) on your machine, not by the Worker:

NameRead byMeaning
OCRE_BUILDbuild.command of wrangler.config.ts--dev or --release; set by ocre for every build (see wrangler.config.ts)
CLOUDFLARE_API_TOKENcfAuthenticates without ocre login (CI); the CLI’s hints mention it when a Cloudflare call fails
CLOUDFLARE_ACCOUNT_IDcfPicks the account when the token or login sees several, instead of accountId in cloudflare.config.ts
CF_SEND_TELEMETRYcffalse turns off cf’s anonymous usage telemetry (on by default; cf cli telemetry disable does the same for good). Ocre does not change it

Cargo.toml

ocre new docs-app (full-stack, with --ocre-path) writes:

[package]
name = "docs-app"
version = "0.1.0"
edition = "2024"
publish = false

# Standalone: the app builds even when created inside another Cargo workspace.
[workspace]

[lib]
crate-type = ["cdylib"]

[dependencies]
ocre = { path = "/path/to/ocre/crates/ocre" }
worker = { version = "0.8.7", features = ["http", "axum", "d1"] }
axum = { version = "0.8.9", default-features = false, features = ["form", "json", "query"] }
askama = "0.16.1"
serde = { version = "1.0", features = ["derive"] }

# `strip` breaks wasm-bindgen ("externref table required"); keep symbols.
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"

Without --ocre-path, the dependency is ocre = { git = "https://github.com/tgeselle/ocre.rs" }.

Ocre features

FeatureDefaultEnablesTurned on by
htmlyesaskama templates, render, HTML error pages, the Htmx extractoron in full-stack apps; off in API-only apps (default-features = false)
graphqlnoocre::graphql (async-graphql, GraphiQL)ocre g api <Model> ... --graphql, which also adds the async-graphql dependency
realtimenoocre::realtime and the OcreChannel Durable Object classthe first ocre g scaffold <Model> ... --realtime

After both generators, the dependency lines read:

ocre = { path = "/path/to/ocre/crates/ocre", features = ["realtime", "graphql"] }
async-graphql = { version = "7.2.1", default-features = false, features = ["graphiql", "custom-error-conversion"] }

GraphQL adds about 1.1 MB of WebAssembly and 20 to 60 ms of CPU each time a Worker instance starts (see Cost model); only turn it on when a client needs it.

API-only mode

ocre new docs-app --api writes Ocre without its default html feature, no askama, and this table at the end:

[dependencies]
ocre = { git = "https://github.com/tgeselle/ocre.rs", default-features = false }
worker = { version = "0.8.7", features = ["http", "axum", "d1"] }
axum = { version = "0.8.9", default-features = false, features = ["form", "json", "query"] }
serde = { version = "1.0", features = ["derive"] }
...

[package.metadata.ocre]
# JSON only: `ocre g scaffold` generates APIs, Ocre's `html` feature is off.
mode = "api"

The generators read mode = "api": ocre g scaffold then generates a JSON API (like ocre g api), and ocre g mailer builds the email text with format! instead of askama templates.

rustfmt.toml

ocre new writes the formatting style Ocre’s own code uses:

max_width = 120
use_small_heuristics = "Max"

Generators pipe the Rust they write through rustfmt with it, and ocre ci runs cargo fmt --check, so a generated app is formatted from the start. Change it freely: later generated code follows it.

rust-toolchain.toml

[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]

rustup reads it in the app directory and installs the stable toolchain with the WebAssembly target on first use. A Rust installed without rustup (for example Homebrew’s rust) ignores it and has no wasm target; ocre dev, ocre deploy and ocre new --deploy then stop with the wasm32-unknown-unknown target is not installed for rustc at <sysroot> and the hint use a rustup toolchain (Homebrew's `rust` has no wasm target) and run `rustup target add wasm32-unknown-unknown` . See Installation.

See also