Reference

Project identity

The values that name your project, and every resource name, origin, and sending domain derived from them.

Your project's name appears in Worker names, bucket names, database names, origins, and the domain your transactional mail is sent from. You set it in one place, and everything deterministic follows.

flowchart LR
    S["PROJECT_SLUG"] --> W["Worker names"]
    S --> R["Bucket and database names"]
    S --> T["Every resource OpenTofu creates"]
    S --> M["Non-production sending domains"]
    Z["PROJECT_ZONE"] --> M
    Z -. "defaults to" .-> D["PROJECT_DOMAIN"]
    N["PROJECT_DISPLAY_NAME"] --> A["The product name people read"]
    D --> P["Production routes"]
    D --> E["Production sending domains"]

This page is the human view of the rule. The rules themselves live in skills/internal/project-context/domains/project-identity.md, which is what your agent reads and what the implementation follows. When this page and that file disagree, that file is right.

The values you set

Four items, declared in .env.project.schema at the root of the repository. That's a different file from the .env.schema beside it, which holds repository tooling and nothing about your project.

ItemRequiredWhat it isExample
PROJECT_SLUGYesThe lowercase name inside every resource namemy-app
PROJECT_DISPLAY_NAMEYesThe product name people readMy App
PROJECT_ZONEYesThe DNS zone you ownexample.com
PROJECT_DOMAINNo, defaults to the zoneThe hostname production is served fromexample.com

Your copy ships with the first three set to REPLACE_ME. Nothing rejects that at build time, so the deploy gate is what holds it — and because both applications read this file, a placeholder holds every runtime context of both back. Local setup is the step that fills them in.

PROJECT_ZONE and PROJECT_DOMAIN are the same value when your project owns a whole zone. They differ when your project lives at a subdomain of a zone you use for other things: the zone stays example.com and the domain becomes my-app.example.com.

The zone is required and the domain isn't, because mail reads the zone in the five runtime contexts that have no production hostname, and only production reads the domain. You can own a zone and send mail from development long before you've chosen a production hostname, and you don't have to invent one to get there.

What derives from them

You don't write any of these in a value file. They're expressions in each application's .env.schema, and they change when you change the values above.

NameShapeWhich context it reads
NUXT_PUBLIC_APP_NAMEThe display nameNone — the same everywhere
CLOUDFLARE_WORKER_NAME<slug>-<app>-<context>The runtime context, remapped
CLOUDFLARE_R2_BUCKET_NAME<slug>-web-<context>APP_ENV
CLOUDFLARE_D1_DATABASE_NAME<slug>-site-<context>APP_ENV
NUXT_EMAIL_SENDER_DOMAIN<app>.<domain> in production, <slug>-<app>-<context>.<zone> in all othersThe runtime context, as-is

Three different context inputs, and each is deliberate:

  • A pull request preview uploads its version onto the development Worker, so CLOUDFLARE_WORKER_NAME resolves to development's name under preview.
  • A local run shares development's bucket and database, so those two resolve development's names under local. APP_ENV is the flag that already carries that mapping.
  • Mail reads the context as-is, so local gets a sender of its own rather than borrowing development's, and production is the one context that reaches your domain.

The test context is the exception to the heading. Its two .env.test files override all three names with fakes, because test reaches no deployed service and a derived name names one.

Serving origins

local and test run on localhost. Every deployed context except production is served from your Cloudflare account's workers.dev subdomain:

https://<slug>-<app>-<context>.<subdomain>.workers.dev

A pull request preview is that origin with pr-<n>- in front of it — and the origin it prefixes is development's, because that's the Worker a preview version lands on. So a preview is served from https://pr-<n>-<slug>-<app>-development.<subdomain>.workers.dev. None of these touches your domain.

That workers.dev subdomain belongs to your Cloudflare account rather than to the project, so it isn't part of project identity. One account can serve several projects, and a project can move between accounts.

Production is the only context where your domain appears, and it has five shapes:

What you're deployingapps/siteapps/web
Both apps, on a zone you ownexample.comapp.example.com
Both apps, at a subdomainmy-app.example.comapp.my-app.example.com
The website onlyexample.com
The product application onlyexample.com
The product mounted under the siteexample.comexample.com/app/

The first three follow one rule: the website takes your domain, and the product application takes app. in front of it. That rule is what CLOUDFLARE_WORKER_ROUTE in each .env.production is built from, so those three topologies need no edit. The last two break the rule — replace that key, and for a path-scoped route set CLOUDFLARE_WORKER_ROUTE_ZONE alongside it.

The route and the origin are two values that have to agree, and nothing checks them against each other. CLOUDFLARE_WORKER_ROUTE decides where the Worker answers; NUXT_PUBLIC_APP_BASE_URL is baked into the bundle and reaches your canonical tags, social images, sitemap and email. Edit them together, or production answers on one name and advertises another.

Origins aren't derived at all. They carry your account's workers.dev subdomain in the deployed non-production contexts, the production value has the five shapes above, and the deploy workflow reads NUXT_PUBLIC_APP_BASE_URL as text to build each preview origin — an expression there would be prefixed unresolved. So you write one into each deployed context's value file yourself, starting with development's at First deploy, which is the step that pushes it.

Sending domains

Transactional mail is sent from a hostname on the zone you own. Production takes the short form and every other runtime context carries its own name, so a bug in development can never send from production's From: domain:

