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 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; 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 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)
  2. D1 migration runner against the bound database
  3. a Docker preflight when Dockerfile-backed containers are declared
  4. wrangler deploy against 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 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. 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.

See also