Last updated:
A Lunora deployment is a Cloudflare Worker, so environments are Wrangler environments and releases are Worker versions. This page covers running more than one deployment, checking a change before it takes traffic, and getting back to the last good version.
Multiple environments
Declare each environment in wrangler.jsonc, then target it with --env:
lunora deploy # the default (top-level) environment
lunora deploy --env staging
lunora deploy --env production--env is understood by every command that talks to a deployed worker:
deploy, logs, env push, cloudflare deployments, cloudflare containers.
Each environment gets its own bindings and its own secrets. Cloudflare
secrets are per-environment and non-inheritable, so a secret set on the
default environment is not visible to staging:
lunora env push --yes --env staging # push .dev.vars to one environment (--yes is required)
lunora env diff --prod # compare local keys against the deployed setenv diff compares secret names, not values; Cloudflare never returns a
secret's value. It is the check that catches the "deployed to staging, forgot
the new secret" failure before the deploy: run lunora env diff --env staging
against the environment you are about to ship to.
lunora env doctor is a narrower, offline check — it validates .dev.vars
against .dev.vars.example only (keys the example lists but the file lacks, and
values that still look like placeholders). It reads no bindings and no schema,
and with no .dev.vars.example present it exits 0 having checked nothing, so a
secret nobody added to the example file is invisible to it.
Give each environment its own D1 database and R2 buckets. Sharing a database between staging and production means a staging migration is a production migration.
Linking a checkout to a deployment
lunora link records the deployed worker's name and public URL in a gitignored
.lunora/project.json, so commands stop needing --url:
lunora link --url https://my-app.acme.workers.dev
lunora link --url https://my-app-staging.acme.workers.dev --env stagingA first successful deploy writes the link automatically. Note the guardrail: the
bulk and destructive commands (export, import, migrate, backup, seed,
insights) only use the link under --prod, so a production link never
silently becomes the target of an unguarded write. The link stores public
identifiers only, never secrets.
Preview deployments
lunora deploy --preview is refused. A Cloudflare Worker
version is not an isolated preview for a Lunora app, for two reasons:
- Cloudflare generates no version preview URL for a Worker that implements a Durable Object, and every Lunora app does (its shards are Durable Objects). An uploaded version would have nothing to open.
- A version runs against the Worker's existing resources. Its Durable Objects,
D1 databases and R2 buckets are production's, and a pending Durable Object
migration only applies on
wrangler deploy. Promoting a version shipped as a "preview" therefore ran its code over production data.
An isolated copy is its own Worker with its own data. lunora preview makes
one per pull request and removes it again:
lunora preview deploy 123 # its own Worker, Durable Objects, D1, KV and R2
lunora preview deploy 123 --seed # …filled with `lunora seed` data
lunora preview list
lunora preview delete 123 # the Worker and every resource recorded for itThe preview Worker is <worker>-preview-<name>, so its shards start empty in a
Durable Object namespace of their own. Every D1 database, KV namespace and R2
bucket the app binds gets a preview-owned copy. The deploy runs the normal
pipeline under a transient env.preview-<name> block that clears production's
routes and custom domains, then runs the data migrations against the preview
and prints its workers.dev URL. It never writes to the project link or the
schema baseline. A preview gets minted values for the secrets Lunora can
generate (derived from LUNORA_PREVIEW_SECRET_SEED when it is set, so they
survive a redeploy). A provider key is copied only when you pass
--secret KEY, with the value taken from the environment. Production's secret
values are never read. Production's vars are copied; override one that must
point at the preview, such as an auth base URL, with --var KEY=VALUE.
What a preview owns is recorded in the account (a lunora-preview-registry KV
namespace), so the job that deletes it does not need the runner that deployed
it. The record is keyed by the project's id (lunora.appId in
package.json, written by the first local lunora preview deploy: commit it), so renaming the
Worker does not lose a preview and two apps sharing a Worker name never see each
other's. delete acts only on that record, and refuses a record that names anything
production's config uses. An app whose bindings live only inside env.<name> is previewed with
lunora preview deploy <number> --env <name>, which plans from that
environment (the top level with env.<name> laid over it); without it such an
app is refused. The environment is recorded with the preview, so delete removes
exactly what was created; if the environment names an account_id other than the
top level's, pass the same --env <name> to list and delete (or set
CLOUDFLARE_ACCOUNT_ID), which otherwise refuse rather than guess the account. An app binding something a preview cannot isolate
yet (Hyperdrive, service bindings, queues, workflows, Vectorize, …) is refused
with the list. See lunora preview for
the details and the API token permissions it needs.
The scaffolded CI pipelines use it:
lunora init my-app --ci github # or --ci gitlabThat scaffolds a production deploy on the default branch and a
lunora preview deploy <number> on every pull or merge request. On GitHub, a
preview-delete job runs lunora preview delete <number> when the pull request
closes. It runs on the base branch, so it first copies the pull request's
package.json and wrangler config out with git and passes them as
--head-dir: the CLI reads them as data (nothing from the pull request is
installed or run) to find a preview made under a lunora.appId or Worker name
the pull request changed, in the account the head's config names. A preview is deleted
through the head's identity only if its record says this pull request's own CI
deploy made it (the repository and pull request number, taken from
GITHUB_REPOSITORY or CI_PROJECT_PATH): a record from another project, a
local deploy or an older CLI is refused and the job fails. If those differ from the base's, or cannot be read,
and no record exists under the base's, the job fails instead of passing. A
pull request that changed both identifiers more than once leaves the preview of
an intermediate commit behind, which no later identity can name: delete it with
lunora preview delete <number> --head-dir <dir>, where <dir> holds that
commit's package.json and wrangler config. A fork's pull or merge request, and a Dependabot pull request, gets no
secrets, so it runs lunora deploy --dry-run instead. On GitLab, the preview is a review
environment whose stop job deletes it when the environment stops: the source
branch is deleted (merging with "delete source branch"), someone stops it under
Operate > Environments, or after two weeks. A closed merge request that keeps
its branch waits for the two weeks or a manual stop. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID for the
production deploy, and on GitHub a separate CLOUDFLARE_PREVIEW_API_TOKEN (with
D1, KV and R2 edit rights, since previews make their own) for the preview jobs.
The workflow gives each token only to the step that uses it, never to the
install or checkout steps, which run a pull request's own dependency scripts;
on GitLab the install is a job of its own with no environment, so it never
receives the preview token. On GitLab, production's variables are Masked and Protected with
Environment scope production, which merge-request pipelines never receive, so
set a separate CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID for previews,
Masked but not Protected, with Environment scope preview/*; only the preview
jobs get them, and never reuse the production token. Create the preview token
in Cloudflare under My Profile > API Tokens > Create Custom Token, with only the
Account permissions Workers Scripts, D1, Workers KV Storage and Workers R2
Storage (Edit), and Account Resources limited to the one account; add no Zone or
User permission. The residual risk is plain: the variable is not Protected, so
anyone who can push a branch to the repository can read it, and Cloudflare cannot
limit a token to resources named preview-*, so it can edit or delete every
Worker, D1 database, KV namespace and R2 bucket on that account. If production
shares the account, give previews an account of their own. The preview token can redeploy any preview with any secrets, and the
preview job holds it (and the optional LUNORA_PREVIEW_SECRET_SEED that keeps
minted secrets stable), so the job runs only for pull requests from the same
repository and the seed adds no access beyond the token. Do not run previews
for untrusted pull requests. After the first deploy, also set LUNORA_DEPLOY_URL (the
production worker's URL) and LUNORA_ADMIN_TOKEN: the production job passes
them to the drift gate, which then
checks the stored rows
before blocking a schema change.
| Target | Preview deployments |
|---|---|
| Cloudflare | lunora preview: its own Worker, Durable Object namespace, D1, KV and R2 |
| celld | Refused: no Wrangler environments. Deploy a separate config with its own CELLD_BUCKET instead |
| Node | Refused: the Node target has no lunora deploy |
Validating without publishing
Two lighter gates run the same pipeline and stop short of publishing:
lunora prepare # every pre-deploy gate, without deploying — no network, for CI
lunora deploy --dry-run # the full pipeline including the bundle, publishes nothing
lunora verify # codegen dry-run + tsc --noEmitMoving traffic between versions
lunora cloudflare deployments wraps wrangler versions and wrangler rollback:
lunora cloudflare deployments list # the 10 most recent
lunora cloudflare deployments status # the live version and its traffic split
lunora cloudflare deployments inspect <version-id> # what a version contains
lunora cloudflare deployments rollback --yes # back to the previous version
lunora cloudflare deployments promote <version-id> --yes # send 100% of traffic to a version
lunora cloudflare deployments promote <version-id> --percent 10 --from <live-version-id> --confirm-shared-state --yes
# a partial rollout: 10% to the version, 90% stays on the live onerollback and promote change live traffic, so both require --yes.
Rolling back code does not roll back data. A release that ran a migration needs the data considered separately; see Migrations for the widen-migrate-narrow pattern that keeps a rollback safe, and Backups for point-in-time recovery when it was not.
Build once, deploy many
To guarantee the artifact you tested is the artifact you ship, split the build from the deploy:
lunora build # full pre-deploy pipeline, bundle written to disk
lunora deploy --prebuilt # ship it, skipping codegen and the drift gateCustom domains
A deployment is reachable at its *.workers.dev URL out of the box. To serve it
from your own domain, attach a route or a custom domain in wrangler.jsonc the
same way you would for any Worker. Cloudflare's
custom domains
documentation covers the DNS side.
Two Lunora-specific things to remember when you do:
- Clients connect to the worker's origin. Anything holding the URL
(
NEXT_PUBLIC_LUNORA_URL, a mobile build, acreateServerClientcall) has to be updated, and framework build-time variables are inlined, so they need a rebuild rather than a restart. - Re-run
lunora linkwith the new URL so the CLI targets the right host.
Trying a deploy without an account
--temporary ships to a temporary Cloudflare account: the Worker is live for
about 60 minutes, after which you either claim it or it is deleted.
lunora deploy --temporaryWrangler errors if you are already authenticated, so drop the flag once you have signed in.
Who changed what
Each shard keeps a bounded, append-only audit log of the admin operations that mutate state: writing a row through the Studio, running a migration, importing a shard, applying CDC, and running a function as another identity. Each entry records the acting user, and the log keeps the most recent 1 000 entries so it cannot grow without bound. Read it from the Studio's Audit page under Observability.