Getting Started

Deploying development

Put `development` on Cloudflare — accounts and credentials first, then a push.

Your project gains its Runtime Contexts in stages, and each stage ends somewhere you can stop: a working laptop, a working pipeline, an optional rehearsal, a working product. This page puts development on Cloudflare.

flowchart LR
    L["1. Local setup<br/>Both apps on your machine"] --> D["**2. Deploying development**<br/>A push deploys"]
    L --> R["3. Adding pull request previews<br/>Independent of development"]
    D --> S["4. Adding staging<br/>Optional"]
    S --> P["5. Going to production<br/>Your domain, real users"]
    D -. "without staging" .-> P

By the end of this page, a push to development deploys both applications. Pull request previews are a stage of their own that doesn't need this one: Adding pull request previews works before this page, after it, or without it. Adding staging is optional and can wait, and Going to production comes last, once the application and its name are final.

Everything here starts on the development branch that Local setup had you create. Nothing here touches main.

You never type a Worker, bucket, database or pool name on this page. Each one follows from the PROJECT_SLUG that Local setup had you set, and the first run that deploys a context creates that context's resources.

Prerequisites

Everything in this section happens in a console you sign in to yourself. It's gathered here rather than spread through the steps so you open each console once.

What you need before you start

You needHow to get itRequired
Local setup finishedLocal setupYes
A Cloudflare account, and its account idpnpm exec wrangler whoami prints the id, 32 hexadecimal charactersYes
The Super Administrator role on that accountCreating an Account API token needs it. Otherwise, ask someone who has itYes
Your workers.dev subdomainThe Workers & Pages page, beside Your subdomainYes — every non-production origin carries it
A Postgres database for developmentAny managed Postgres reachable from the internet, never one another context usesYes
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
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 onThe one you created in Local setupYes

Command-line tools

ToolWhy
Infisical CLIReads and writes your secret store
ghSets the repository secret and variables in one go
wranglerAlready a dependency — pnpm exec wrangler

Run infisical login and pnpm exec wrangler login once after installing. Both sign you in as yourself, so nothing on this page asks you to create a personal token.

The credentials involved

These are easy to confuse, so each name below is the one used everywhere else on this page.

Cloudflare, where a person and a runner sign in differently:

Who signs inWithWhy that method
You, at a terminalpnpm exec wrangler login, a browser sign-in as yourselfNo token to create, store or leak
GitHub Actionsthe <slug>-github-actions Account API token, as CLOUDFLARE_API_TOKENA runner cannot open a browser

The project uses no User API token. A User API token belongs to one person and stops working when that person leaves the account, and the deploy stops with it. An Account API token belongs to the account.

Infisical, where each of these signs in its own way:

Who signs inAsWhy that method
You, at a terminalyour own infisical login sessionThe commands on this page write secrets as you
The applications on your machineyour machine identity, Universal AuthLocal setup connected it
A pull request buildthe pull request identity (GH PRs), OIDCThe runner mints a token per run
A push deploythe push identity (GH Push), OIDCThe runner mints a token per run

Connect the repository to Cloudflare

Every deployed context shares these settings, so you set them once — here, or from Adding pull request previews if you reach that stage first. They use the account, the role, the two identities and the command-line tools from Prerequisites.

The Cloudflare API token

This is the deploy's only Cloudflare credential. Create an Account API token, not a User one — the credentials involved says why.

Go to Manage Account > Account API Tokens > Create Token, name it <slug>-github-actions, and give it these permissions, each scoped to the entire account:

Permission groupLevelWhy
Workers ScriptsEditDeploys each Worker and each pull request preview, and deletes a closed pull request's preview
Workers R2 StorageEditFinds or creates apps/web's bucket
D1EditFinds or creates apps/site's content database
HyperdriveEditFinds, creates or updates apps/web's pool

