Getting Started

First deploy

Put your project on Cloudflare in the three non-production contexts — everything provisioned up front, then four commands and a push.

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.

Your copy inherited constants pointing at NuxtStart's Cloudflare account and secret store. The prerequisites below replace them. Until then, infrastructure commands authenticate as nobody. Your project's own name is not one of them — that's .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 needHow to get itRequired
Local setup finishedLocal setupYes
A Cloudflare account, and its account idManage Account > Account API Tokens shows the id, 32 hexadecimal charactersYes
Your workers.dev subdomainThe Workers & Pages page, beside Your subdomainYes — every non-production origin carries it
A DNS zone you controlThe value you set as PROJECT_ZONE. Add it to Cloudflare, or keep DNS access at your hostOnly for outgoing email
apps/web and apps/site Infisical projectsSet up your secret storeYes
The GH PRs and GH Push identitiesMachine identitiesYes
The infrastructure identityMachine identities — lets CI run plan on a pull requestNo, most projects skip it
Google, GitHub, and Polar credentials per contextSet up sign-in and paymentsOnly for the feature that reads them
A GitHub repository you can set secrets onYour own copy of the templateYes

Command-line tools

ToolWhy
OpenTofuProvisions the long-lived resources
Infisical CLIReads and writes your secret store
ghSets the repository secrets in one go
wranglerAlready 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 ContextSecret TierFolder
developmentdevelopment/development
previewdevelopment/preview
stagingstaging/

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>'
Cloudflare opens the pooled connection from its own edge, so the Docker Postgres from local setup cannot be an origin. A pool pointed at one applies cleanly, reports no drift, and fails once traffic arrives.

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:

TokenKindEnds up in
<slug>-infra-stateR2 API tokenAWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the infra project
<slug>-infra-provisionaccount API tokenTF_VAR_cloudflare_api_token in the infra project
<slug>-worker-deployaccount API tokenthe CLOUDFLARE_DEPLOY_API_TOKEN repository secret

Infisical, where four different things sign in:

Who signs inAsWhy that method
You, at a terminalyour own infisical login sessioninfra/tofu.sh needs no identity on a laptop
The Plan jobthe infrastructure identity, Universal Authinfra/tofu.sh has no OIDC path, so CI needs a client secret
A pull request buildthe pull request identity, OIDCthe runner mints a token per run
A push deploythe push identity, OIDCthe 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>-infrastructureInfisical 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:

ConstantReplace with
CLOUDFLARE_ACCOUNT_IDYour Cloudflare account id, 32 hexadecimal characters
INFISICAL_INFRA_PROJECT_IDThe 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:

FieldValue
Token name<slug>-infra-state
PermissionObject 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.

The Secret Access Key cannot be read back. Lose it and the token is dead weight: create a replacement, then delete this one — the original keeps its bucket access until you do.

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:

PermissionLevelCreates
Workers R2 StorageEditapps/web's bucket, one per Runtime Context
D1Editapps/site's content database, one per context
HyperdriveEditthe 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
The Infisical CLI has no masking flag. A bare 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.
The three token values enter your shell history this way. Keep a copy of each in your team password manager — Infisical Free has no secret history — and clear the entry afterwards if that matters to you. Setting them in the dashboard instead avoids the history at the cost of three more screens.
Losing 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.

OutputGoes to
web_hyperdrive_idapps/webCLOUDFLARE_HYPERDRIVE_ID
site_d1_database_idapps/siteCLOUDFLARE_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.

Delete the two zeroed stand-ins Local setup had you put in apps/web/.env.development.local and apps/site/.env.development.local:
CLOUDFLARE_HYPERDRIVE_ID=00000000000000000000000000000000
CLOUDFLARE_D1_DATABASE_ID=00000000-0000-0000-0000-000000000000
Those files load after .env.development, so a stand-in left behind overrides the real id on your machine silently. The localhost URLs in the same files stay.
Already on Cloudflare with data you cannot lose? Import existing resources with cf-terraforming and keep their names, rather than letting an apply create new ones beside them.

2. Repository settings

In your repository on GitHub, go to Settings > Secrets and variables > Actions. Add each row under the tab its Kind names:

KindNameHolds
SecretCLOUDFLARE_DEPLOY_API_TOKENAccount token, two permissions — see below
VariableCLOUDFLARE_ACCOUNT_IDThe account both Workers live in
VariableINFISICAL_GH_PRS_IDENTITY_IDThe pull request identity, trusted for repo:<org>/<repo>:pull_request, granted preview
VariableINFISICAL_GH_PUSH_IDENTITY_IDThe push identity, trusted for refs/heads/…, granted development, staging and production
SecretINFISICAL_INFRA_CLIENT_IDThe infrastructure identity's client id — skip both rows if you skipped it
SecretINFISICAL_INFRA_CLIENT_SECRETIts 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>'
Name the repository rather than relying on the directory you happen to be in. Without --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.

Both, or neither. The infrastructure 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 groupLevelWhy
Workers ScriptsEditUploads the script, its vars, its secrets and its bindings
Workers R2 StorageReadwrangler 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.

R2 must be Read, not Edit, and it must be the account-scoped Workers R2 Storage group — choose Entire Account in the resource dropdown. The Workers R2 Storage Bucket Item option under R2 Buckets never satisfies an account-level call, however many buckets it names. Both mistakes fail with the same opaque 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:

apps/web/.env.development
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-web-development.<subdomain>.workers.dev
apps/site/.env.development
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.

One key in .env.preview is not preview's own, and this is the step people get wrong:
KeySet it to
NUXT_PUBLIC_APP_BASE_URLthe development origin, the same value as .env.development
It's the string the workflow prefixes 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:

apps/web/.env.staging
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-web-staging.<subdomain>.workers.dev

6. Create the staging branch

Promotion runs developmentstagingmain, 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.dev hostname cannot be a sending domain. Onboarding needs SPF, DKIM and MX records on a zone you control, which is what PROJECT_ZONE has to be. Your non-production origins are on workers.dev and your senders never can be.
  • Onboarding is per exact hostname, not per zone. acme.com does not authorise acme-web-development.acme.com.
A hostname you have not onboarded still resolves, still builds and still deploys. The message fails at the recipient instead — email verification, password reset and Polar receipts all go through it. Adding a runtime context means adding an onboarding.

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

Copyright © 2026