Skip to content
DocsDocumentation

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-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 app hook is bundled into the worker, so a runtime import here ships with it. It is also evaluated on the host to discover the hook, so a runtime import must resolve there too — a cloudflare:* 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 type is erased on both counts, which is why the shape above uses satisfies rather than a defineConfig() call.
  • Write target, remote and advisor.minSeverity as literals. lunora codegen resolves 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 verify reports platform_unreadable_target, and an unreadable minSeverity reports advisor_min_severity_invalid and filters nothing. A const in 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 printing

Provider-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 production

Or 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 list

Container egress is billed separately from Workers. See Limits.

Deploy

pnpm lunora deploy

This runs:

  1. lunora codegen (no-op if up-to-date), its postcodegen hook, and the platform-portability diagnostics
  2. the schema-drift gate, then binding provisioning into wrangler.jsonc and wrangler.jsonc validation
  3. a Docker preflight when Dockerfile-backed containers are declared
  4. wrangler deploy against 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 prepare shares the deploy's gate pipeline — codegen, the postcodegen hook, the schema-drift gate, binding provisioning, the D1 placeholder and container/Docker preflights, and wrangler.jsonc validation — so what it accepts is what deploy accepts. 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 verify writes nothing and adds tsc --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 analyze bundles 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 messages

This 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.