First deploy
Deploying happens in three stages, and this page is the second one. Each stage ends somewhere you can stop: a working laptop, a working pipeline, a working product.
flowchart LR
L["**1. Local setup**<br/>Both apps on your machine"] --> N["**2. First deploy**<br/>development, preview, staging"]
N --> P["**3. Going to production**<br/>Your domain, real users"]
By the end of this page, pushes to development and staging deploy, and every pull request gets
its own URL. Production is deliberately left off. Give yourself a few rounds of shipping through
staging before Going to production puts your domain in
front of real users.
Everything here happens on the development branch that
Local setup had you create. Nothing here touches main.
.env.project.schema, which
Local setup had you fill in.Prerequisites
Everything in this section is provisioning, and none of it is automated. It's gathered here rather than spread through the steps so you open each console once. Set aside an afternoon.
What you need before you start
| You need | How to get it | Required |
|---|---|---|
| Local setup finished | Local setup | Yes |
| A Cloudflare account, and its account id | Manage Account > Account API Tokens shows the id, 32 hexadecimal characters | Yes |
Your workers.dev subdomain | The Workers & Pages page, beside Your subdomain | Yes — every non-production origin carries it |
| A DNS zone you control | The value you set as PROJECT_ZONE. Add it to Cloudflare, or keep DNS access at your host | Only for outgoing email |
apps/web and apps/site Infisical projects | Set up your secret store | Yes |
The GH PRs and GH Push identities | Machine identities | Yes |
| The infrastructure identity | Machine identities — lets CI run plan on a pull request | No, most projects skip it |
| Google, GitHub, and Polar credentials per context | Set up sign-in and payments | Only for the feature that reads them |
| A GitHub repository you can set secrets on | Your own copy of the template | Yes |
Command-line tools
| Tool | Why |
|---|---|
| OpenTofu | Provisions the long-lived resources |
| Infisical CLI | Reads and writes your secret store |
gh | Sets the repository secrets in one go |
wrangler | Already a dependency — pnpm exec wrangler |
Run infisical login once after installing. The minimum OpenTofu version is the required_version
in infra/main.tf; an older tofu refuses the first command and names the version it wants.
Three Postgres databases
Nothing in infra/ creates one, and each deployed context needs its own. Create all three now so no
later step stops on a missing connection string.
| Runtime Context | Secret Tier | Folder |
|---|---|---|
development | development | /development |
preview | development | /preview |
staging | staging | / |
Any managed Postgres works. Store each connection string as DATABASE_URL in the row's tier and
folder, in apps/web's project — the project id is the projectId committed in
apps/web/.env.infisical:
# Repeat per row above, changing --env and --path
infisical secrets set DATABASE_URL='<the connection string for that context>' \
--env=development --path=/development --projectId='<apps/web project id>'
A connection string may carry sslmode and no other query parameter. Neon and Supabase hand out
strings ending ?sslmode=require&channel_binding=require, and the second parameter fails the plan by
name. infra/README.md has the full argument and the two valid edits.
The credentials involved
These are easy to confuse, so each name below is the one used everywhere else on this page. Cloudflare has no machine identities — its side is API tokens only.
Cloudflare API tokens, all three created in this section:
| Token | Kind | Ends up in |
|---|---|---|
<slug>-infra-state | R2 API token | AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the infra project |
<slug>-infra-provision | account API token | TF_VAR_cloudflare_api_token in the infra project |
<slug>-worker-deploy | account API token | the CLOUDFLARE_DEPLOY_API_TOKEN repository secret |
Infisical, where four different things sign in:
| Who signs in | As | Why that method |
|---|---|---|
| You, at a terminal | your own infisical login session | infra/tofu.sh needs no identity on a laptop |
The Plan job | the infrastructure identity, Universal Auth | infra/tofu.sh has no OIDC path, so CI needs a client secret |
| A pull request build | the pull request identity, OIDC | the runner mints a token per run |
| A push deploy | the push identity, OIDC | the runner mints a token per run |
Provision the infrastructure credentials
Six passes, in order — each uses what the one before it produced.
Create the infrastructure secret-store project
The passes below produce credentials that go here, so create it first, empty.
From your organization's Projects list, create a project named <slug>-infrastructure —
Infisical projects covers the dialog.
Project names are organization-wide, so the prefix keeps a second project's store apart from this
one.
Rename its dev environment slug to development, the same rename
Set up your secret store applies to the other two projects.
Delete staging and prod or leave them empty — nothing reads them. infra/tofu.sh passes
--env=development for every Runtime Context, so a project still on dev fails on the secret read.
Copy the project id from the URL or Project > Settings. The next pass commits it.
Point the repository at your own account
Replace the committed constants in infra/tofu.sh:
| Constant | Replace with |
|---|---|
CLOUDFLARE_ACCOUNT_ID | Your Cloudflare account id, 32 hexadecimal characters |
INFISICAL_INFRA_PROJECT_ID | The infrastructure project's id, from the pass above |
A project id is a reference, not a secret, which is why it's committed.
Your project's name isn't here. infra/tofu.sh reads PROJECT_SLUG out of .env.project.schema —
the same file both applications read — and hands it to OpenTofu, so the slug you set in
Local setup already names every resource
infra/main.tf creates, and the state bucket <slug>-infra-state with them. Keep it under 38
characters: a preview builds pr-<n>-<slug>-site-development against a 63-character DNS label.
No CLOUDFLARE_WORKER_NAME, bucket name, or database name appears in any committed .env.<context>,
because each application's schema builds them from that same slug.
Project identity is the full list, and no .tf file is ever one
of these edits.
CLOUDFLARE_ACCOUNT_ID is the one people miss. The R2 endpoint the state is written to is that same
id in a URL, so NuxtStart's value points your state at an account you cannot authenticate against —
and the failure names the bucket, not the id.Create the bucket that stores OpenTofu's records
The bucket holding the state cannot be declared in the state, so it's created by hand once. One bucket serves every active Runtime Context.
pnpm exec wrangler r2 bucket create "<slug>-infra-state"
Create an R2 API token, so OpenTofu can write to that bucket
Create an Account API token, not a User one: it belongs to the account, so teammates and CI
share it and it survives you leaving. A User API token goes inactive when your user is removed, and
takes every plan with it. Creating one needs the Super Administrator role.
In the Cloudflare dashboard, go to R2 object storage. Under Account Details, select Manage next to API Tokens, then Create Account API token:
| Field | Value |
|---|---|
| Token name | <slug>-infra-state |
| Permission | Object Read & Write |
| Buckets | <slug>-infra-state, and nothing else |
Token and bucket share a name on purpose: one credential, one bucket. Token names are account-wide, so a second project needs its own prefix. R2 API tokens is the fallback if a screen has moved.
Cloudflare shows the Access Key ID and the Secret Access Key once. Keep both — the sixth pass writes them into the infrastructure project. Put a copy in your team password manager as well, because Infisical Free has no secret history.
Create a Cloudflare API token, so OpenTofu can create the resources
An account token again. Go to Manage Account > Account API Tokens > Create Token, name it
<slug>-infra-provision, and give it one Edit permission per resource the root capability
files declare, and nothing else:
| Permission | Level | Creates |
|---|---|---|
| Workers R2 Storage | Edit | apps/web's bucket, one per Runtime Context |
| D1 | Edit | apps/site's content database, one per context |
| Hyperdrive | Edit | the pool in front of apps/web's Postgres |
That's what infra/web-assets.tf, infra/web-database.tf and infra/site-content.tf declare today.
Read them before you fill the form: a project that drops an application drops the permission its
resource needed, and one that adds a resource adds another. An apply missing a permission fails on
that resource rather than at the start.
This is not the R2 token above — that one is an S3 credential for the state bucket and needs nothing else.
Leave the expiration empty, then Continue to summary > Create Token. Account owned tokens is the fallback if a screen has moved.
This value is shown once too. Keep it for the next pass, and replace a lost one the same way: create another, delete this one.
Fill the infrastructure project
Set all four secrets in one command, generating the passphrase inline. Replace the three token
values you kept, and <infra-project-id> with the id you copied earlier:
# Writes every secret a plan reads into the infra project's development environment at /
infisical secrets set \
AWS_ACCESS_KEY_ID='<R2 Access Key ID>' \
AWS_SECRET_ACCESS_KEY='<R2 Secret Access Key>' \
TF_VAR_cloudflare_api_token='<the infra-provision token>' \
TF_VAR_state_encryption_passphrase="$(openssl rand -base64 32)" \
--env=development --path=/ --projectId='<infra-project-id>'
The AWS_ names are not a mistake: those are the R2 credentials wearing AWS names, because the
state backend speaks the S3 API. infisical secrets set masks values in its own output.
Check the four landed. infisical secrets prints values in full, so send it through jq and emit
the key names alone:
out=$(infisical secrets --projectId='<infra-project-id>' \
--env=development --path=/ --output=json --silent)
printf '%s' "$out" | jq -r '.[].secretKey' | sort
infisical secrets puts every credential in that
folder on your screen, into your scrollback, and into any terminal recording or CI log. Capture to a
variable and print only what you need.TF_VAR_state_encryption_passphrase loses the state. The resources survive, but each has to
be imported into a fresh state file one at a time. Keep a copy in your team password manager.You can plan now. infra/tofu.sh finds no client credentials on your machine and falls back to your
infisical login session, so nothing more is needed to run it yourself.
Part 1: Get development live
Three steps. At the end your product is on the internet.
1. Apply development, and nothing else yet
The root derives an independent backend key and OpenTofu data directory for each active Runtime
Context, so development applies alone:
infra/tofu.sh development init
infra/tofu.sh development plan
infra/tofu.sh development apply
infra/tofu.sh development output
Each id an apply prints names a value in an application's .env.development. Paste it into the key it
names, in the same commit as the apply — Infrastructure says why.
| Output | Goes to |
|---|---|
web_hyperdrive_id | apps/web → CLOUDFLARE_HYPERDRIVE_ID |
site_d1_database_id | apps/site → CLOUDFLARE_D1_DATABASE_ID |
The name outputs — web_r2_bucket_name, site_d1_database_name and any your project adds — go
nowhere. Your slug already decides them and both applications derive them; they are printed so a plan
and an apply say out loud what they created. An id is different, because Cloudflare mints it and
nothing in Git can know it in advance.
The root capability files decide what the apply produces, so the set follows your project rather
than this table. Drop an application and its outputs go with it — a project without apps/site gets the two
web_ outputs and no D1 at all, and that is correct rather than a failed resource.
The variables a deploy needs lists which
application declares which key. infra/README.md is the runbook for every later time, including
what each refusal means.
apps/web/.env.development.local and apps/site/.env.development.local:CLOUDFLARE_HYPERDRIVE_ID=00000000000000000000000000000000
CLOUDFLARE_D1_DATABASE_ID=00000000-0000-0000-0000-000000000000
.env.development, so a stand-in left behind overrides the real id on your
machine silently. The localhost URLs in the same files stay.2. Repository settings
In your repository on GitHub, go to Settings > Secrets and variables > Actions. Add each row under the tab its Kind names:
| Kind | Name | Holds |
|---|---|---|
| Secret | CLOUDFLARE_DEPLOY_API_TOKEN | Account token, two permissions — see below |
| Variable | CLOUDFLARE_ACCOUNT_ID | The account both Workers live in |
| Variable | INFISICAL_GH_PRS_IDENTITY_ID | The pull request identity, trusted for repo:<org>/<repo>:pull_request, granted preview |
| Variable | INFISICAL_GH_PUSH_IDENTITY_ID | The push identity, trusted for refs/heads/…, granted development, staging and production |
| Secret | INFISICAL_INFRA_CLIENT_ID | The infrastructure identity's client id — skip both rows if you skipped it |
| Secret | INFISICAL_INFRA_CLIENT_SECRET | Its client secret |
Or set all six with gh. Set REPO to the repository you mean, replace each placeholder, then paste
the block:
REPO=<owner>/<repo>
gh secret set CLOUDFLARE_DEPLOY_API_TOKEN --repo "$REPO" --body '<the deploy token below>'
gh secret set INFISICAL_INFRA_CLIENT_ID --repo "$REPO" --body '<infrastructure identity client id>'
gh secret set INFISICAL_INFRA_CLIENT_SECRET --repo "$REPO" --body '<its client secret>'
gh variable set CLOUDFLARE_ACCOUNT_ID --repo "$REPO" --body '<the CLOUDFLARE_ACCOUNT_ID from infra/tofu.sh>'
gh variable set INFISICAL_GH_PRS_IDENTITY_ID --repo "$REPO" --body '<the pull request identity id>'
gh variable set INFISICAL_GH_PUSH_IDENTITY_ID --repo "$REPO" --body '<the push identity id>'
--repo, gh
writes to whatever clone is your working directory, and one set of values silently overwrites
another project's. A wrong Cloudflare token is the worst case: both projects can share one account,
so the deploy authenticates and the run goes green.Check the result with gh secret list --repo "$REPO" and gh variable list --repo "$REPO". Both
print names; only variables print values.
--body puts each value in your shell history. Drop the flag and gh reads the value from standard
input instead, so paste it at the prompt and end with Ctrl-D — or set the three secrets in the
dashboard and use gh for the three variables, which are identifiers rather than credentials.An identity id opens nothing without a token only your runner can mint, which is why the two OIDC
identities travel as variables. The INFISICAL_INFRA_ pair is the only Infisical client secret in GitHub at
all, and the name says INFRA deliberately: an INFISICAL_CLIENT_ID would read as the
applications' identity to whoever adds the next workflow.
Plan job skips itself with a warning when neither is set. A
half-set pair fails, because nobody means to set one.The Cloudflare deploy token
Go to Manage Account > Account API Tokens > Create Token, name it
<slug>-worker-deploy, and give it two permissions scoped to the entire account:
| Permission group | Level | Why |
|---|---|---|
| Workers Scripts | Edit | Uploads the script, its vars, its secrets and its bindings |
| Workers R2 Storage | Read | wrangler checks each R2 binding's bucket exists before deploying |
Cloudflare's Edit Cloudflare Workers template grants eight permissions, including write on Workers
Routes for every zone. Two is enough.
Authentication error [code: 10000], and apps/site deploys
cleanly without R2, so the fault looks intermittent.3. Push development
Both applications ship with NUXT_PUBLIC_APP_BASE_URL=REPLACE_ME, and no origin is derived. The
deploy gate skips any context whose value file still assigns that word, so a push with it left in
place goes green and ships nothing. Write development's origin into each file first. Both Worker
names come from your slug, so the only part you supply is the workers.dev subdomain from the
prerequisites:
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-web-development.<subdomain>.workers.dev
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-site-development.<subdomain>.workers.dev
Commit the ids you pasted and the two origins, then push:
git push origin development
The workflow builds both applications, migrates apps/web's database and deploys each Worker.
Which event deploys what is the whole mapping.
You are live
The run is green and both Workers serve. Each application's URL is its NUXT_PUBLIC_APP_BASE_URL in
its own .env.development — a *.workers.dev origin until
Going to production attaches your domain.
A run that shipped nothing is also green, so read the run log if a URL returns 404. When a deploy is skipped lists every reason.
Part 2: preview and staging
Everything here is additive. Each Runtime Context has its own backend key and OpenTofu data
directory, so an apply for one cannot disturb another. Both contexts already have their
DATABASE_URL from the prerequisites.
4. Apply preview, so pull requests get their own URL
A pull request never gets a Worker. It uploads a version onto the development Worker under a
pr-<n> alias, bound to preview's own bucket, database and pool — which is why the development
Worker had to exist first. versions upload cannot create one, and only a push does. Part 1 did it.
infra/tofu.sh preview init
infra/tofu.sh preview plan
infra/tofu.sh preview apply
infra/tofu.sh preview output
Paste each id into the key it names, in that application's .env.preview.
.env.preview is not preview's own, and this is the step people get wrong:| Key | Set it to |
|---|---|
NUXT_PUBLIC_APP_BASE_URL | the development origin, the same value as .env.development |
pr-<n>- onto to build the preview URL, so it must stay a bare
workers.dev origin. The ids from the apply are preview's own and go in as printed.The Worker name is the same story and you don't write it: each application's schema remaps preview
onto the development Worker, because that is the Worker a preview version lands on rather than
one preview owns. A <slug>-web-preview would name a Worker nothing ever creates, and
versions upload refuses to create one.Then open a pull request to check it. Pull request previews covers what a preview does and does not do — it never migrates the database.
5. Apply staging
The same four commands, with each output pasted into .env.staging. Staging is already an active
context, so nothing has to be turned on:
infra/tofu.sh staging init
infra/tofu.sh staging plan
infra/tofu.sh staging apply
infra/tofu.sh staging output
Staging's origin takes the same shape as development's, with its own context in it:
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-web-staging.<subdomain>.workers.dev
6. Create the staging branch
Promotion runs development → staging → main, and the middle branch does not exist yet:
git checkout -b staging development
git push -u origin staging
A push to staging deploys staging. Nothing enforces the order — the gate reads only the branch
pushed — so promote by merging forward.
Email will not send until you onboard a domain
Local setup had you set PROJECT_ZONE, and Mailpit caught the
mail without asking anything of it. A deployed context sends for real.
You write no sending domain anywhere. Each application builds one per context —
<slug>-<app>-<context>.<zone> in every context except production.
Project identity has the full table.
Read the hostname for a context off the resolved environment, rather than assembling it yourself:
cd apps/web && APP_RUNTIME_CONTEXT=development pnpm exec varlock load --filter NUXT_EMAIL_SENDER_DOMAIN
Onboard that exact hostname, then fetch the DNS records it needs:
pnpm exec wrangler email sending enable acme-web-development.acme.com
pnpm exec wrangler email sending dns get acme-web-development.acme.com
When PROJECT_ZONE is a zone in the same Cloudflare account, the records are added for you — confirm
with wrangler email sending list. Otherwise add each printed record at your DNS host.
Repeat for every application and every context you deploy. Two things bite:
- A
*.workers.devhostname cannot be a sending domain. Onboarding needs SPF, DKIM and MX records on a zone you control, which is whatPROJECT_ZONEhas to be. Your non-production origins are onworkers.devand your senders never can be. - Onboarding is per exact hostname, not per zone.
acme.comdoes not authoriseacme-web-development.acme.com.
When it does not work
A green run that shipped nothing means the deploy was skipped — usually step 2, or
.env.project.schema, an .env.schema or a value file still assigning REPLACE_ME. The run log
names the file it matched. A red run naming a secret or a CLOUDFLARE_* variable means a
prerequisite is unfinished. See
When a deploy is skipped.
Next steps
- Going to production — stage 3, once you've shipped through staging a few times
- Deploy pipeline — what each event does, and what a deploy needs
- Infrastructure — the runbook for every apply after these
