Deployment

Deploy pipeline

How a push or a pull request reaches Cloudflare, and what a deploy needs from you.

Both applications deploy to Cloudflare Workers, independently, from the same repository, and GitHub Actions is what deploys them. There is no git connection on the Cloudflare side and no build or deploy setting in the Cloudflare dashboard. The whole path is one workflow file in your repository, and you own it.

Which event deploys what

EventRuntime ContextWhat runs
Push to development / stagingthat namebuild → migrate (web only) → varlock-wrangler deploy
Push to mainproductionthe same — but see below
Pull request, whatever it targetspreviewbuild → varlock-wrangler versions upload --preview-alias pr-<n>

A pull request is preview whichever branch it targets. No branch name is read on one.

In NuxtStart itself, main is the production branch, but its production deploy skips because apps/*/.env.production still assigns REPLACE_ME. NuxtStart publishes its transformed downstream tree to published after the ordinary promotion. Your project fills in the production values and the same pipeline lets main through. See Migration publication.

Pull request previews

A pull request does not get a Worker. It uploads a version onto the development Worker under a pr-<n> alias, which gives it a predictable URL of the shape https://pr-<n>-<worker>.<subdomain>.workers.dev. The version binds preview's own bucket, database and connection pool, so it never touches development's data.

A pull request never migrates the database. A preview runs against whatever schema the shared preview database already has, so a pull request that needs a schema change has a broken preview until it merges.

When a deploy is skipped

The run goes green and nothing ships. That is deliberate, and the run log carries the reason:

  • The pull request came from another repository. GitHub withholds secrets from a fork, so an outside contributor's run cannot deploy and should not go red for it.
  • There are no Cloudflare credentials on the repository yet.
  • The value file for that Runtime Context still assigns REPLACE_ME, which is how a value file says "this context is not configured here".
  • .env.project.schema or that application's .env.schema still assigns REPLACE_ME. A schema default is a value no .env.<context> can override, so this one holds every Runtime Context back rather than one — and the root file holds both applications back, because both read it. The keys that ship this way are PROJECT_SLUG, PROJECT_DISPLAY_NAME, and PROJECT_ZONE.

A run that cannot deploy still builds and still scans for secrets, against the committed test fakes. Your build coverage never depends on holding credentials.

A mistake ends the run red instead, at whichever step hits it. A half-set Cloudflare secret-and-variable pair fails at the deploy with Cloudflare's own authentication error, and a Runtime Context with no committed value file fails at the build with varlock's. The one fault the gate itself reports is an unset machine-identity variable for the event, because the error it would otherwise produce names nothing.

What each application binds

Each application declares only what it uses. This is the practical difference between the two deployables.

apps/site

BindingKindSource
ASSETSStatic assets.output/public
DBD1CLOUDFLARE_D1_DATABASE_NAME / CLOUDFLARE_D1_DATABASE_ID
EMAILsend_email

That D1 database is not an application database. It holds Nuxt Content's dump, which Content restores at runtime. The site has no Drizzle schema and no migration pipeline.

apps/web

BindingKindSource
ASSETSStatic assets.output/public
BLOBR2hub.blobCLOUDFLARE_R2_BUCKET_NAME
POSTGRESHyperdrivehub.dbCLOUDFLARE_HYPERDRIVE_ID
EMAILsend_email
Cron, dailycontributed by the auth layer

R2 and Hyperdrive are configured through the hub block rather than declared directly, and the cron trigger comes from a layer — a layer contributing a binding is normal here. Where the resources on the other end come from is Infrastructure.

The variables a deploy needs

VariablesitewebWhere it comes from
CLOUDFLARE_WORKER_NAMErequiredrequiredderived from your slug
CLOUDFLARE_WORKER_ROUTEoptionaloptionalderived from your domain, in production only
CLOUDFLARE_WORKER_ROUTE_ZONEoptionalyou
CLOUDFLARE_D1_DATABASE_NAMErequiredderived from your slug
CLOUDFLARE_D1_DATABASE_IDrequiredthe apply that made it
CLOUDFLARE_R2_BUCKET_NAMErequiredderived from your slug
CLOUDFLARE_HYPERDRIVE_IDrequiredthe apply that made it

A required row is required in every deployed Runtime Context and in neither local nor test, so a laptop needs none of them and a deployed build that is missing one fails by name.

Only the rows marked "the apply that made it" are values a file holds. The names come from PROJECT_SLUG — see Project identity — and the route comes from PROJECT_DOMAIN.

preview resolves these like any other deployed context, but its Worker name is development's: a preview version lands on that Worker rather than on one preview owns. Preview owns its bucket, its database and its pool, and nothing else.

Attaching a domain

CLOUDFLARE_WORKER_ROUTE is unset in every context except production, so those Workers serve from workers.dev. Production's is already built from PROJECT_DOMAIN — the site takes your domain and the product takes app. in front of it — and you change it only for a topology that rule does not cover. apps/web emits one of two forms, and NUXT_APP_BASE_URL is what selects it — a mount path other than / emits a zone-scoped route, anything else emits a Custom Domain:

ValueEmitted asAlso needs
app.example.comcustom_domain: true
example.com/app/*a route with zone_nameNUXT_APP_BASE_URL (the switch), CLOUDFLARE_WORKER_ROUTE_ZONE
A Workers Custom Domain claims an entire hostname, so it cannot express the second shape. Setting a path-scoped pattern withoutNUXT_APP_BASE_URL emits custom_domain: true, and wrangler rejects the deploy. Nothing catches it at build time.

The second form is how you mount the product under the site's own origin instead of giving it one. apps/site only ever takes the first.

Database migrations

Only apps/web has an application database, and the pipeline applies its migrations between the build and the upload so a Worker never serves against a schema that is behind it. The commands, when they run and what a pull request does instead are all in Database.

What a local run cannot do

Nothing local deploys. wrangler.jsonc and .wrangler/ are gitignored, so any local bindings you create stay on your machine — the deployed bindings come from the generated .output/server/wrangler.json.

Going further

Infrastructure covers the long-lived Cloudflare resources every binding points at. First deploy is the one-time setup for the non-production contexts, and Going to production is the rest.

Copyright © 2026