Each resource type the deploy creates needs its own Edit permission, and your bindings decide the set: hub.blob and hub.db in apps/web/nuxt.config.ts, and d1_databases in apps/site/nuxt.config.ts. A project without apps/site can leave out D1, and a binding of a new resource type adds a row. Cloudflare's Edit Cloudflare Workers template grants far more, including write on Workers Routes for every zone, so build the token from the rows above instead.

R2 must be the account-scoped Workers R2 Storage group, at Edit — 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. Every wrong R2 permission fails with the same opaque Authentication error [code: 10000], and apps/site deploys cleanly without R2, so the fault looks intermittent.

Leave the expiration empty, then Continue to summary > Create Token. Account owned tokens is the fallback if a screen has moved.

Cloudflare shows the value once, and it goes straight into the CLOUDFLARE_API_TOKEN secret below. Lose it and you create another, then delete this one.

The secret and the variables

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

KindNameHolds
SecretCLOUDFLARE_API_TOKENThe <slug>-github-actions token from the step above
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

Or set every row with gh. Set REPO to the repository you mean, replace each placeholder, then paste the block:

REPO=<owner>/<repo>

gh secret set   CLOUDFLARE_API_TOKEN          --repo "$REPO" --body '<the <slug>-github-actions token>'
gh variable set CLOUDFLARE_ACCOUNT_ID         --repo "$REPO" --body '<your Cloudflare account id>'
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 secret in the dashboard and use gh for the 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. No Infisical client secret is stored in GitHub at all.

The secret turns on every deploy at once. From then on a push to development deploys development, and every pull request deploys a preview. A context whose values are still empty ends its run red, naming the first one, so a stage you haven't reached yet shows as a red run rather than a skipped one.

Get development live

At the end of this part your product is on the internet.

1. Fill development's secret folder

development reads the development Secret Tier's /development folder in apps/web's Infisical project — the project id is the projectId committed in apps/web/.env.infisical. Store your Postgres connection string there:

infisical secrets set DATABASE_URL='<the development connection string>' \
  --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. The database has to be reachable from the internet.

Paste the connection string as your provider hands it out, query string included. The deploy builds the context's Hyperdrive configuration from it, and Resources says which parts it reads.

Then add development's own value for every key that Set up your secret store put in /local. A feature you're skipping takes a stand-in, as it does locally.

2. Write development's origins

Both applications ship with their origins empty — NUXT_PUBLIC_APP_BASE_URL in each, and apps/web's NUXT_PUBLIC_APP_WEBSITE_BASE_URL — and no origin is derived. Each one is required, so now that the repository holds your Cloudflare token, a push without them fails at the build, naming the key. 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
NUXT_PUBLIC_APP_WEBSITE_BASE_URL=https://<slug>-site-development.<subdomain>.workers.dev
apps/site/.env.development
NUXT_PUBLIC_APP_BASE_URL=https://<slug>-site-development.<subdomain>.workers.dev

NUXT_PUBLIC_APP_WEBSITE_BASE_URL is the site's origin, which apps/web links to for /docs and /blog.

3. Push development

Commit the two files, then push:

git push origin development

Before it builds, the workflow finds each resource development binds, by name. This is the first run into development, so it creates all of them: the bucket and pool <slug>-web-development, and the content database <slug>-site-development. It points the pool at development's DATABASE_URL, then builds both applications, migrates apps/web's database and deploys each Worker. Resources has the table, and 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 the reasons.

Turn on dependency updates

Install the Renovate app and turn on Dependabot alerts, so each dependency release arrives as a pull request. Turn it on has the steps. Until then, .github/renovate.json5 does nothing.

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, and on a push to your own repository that means the run found no Cloudflare credentials — Connect the repository to Cloudflare. A red run naming a key means that value is empty — in .env.project.schema, the context's value file, or its folder in the secret store. One naming a CLOUDFLARE_* variable means a prerequisite is unfinished, and one that stops at Upsert the Cloudflare resources points at that context's DATABASE_URL or at one of the token's resource permissions. See When a deploy is skipped.

Next steps

Copyright © 2026