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-06-10",
"compatibility_flags": ["nodejs_compat"],
"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" }],
// Required whenever a SchedulerDO is declared. The DO reads its dispatch
// target from this binding on its own env — never off the schedule request,
// which would let a caller steer the callback — and refuses to enqueue
// without it, failing every ctx.scheduler.runAfter/runAt.
"vars": { "LUNORA_ORIGIN_URL": "https://my-lunora-app.example.workers.dev" },
}:::note[Vite-first apps: the scheduler wires itself]
ctx.scheduler.runAfter / runAt dispatch through a SchedulerDO, and wrangler
binds only what the worker entry exports. In a Vite-first app ("main": "virtual:lunora/worker") that entry is generated, so there is nothing to edit —
and nothing to: as soon as your app uses the scheduler (a cron, a ctx.scheduler
call, or a direct @lunora/scheduler dependency), codegen emits the class,
the generated entry exports and wires it, and lunora dev reconciles the
SCHEDULER binding and its migration into wrangler.jsonc for you.
Cron jobs never needed it: they run through the worker's own scheduled()
handler. Only deferred dispatch and the studio's scheduled-jobs view do.
One thing is still yours: LUNORA_ORIGIN_URL. The Durable Object reads its
dispatch origin from its own env and refuses to enqueue without it, so
lunora verify warns whenever a SchedulerDO is declared and that var is unset.
:::
lunora.config.ts
One optional file at the project root carries the project's Lunora settings — the
deploy target, the remote-binding dev preference, the app hook a
Vite-first project composes its worker with, and advisor.minSeverity, the
lowest advisory level codegen reports and writes into _generated/shard.ts
("info", "warn" or "error"; an ERROR is never dropped, so the codegen gate
stays on). It replaces lunora.json: the two
were always the same question, split across a format boundary that existed only
because the CLI could not read TypeScript. It can now (jiti), so there is one
file and it is real code.
// lunora.config.ts
import type { LunoraConfig } from "./lunora/_generated/app";
export default {
target: "cloudflare",
remote: true,
advisor: { minSeverity: "warn" },
} satisfies LunoraConfig;lunora.config.mts, .js and .mjs are read too, so a JS-authored project is
not forced into TypeScript for one file. .cts / .cjs are deliberately not:
Vite's default resolve.extensions does not cover them, so the specifier the
generated worker entry imports for the app hook would not resolve.
Every template that declares a tsconfig include lists lunora.config.ts in it,
which is what gets the file type-checked (nuxt declares none — it extends the
config Nitro generates, which already covers the project root).
The app hook
In a Vite-first app ("main": "virtual:lunora/worker") @lunora/vite generates
the worker entry, so there is no file to chain your own defineApp() calls on.
Anything mechanical is composed for you — the shard selector, and
.scheduler(...) as above. Anything that needs your code is not:
.auth(...) takes your better-auth options, .global(...) your D1 writer,
.vectors(...) your embedder, and resolveIdentity is only ever set by
.auth(), .access() or .extend().
// lunora.config.ts
import type { AppBuilder, LunoraConfig } from "./lunora/_generated/app";
interface Env {
DB: D1Database;
BETTER_AUTH_SECRET: string;
}
export default {
target: "cloudflare",
app: (app: AppBuilder<Env>): AppBuilder<Env> =>
app
.auth({
d1: (env) => env.DB,
options: (env) => ({ secret: env.BETTER_AUTH_SECRET }),
})
.global({ d1: (env) => env.DB }),
} satisfies LunoraConfig<Env>;The generated entry calls the hook between the shard selector and its own
.httpRouter(...) / .build(), so the framework wiring stays with the plugin
and your capabilities are chained in the middle.
Three things are worth knowing about this file:
- Export it as
default. Both readers resolve the default export and nothing else, because the generated entry imports it as one. A hook reached through a named export is ignored. - Keep the imports type-only if you can. The
apphook is bundled into the worker, so a runtimeimporthere ships with it. It is also evaluated on the host to discover the hook, so a runtime import must resolve there too — acloudflare:*module or a tsconfig path alias (which the loader does not read) will throw, and the build warns that the hook was not composed.import typeis erased on both counts, which is why the shape above usessatisfiesrather than adefineConfig()call. - Write
target,remoteandadvisor.minSeverityas literals.lunora codegenresolves the target synchronously, by parsing this file rather than running it, so a computed value, a getter or a spread is not seen. It is not silently defaulted either —lunora verifyreportsplatform_unreadable_target, and an unreadableminSeverityreportsadvisor_min_severity_invalidand filters nothing. Aconstin the same file and a shorthand property both work.
A config that throws, or whose app is not a function, is ignored rather than
breaking the build — the entry composes as if the hook were absent. A
hand-written entry (src/server.ts, src/worker.ts) needs no hook at all: chain
the calls there directly.
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. Nothing else covers this: the wrangler-validator has no
notion of a "required secret" and emits no missing-secret warning, so on a first
deploy this gate is the only net, and it is the one that skips.
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), itspostcodegenhook, and the platform-portability diagnostics- the schema-drift gate, then binding provisioning into
wrangler.jsoncandwrangler.jsoncvalidation - a Docker preflight when Dockerfile-backed containers are declared
wrangler deployagainst the resolved Worker (building/pushing container images)
No step applies the SQL that lunora migrate generate emits — see
Migrations. Data migrations run after the
deploy, and only under --migrate (which also requires --migrate-yes and, on
an unlinked checkout, --migrate-url).
CI should run one of these before the deploy job, so codegen drift and a stale
_generated/ are a build failure rather than a deploy that silently ships old
types:
-
lunora prepareshares the deploy's gate pipeline — codegen, thepostcodegenhook, the schema-drift gate, binding provisioning, the D1 placeholder and container/Docker preflights, andwrangler.jsoncvalidation — so what it accepts is whatdeployaccepts. It writes (_generated/,wrangler.jsonc), so reach for it when you want the reconciled config too.It does not run migrations (step 2) or build and push images (step 4). Migrations are a deploy-time action against a live database, not a check.
-
lunora verifywrites nothing and addstsc --noEmit, so it is the lighter gate for a pull-request check.
Worker size
Cloudflare caps a Worker script in two places: 3 MB on Workers Free and 10 MB on Workers Paid after gzip compression, and 64 MB before compression on both plans. Both are enforced at upload, so an over-budget bundle is a rejected deploy rather than a slow one. A bundle that compresses unusually well (a large generated table, say) can sit under the gzip limit while breaching the raw one. Check both numbers.
lunora build weighs what it wrote:
pnpm lunora build
# … bundle: 1684.9 KiB raw, 412.9 KiB gzipped across 1 file(s)
pnpm lunora build --format json | jq .bundle
# { "files": 1, "gzipBytes": 422840, "rawBytes": 1725313 }Only the uploaded files are counted: the sourcemap and the esbuild metafile
sitting in the same out-dir are not part of the script, and counting them would
roughly triple the number. Compare gzipBytes against your plan's compressed
limit and rawBytes against the 64 MB one; together they match what
wrangler deploy reports as Total Upload: … / gzip: ….
A starter app is around 410 KiB gzipped, so most projects have a lot of room. If yours is approaching the limit:
- Drop add-ons you no longer import. Every
@lunora/*add-on your Worker entry reaches is bundled, whether or not a request ever uses it. - Check for a dev-only import reaching the Worker entry. A seed script, a
test helper, or a Node-only utility imported from
lunora/pulls its whole dependency tree into the deployed bundle. - Look at what is actually heavy.
lunora analyzebundles the Worker and prints the largest modules, which is usually enough to name the culprit.
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.
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.