Skip to content
DocsconceptsDocumentation

Environments & releases

Running staging next to production, deploying an isolated environment, checking pull requests with a dry run, an isolated preview per pull request, and rolling traffic back when something goes wrong.

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 set

env 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 staging

A 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 it

The 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 gitlab

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

TargetPreview deployments
Cloudflarelunora preview: its own Worker, Durable Object namespace, D1, KV and R2
celldRefused: no Wrangler environments. Deploy a separate config with its own CELLD_BUCKET instead
NodeRefused: 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 --noEmit

Moving 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 one

rollback 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 gate

Custom 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, a createServerClient call) has to be updated, and framework build-time variables are inlined, so they need a rebuild rather than a restart.
  • Re-run lunora link with 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 --temporary

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