Project identity
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.
| Item | Required | What it is | Example |
|---|---|---|---|
PROJECT_SLUG | Yes | The lowercase name inside every resource name | my-app |
PROJECT_DISPLAY_NAME | Yes | The product name people read | My App |
PROJECT_ZONE | Yes | The DNS zone you own | example.com |
PROJECT_DOMAIN | No, defaults to the zone | The hostname production is served from | example.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.
| Name | Shape | Which context it reads |
|---|---|---|
NUXT_PUBLIC_APP_NAME | The display name | None — 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 others | The runtime context, as-is |
Three different context inputs, and each is deliberate:
- A pull request preview uploads its version onto the
developmentWorker, soCLOUDFLARE_WORKER_NAMEresolves to development's name underpreview. - A local run shares development's bucket and database, so those two resolve development's names
under
local.APP_ENVis the flag that already carries that mapping. - Mail reads the context as-is, so
localgets a sender of its own rather than borrowing development's, andproductionis 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 deploying | apps/site | apps/web |
|---|---|---|
| Both apps, on a zone you own | example.com | app.example.com |
| Both apps, at a subdomain | my-app.example.com | app.my-app.example.com |
| The website only | example.com | — |
| The product application only | — | example.com |
| The product mounted under the site | example.com | example.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.
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 context | apps/web | apps/site |
|---|---|---|
production | app.<domain> | site.<domain> |
staging, development, preview, local | <slug>-web-<context>.<zone> | <slug>-site-<context>.<zone> |
test | mail.example.test | mail.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.
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.
PROJECT_SLUG=my-app
PROJECT_DISPLAY_NAME=My App
PROJECT_ZONE=example.com
PROJECT_DOMAIN=$PROJECT_ZONE
| Runtime context | Application | Serves | Sends mail from | Worker name |
|---|---|---|---|---|
production | apps/site | example.com | site.example.com | my-app-site-production |
production | apps/web | app.example.com | app.example.com | my-app-web-production |
staging | apps/site | workers.dev | my-app-site-staging.example.com | my-app-site-staging |
staging | apps/web | workers.dev | my-app-web-staging.example.com | my-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.
PROJECT_SLUG=acme-crm
PROJECT_DISPLAY_NAME=Acme CRM
PROJECT_ZONE=getacme.com
PROJECT_DOMAIN=$PROJECT_ZONE
| Runtime context | Application | Serves | Sends mail from | Worker name |
|---|---|---|---|---|
production | apps/site | getacme.com | site.getacme.com | acme-crm-site-production |
production | apps/web | app.getacme.com | app.getacme.com | acme-crm-web-production |
staging | apps/site | workers.dev | acme-crm-site-staging.getacme.com | acme-crm-site-staging |
staging | apps/web | workers.dev | acme-crm-web-staging.getacme.com | acme-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.
PROJECT_SLUG=my-app
PROJECT_DISPLAY_NAME=My App
PROJECT_ZONE=example.com
PROJECT_DOMAIN=my-app.example.com
| Runtime context | Application | Serves | Sends mail from | Worker name |
|---|---|---|---|---|
production | apps/site | my-app.example.com | site.my-app.example.com | my-app-site-production |
production | apps/web | app.my-app.example.com | app.my-app.example.com | my-app-web-production |
staging | apps/site | workers.dev | my-app-site-staging.example.com | my-app-site-staging |
staging | apps/web | workers.dev | my-app-web-staging.example.com | my-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:
| Kind | Examples |
|---|---|
| Values your Cloudflare account owns | The workers.dev subdomain, and the origins built from it |
| Values a provider mints on apply | CLOUDFLARE_HYPERDRIVE_ID, CLOUDFLARE_D1_DATABASE_ID |
| Values registered elsewhere | GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID |
Account constants in infra/tofu.sh | CLOUDFLARE_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.