Runtime contextapps/webapps/site
productionapp.<domain>site.<domain>
staging, development, preview, local<slug>-web-<context>.<zone><slug>-site-<context>.<zone>
testmail.example.testmail.example.test

Production's two prefixes are the ones its routes already use. apps/web sends from the same hostname it serves — a Custom Domain is a proxied address record, and the SPF and DKIM records live at that name alongside it. apps/site takes site. rather than your domain itself, because its route is the apex.

local resolves a hostname nothing ever onboards, which is correct: local mail is caught by Mailpit and never reaches Cloudflare. The test suite uses a fake on a reserved domain.

Cloudflare Email Sending is onboarded one exact hostname at a time. SPF is looked up on the From: domain, and the DKIM key sits at <selector>._domainkey.<hostname>, so each context needs its own records. A hostname you haven't onboarded still resolves, still builds, and still deploys — the message fails at the recipient instead. Adding a runtime context means adding an onboarding.

A sender is not an origin, and you can't point one at the other. Your non-production origins are on your Cloudflare account's workers.dev subdomain, and a sender can never go there: onboarding writes SPF and DKIM records into the sending hostname's zone, and workers.dev belongs to Cloudflare rather than to you.

Worked scenarios

Each of these sets the four values and changes nothing else. The route, the resource names, and the sending domains all follow. Only the origins are yours to write, because no expression can know your account's workers.dev subdomain — the section above says why.

You own the whole zone

The common case, and the one the defaults are built for. Leave PROJECT_DOMAIN alone and it takes the zone.

.env.project.schema
PROJECT_SLUG=my-app
PROJECT_DISPLAY_NAME=My App
PROJECT_ZONE=example.com
PROJECT_DOMAIN=$PROJECT_ZONE
Runtime contextApplicationServesSends mail fromWorker name
productionapps/siteexample.comsite.example.commy-app-site-production
productionapps/webapp.example.comapp.example.commy-app-web-production
stagingapps/siteworkers.devmy-app-site-staging.example.commy-app-site-staging
stagingapps/webworkers.devmy-app-web-staging.example.commy-app-web-staging

Your slug doesn't match your domain

Nothing forces them to agree. The slug names resources inside your Cloudflare account; the domain names what the public sees. A company called Acme can ship a product at getacme.com and still keep acme-crm on every bucket and Worker.

.env.project.schema
PROJECT_SLUG=acme-crm
PROJECT_DISPLAY_NAME=Acme CRM
PROJECT_ZONE=getacme.com
PROJECT_DOMAIN=$PROJECT_ZONE
Runtime contextApplicationServesSends mail fromWorker name
productionapps/sitegetacme.comsite.getacme.comacme-crm-site-production
productionapps/webapp.getacme.comapp.getacme.comacme-crm-web-production
stagingapps/siteworkers.devacme-crm-site-staging.getacme.comacme-crm-site-staging
stagingapps/webworkers.devacme-crm-web-staging.getacme.comacme-crm-web-staging

The slug never appears in a production hostname, so pick it for your resource list rather than for your marketing.

Your project lives at a subdomain

You own example.com and use it for other things, so this project gets my-app.example.com. This is the one case where you set PROJECT_DOMAIN yourself.

.env.project.schema
PROJECT_SLUG=my-app
PROJECT_DISPLAY_NAME=My App
PROJECT_ZONE=example.com
PROJECT_DOMAIN=my-app.example.com
Runtime contextApplicationServesSends mail fromWorker name
productionapps/sitemy-app.example.comsite.my-app.example.commy-app-site-production
productionapps/webapp.my-app.example.comapp.my-app.example.commy-app-web-production
stagingapps/siteworkers.devmy-app-site-staging.example.commy-app-site-staging
stagingapps/webworkers.devmy-app-web-staging.example.commy-app-web-staging

Read the two staging rows against the two production rows. Production hangs off the domain and everything else hangs off the zone, so your non-production senders stay flat at my-app-web-staging.example.com rather than nesting another label deep.

The two topologies that need an edit

The last two rows of the production table above — the product application deployed alone, and the product mounted under the website — break the app. rule, so they're the only ones where you replace CLOUDFLARE_WORKER_ROUTE in apps/web/.env.production by hand. For a path-scoped route, set CLOUDFLARE_WORKER_ROUTE_ZONE alongside it. The sending domains still derive, and still need onboarding.

What you still write by hand

Project identity covers names this repository decides. Three kinds of value stay explicit, because nothing here can derive them correctly:

KindExamples
Values your Cloudflare account ownsThe workers.dev subdomain, and the origins built from it
Values a provider mints on applyCLOUDFLARE_HYPERDRIVE_ID, CLOUDFLARE_D1_DATABASE_ID
Values registered elsewhereGOOGLE_CLIENT_ID, GITHUB_CLIENT_ID
Account constants in infra/tofu.shCLOUDFLARE_ACCOUNT_ID, INFISICAL_INFRA_PROJECT_ID

No .tf file is ever one of them. infra/main.tf builds <slug>-<app>-<context> from the same PROJECT_SLUG your application schemas read, so the bucket, database, and connection pool are named by the value you set once. The two account constants in infra/tofu.sh and the file rename that activates a runtime context are the only edits infra/ asks for. First deploy walks the constants, and Going to production walks the rename.

Next steps

  • Local setup is where you set these three values, if you haven't yet.
  • Environment values explains which file each value lives in and which one wins.
  • First deploy walks through the accounts and resources you create before any of this resolves.
  • Going to production is where your domain and the two production sending hostnames come into use.

Copyright © 2026