Deployment
Wrangler bindings, secrets, and the deploy flow.
Last updated:
Lunora apps deploy as a single Cloudflare Worker plus Durable Object class, D1 database, and R2 bucket bindings. There is nothing else to provision: no separate API service, no platform sign-up.
wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-lunora-app",
"main": "src/server/index.ts",
"compatibility_date": "2026-04-07",
"compatibility_flags": ["nodejs_compat", "web_socket_auto_reply_to_close"],
"durable_objects": {
"bindings": [
{ "name": "SHARD", "class_name": "ShardDO" },
{ "name": "SCHEDULER", "class_name": "SchedulerDO" },
],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ShardDO", "SchedulerDO"] }],
"d1_databases": [{ "binding": "DB", "database_name": "my-lunora-app", "database_id": "<from-wrangler-d1-create>" }],
"r2_buckets": [{ "binding": "FILES", "bucket_name": "my-lunora-files" }],
}Secrets
Locally
Local secrets live in .dev.vars (gitignored), which wrangler dev and the
Vite dev server load automatically. Commit a .dev.vars.example listing the
keys your worker needs:
# .dev.vars.example
AUTH_SECRET="replace-with-openssl-rand-hex-32"
AUTH_URL="http://localhost:5173"
STORAGE_SECRET="replace-with-openssl-rand-hex-32"When you run lunora dev (or start Vite) without a .dev.vars, Lunora offers
to generate one from the example. Secret-looking placeholders (*_SECRET,
*_TOKEN, and the like) are filled with fresh random values, and everything
else is copied verbatim. If .dev.vars exists but is missing keys the example
lists (e.g. you enabled a new addon), it offers to append just those.
On every lunora dev / vite dev startup, Lunora also auto-generates any
empty secret already in .dev.vars and ensures LUNORA_ADMIN_TOKEN is
present — so a project scaffolded by lunora add (which writes its secrets
blank) boots with working values and the Studio
authenticates without prompting. Only locally-generatable secrets are minted;
provider keys (RESEND_API_KEY, STRIPE_SECRET_KEY, …) stay blank for you to
paste, and a real value is never overwritten.
You can also manage keys by hand: lunora env set NAME VALUE, lunora env list,
or generate strong values explicitly with lunora env generate (see below). To
check a .dev.vars against its example for missing keys, still-unset
placeholders, and stray extras, run lunora env doctor — it exits non-zero when
anything is actionable, so it works as a CI gate or a pre-dev sanity check.
Generating strong secrets
lunora env generate mints cryptographically-strong values (32-byte hex, like
openssl rand -hex 32) for the secrets your project can generate locally — use
them for production or any other environment:
lunora env generate # print KEY=value for every generatable secret
lunora env generate AUTH_SECRET # print one
lunora env generate --set # write them into .dev.vars instead of printingProvider-issued keys (Resend, Stripe, Polar) are skipped — you obtain those from the provider's dashboard.
In production
Cloudflare secrets are per-environment and non-inheritable — set them for the environment you deploy to:
wrangler secret put AUTH_SECRET --env production
wrangler secret put RESEND_API_KEY --env productionOr push everything from .dev.vars at once with lunora env push --yes
(--prod targets the production environment). Pipe a freshly generated value
straight in: lunora env generate AUTH_SECRET | cut -d= -f2- | wrangler secret put AUTH_SECRET --env production.
wrangler deploy never pushes .dev.vars values, so lunora deploy checks the
target worker's secrets first:
- Interactively, it offers to generate + push any missing generatable secret before shipping, and flags provider keys for you to set by hand.
- Non-interactively (CI), a missing required secret aborts the deploy — better a failed pipeline than a worker that crashes on a missing secret. Set the secrets (the error lists them) and re-deploy.
The check is best-effort: a brand-new worker (nothing deployed yet) or an
unauthenticated wrangler can't be queried, so the deploy proceeds. Lunora never
logs secret values; the wrangler-validator plugin warns if a binding marked
secret: true in your schema is missing.
See Cloudflare's Secrets and Environments docs for the underlying model.
Containers
If your app declares containers, wrangler deploy
also builds each Dockerfile-backed image with your local Docker engine and
pushes it to Cloudflare's registry. lunora deploy runs a Docker preflight
first and stops with an actionable message when an engine isn't available, so
the failure is one line instead of a wrangler stack trace. Images must target
linux/amd64.
To split image build/push from the Worker deploy (e.g. across CI jobs), use the
lunora containers wrappers:
lunora containers build ./containers/transcoder --tag transcoder:v1 --push
lunora containers images listContainer egress is billed separately from Workers. See Limits.
Deploy
pnpm lunora deployThis runs:
lunora codegen(no-op if up-to-date)- D1 migration runner against the bound database
- a Docker preflight when Dockerfile-backed containers are declared
wrangler deployagainst the resolved Worker (building/pushing container images)
CI should run lunora codegen --check first so a stale _generated/ is a
build failure, not a deploy that silently uses old types.
Streaming logs
Tail a deployed Worker's live logs with:
pnpm lunora logs # pretty-printed live tail
pnpm lunora logs --format json # one JSON object per line (pipe to jq)
pnpm lunora logs --status error # only failed invocations
pnpm lunora logs --search "userId" # substring filter on log messagesThis wraps wrangler tail, so it needs a deployed Worker and your wrangler
config; pass a Worker name as the first argument to override the configured
one, and --env <name> to target an environment.
For durable, off-Cloudflare log sinks (Datadog, an HTTP endpoint, R2), forward
tail events to a consumer Worker via tail_consumers in wrangler.jsonc:
{
// ...
"tail_consumers": [{ "service": "log-forwarder" }],
}Each entry names a Worker that receives this Worker's logs, exceptions, and
fetch metadata. @lunora/config exports a withTailConsumer(config, consumer)
helper that appends an entry idempotently (deduped by service + environment),
and the wrangler validator flags any tail_consumers entry missing its
service.
Logpush
To ship logs to a retention/SIEM sink (R2, HTTP, Splunk, Datadog, S3) without a consumer Worker, enable Cloudflare Logpush with a single flag:
{
// ...
"logpush": true,
}Lunora validates that logpush is a boolean (a typo like "logPush" would
otherwise be silently dropped by wrangler), but the sink itself is a Logpush
job created in the Cloudflare dashboard or via the API, and Lunora does not
manage its lifecycle. The Studio surfaces both halves: its Logs → Log drains
panel renders the { "logpush": true } snippet and deep-links to the Cloudflare
observability dashboard where you create the job. Lunora validates the flag; the
job lifecycle stays in Cloudflare.
Platform configuration
A few Cloudflare platform features are pure wrangler.jsonc config. There is
no Lunora adapter to import; you declare the block and Lunora's
wrangler-validator shape-checks it, so a typo is a build-time error instead of
a value wrangler silently drops. You always provision the resource itself
(dashboard or wrangler); Lunora validates the config, it does not manage the
lifecycle.
Smart Placement
Smart Placement lets Cloudflare run your Worker close to the services it calls, such as a regional database, instead of close to the user. Opt in with:
{
// ...
"placement": { "mode": "smart" },
}"smart" is the only supported mode. The validator rejects any other value (a
typo like "fast") and a non-object placement.
mTLS client certificates
To present a client certificate when your Worker calls a mutually-authenticated
upstream, upload the cert (wrangler mtls-certificate upload) and bind it:
{
// ...
"mtls_certificates": [{ "binding": "MY_CERT", "certificate_id": "<from-upload>" }],
}Each entry must name a non-empty binding and a non-empty certificate_id; the
validator flags either when missing.
Workers for Platforms
If you build a multi-tenant platform that deploys user Workers into a dispatch namespace, bind the namespace:
{
// ...
"dispatch_namespaces": [{ "binding": "DISPATCHER", "namespace": "tenants" }],
}Both binding and namespace are required on each entry.
Static assets
Lunora deploys as a single Worker, so the conventional way to serve your Vite client build is Workers Static Assets. Cloudflare serves the files for free and only invokes the Worker on a miss, so the Lunora SSR/API fetch handler still runs underneath:
{
// ...
"assets": { "directory": "./dist/client", "binding": "ASSETS" },
}The validator requires a non-empty directory (pointing at the built client
output) and shape-checks the optional binding / html_handling /
not_found_handling fields. Lunora does not auto-inject this block; the
output directory is framework-adapter-specific, so you declare it.
Non-goals
A couple of Cloudflare products are deliberately not wired into Lunora:
- Cloudflare Pages. The Lunora Worker is the deploy unit; there is no second Pages artifact. Serve your client build from the same Worker with the Static assets block above instead.
- Pub/Sub (MQTT). Realtime fan-out is handled by Durable-Object-hibernated WebSocket subscriptions (see Real-time), which need no external broker. Cloudflare Pub/Sub is a beta MQTT broker with no Worker binding; the only thing it adds is native-MQTT device ingest, a narrow case we'll revisit if it reaches GA and a concrete need appears.