Deploy pipeline
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
| Event | Runtime Context | What runs |
|---|---|---|
Push to development / staging | that name | build → migrate (web only) → varlock-wrangler deploy |
Push to main | production | the same — but see below |
| Pull request, whatever it targets | preview | build → varlock-wrangler versions upload --preview-alias pr-<n> |
A pull request is preview whichever branch it targets. No branch name is read on one.
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.
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.schemaor that application's.env.schemastill assignsREPLACE_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 arePROJECT_SLUG,PROJECT_DISPLAY_NAME, andPROJECT_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
| Binding | Kind | Source |
|---|---|---|
ASSETS | Static assets | .output/public |
DB | D1 | CLOUDFLARE_D1_DATABASE_NAME / CLOUDFLARE_D1_DATABASE_ID |
EMAIL | send_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
| Binding | Kind | Source |
|---|---|---|
ASSETS | Static assets | .output/public |
BLOB | R2 | hub.blob → CLOUDFLARE_R2_BUCKET_NAME |
POSTGRES | Hyperdrive | hub.db → CLOUDFLARE_HYPERDRIVE_ID |
EMAIL | send_email | — |
| — | Cron, daily | contributed 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
| Variable | site | web | Where it comes from |
|---|---|---|---|
CLOUDFLARE_WORKER_NAME | required | required | derived from your slug |
CLOUDFLARE_WORKER_ROUTE | optional | optional | derived from your domain, in production only |
CLOUDFLARE_WORKER_ROUTE_ZONE | — | optional | you |
CLOUDFLARE_D1_DATABASE_NAME | required | — | derived from your slug |
CLOUDFLARE_D1_DATABASE_ID | required | — | the apply that made it |
CLOUDFLARE_R2_BUCKET_NAME | — | required | derived from your slug |
CLOUDFLARE_HYPERDRIVE_ID | — | required | the 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:
| Value | Emitted as | Also needs |
|---|---|---|
app.example.com | custom_domain: true | — |
example.com/app/* | a route with zone_name | NUXT_APP_BASE_URL (the switch), CLOUDFLARE_WORKER_ROUTE_ZONE |
NUXT_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.
