---
title: "Deploying development"
description: "Put `development` on Cloudflare — accounts and credentials first, then a push."
canonical_url: "https://nuxtstart.com/docs/getting-started/deploying-development"
---

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.

```mermaid
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](/docs/getting-started/adding-pull-request-previews) works before this
page, after it, or without it. [Adding staging](/docs/getting-started/adding-staging) is optional
and can wait, and [Going to production](/docs/getting-started/going-to-production) comes last, once
the application and its name are final.

Everything here starts on the `development` branch that
[Local setup](/docs/getting-started/local-setup) had you create. Nothing here touches `main`.

<note>

You never type a Worker, bucket, database or pool name on this page. Each one follows from the
`PROJECT_SLUG` that [Local setup](/docs/getting-started/local-setup#name-your-project) had you set,
and the first run that deploys a context creates that context's resources.

</note>

## 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

<table>
<thead>
  <tr>
    <th>
      You need
    </th>
    
    <th>
      How to get it
    </th>
    
    <th>
      Required
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Local setup finished
    </td>
    
    <td>
      <a href="/docs/getting-started/local-setup">
        Local setup
      </a>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      A Cloudflare account, and its account id
    </td>
    
    <td>
      <code>
        pnpm exec wrangler whoami
      </code>
      
       prints the id, 32 hexadecimal characters
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      The Super Administrator role on that account
    </td>
    
    <td>
      Creating an Account API token needs it. Otherwise, ask someone who has it
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      Your <code>
        workers.dev
      </code>
      
       subdomain
    </td>
    
    <td>
      The <strong>
        Workers & Pages
      </strong>
      
       page, beside <strong>
        Your subdomain
      </strong>
    </td>
    
    <td>
      Yes — every non-production origin carries it
    </td>
  </tr>
  
  <tr>
    <td>
      A Postgres database for <code>
        development
      </code>
    </td>
    
    <td>
      Any managed Postgres reachable from the internet, never one another context uses
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      A DNS zone you control
    </td>
    
    <td>
      The value you set as <code>
        PROJECT_ZONE
      </code>
      
      . Add it to Cloudflare, or keep DNS access at your host
    </td>
    
    <td>
      Only for outgoing email
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        apps/web
      </code>
      
       and <code>
        apps/site
      </code>
      
       Infisical projects
    </td>
    
    <td>
      <a href="/docs/configuration/secret-store">
        Set up your secret store
      </a>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      The <code>
        GH PRs
      </code>
      
       and <code>
        GH Push
      </code>
      
       identities
    </td>
    
    <td>
      <a href="/docs/configuration/machine-identities">
        Machine identities
      </a>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      Google, GitHub, and Polar credentials per context
    </td>
    
    <td>
      <a href="/docs/configuration/provider-accounts">
        Set up sign-in and payments
      </a>
    </td>
    
    <td>
      Only for the feature that reads them
    </td>
  </tr>
  
  <tr>
    <td>
      A GitHub repository you can set secrets on
    </td>
    
    <td>
      The one you created in <a href="/docs/getting-started/local-setup#get-the-code">
        Local setup
      </a>
    </td>
    
    <td>
      Yes
    </td>
  </tr>
</tbody>
</table>

### Command-line tools

<table>
<thead>
  <tr>
    <th>
      Tool
    </th>
    
    <th>
      Why
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <a href="https://infisical.com/docs/cli/overview" rel="nofollow">
        Infisical CLI
      </a>
    </td>
    
    <td>
      Reads and writes your secret store
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="https://cli.github.com" rel="nofollow">
        <code>
          gh
        </code>
      </a>
    </td>
    
    <td>
      Sets the repository secret and variables in one go
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        wrangler
      </code>
    </td>
    
    <td>
      Already a dependency — <code>
        pnpm exec wrangler
      </code>
    </td>
  </tr>
</tbody>
</table>

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:

<table>
<thead>
  <tr>
    <th>
      Who signs in
    </th>
    
    <th>
      With
    </th>
    
    <th>
      Why that method
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      You, at a terminal
    </td>
    
    <td>
      <code>
        pnpm exec wrangler login
      </code>
      
      , a browser sign-in as yourself
    </td>
    
    <td>
      No token to create, store or leak
    </td>
  </tr>
  
  <tr>
    <td>
      GitHub Actions
    </td>
    
    <td>
      the <code>
        <slug>-github-actions
      </code>
      
       Account API token, as <code>
        CLOUDFLARE_API_TOKEN
      </code>
    </td>
    
    <td>
      A runner cannot open a browser
    </td>
  </tr>
</tbody>
</table>

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:

<table>
<thead>
  <tr>
    <th>
      Who signs in
    </th>
    
    <th>
      As
    </th>
    
    <th>
      Why that method
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      You, at a terminal
    </td>
    
    <td>
      your own <code>
        infisical login
      </code>
      
       session
    </td>
    
    <td>
      The commands on this page write secrets as you
    </td>
  </tr>
  
  <tr>
    <td>
      The applications on your machine
    </td>
    
    <td>
      your machine identity, Universal Auth
    </td>
    
    <td>
      <a href="/docs/getting-started/local-setup">
        Local setup
      </a>
      
       connected it
    </td>
  </tr>
  
  <tr>
    <td>
      A pull request build
    </td>
    
    <td>
      <strong>
        the pull request identity
      </strong>
      
       (<code>
        GH PRs
      </code>
      
      ), OIDC
    </td>
    
    <td>
      The runner mints a token per run
    </td>
  </tr>
  
  <tr>
    <td>
      A push deploy
    </td>
    
    <td>
      <strong>
        the push identity
      </strong>
      
       (<code>
        GH Push
      </code>
      
      ), OIDC
    </td>
    
    <td>
      The runner mints a token per run
    </td>
  </tr>
</tbody>
</table>

## Connect the repository to Cloudflare

Every deployed context shares these settings, so you set them once — here, or from
[Adding pull request previews](/docs/getting-started/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](#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](#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:

<table>
<thead>
  <tr>
    <th>
      Permission group
    </th>
    
    <th>
      Level
    </th>
    
    <th>
      Why
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        Workers Scripts
      </strong>
    </td>
    
    <td>
      <code>
        Edit
      </code>
    </td>
    
    <td>
      Deploys each Worker and each pull request preview, and deletes a closed pull request's preview
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Workers R2 Storage
      </strong>
    </td>
    
    <td>
      <code>
        Edit
      </code>
    </td>
    
    <td>
      Finds or creates <code>
        apps/web
      </code>
      
      's bucket
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        D1
      </strong>
    </td>
    
    <td>
      <code>
        Edit
      </code>
    </td>
    
    <td>
      Finds or creates <code>
        apps/site
      </code>
      
      's content database
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Hyperdrive
      </strong>
    </td>
    
    <td>
      <code>
        Edit
      </code>
    </td>
    
    <td>
      Finds, creates or updates <code>
        apps/web
      </code>
      
      's pool
    </td>
  </tr>
</tbody>
</table>

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.

<caution>

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.

</caution>

Leave the expiration empty, then **Continue to summary** > **Create Token**.
[Account owned tokens](https://developers.cloudflare.com/fundamentals/api/get-started/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:

<table>
<thead>
  <tr>
    <th>
      Kind
    </th>
    
    <th>
      Name
    </th>
    
    <th>
      Holds
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Secret
    </td>
    
    <td>
      <code>
        CLOUDFLARE_API_TOKEN
      </code>
    </td>
    
    <td>
      The <code>
        <slug>-github-actions
      </code>
      
       token from the step above
    </td>
  </tr>
  
  <tr>
    <td>
      Variable
    </td>
    
    <td>
      <code>
        CLOUDFLARE_ACCOUNT_ID
      </code>
    </td>
    
    <td>
      The account both Workers live in
    </td>
  </tr>
  
  <tr>
    <td>
      Variable
    </td>
    
    <td>
      <code>
        INFISICAL_GH_PRS_IDENTITY_ID
      </code>
    </td>
    
    <td>
      The pull request identity, trusted for <code>
        repo:<org>/<repo>:pull_request
      </code>
      
      , granted preview
    </td>
  </tr>
  
  <tr>
    <td>
      Variable
    </td>
    
    <td>
      <code>
        INFISICAL_GH_PUSH_IDENTITY_ID
      </code>
    </td>
    
    <td>
      The push identity, trusted for <code>
        refs/heads/…
      </code>
      
      , granted development, staging and production
    </td>
  </tr>
</tbody>
</table>

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

```shell
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>'
```

<caution>

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.

</caution>

Check the result with `gh secret list --repo "$REPO"` and `gh variable list --repo "$REPO"`. Both
print names; only variables print values.

<caution>

`--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.

</caution>

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:

```shell
infisical secrets set DATABASE_URL='<the development connection string>' \
  --env=development --path=/development --projectId='<apps/web project id>'
```

<caution>

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.

</caution>

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](/docs/deployment/resources#the-hyperdrive-configuration) says which parts it reads.

Then add development's own value for every key that
[Set up your secret store](/docs/configuration/secret-store#what-to-put-in-the-local-folder) 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:

```env [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
```

```env [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:

```shell
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](/docs/deployment/resources) has the table, and
[Which event deploys what](/docs/deployment/pipeline#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](/docs/getting-started/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](/docs/deployment/pipeline#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](/docs/configuration/dependency-updates#turn-it-on) has the steps.
Until then, `.github/renovate.json5` does nothing.

## Email will not send until you onboard a domain

[Local setup](/docs/getting-started/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](/docs/reference/project-identity#sending-domains) has the full table.

Read the hostname for a context off the resolved environment, rather than assembling it yourself:

```bash
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:

```bash
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`.

<warning>

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.

</warning>

## 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](#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](/docs/deployment/pipeline#when-a-deploy-is-skipped).

## Next steps

- [Adding pull request previews](/docs/getting-started/adding-pull-request-previews) — a preview of
every pull request, on preview's own Worker and data
- [Adding staging](/docs/getting-started/adding-staging) — optional, a pre-production context between
`development` and production
- [Going to production](/docs/getting-started/going-to-production) — the last stage, once the
application and its name are final
- [Deploy pipeline](/docs/deployment/pipeline) — what each event does, and what a deploy needs
- [Resources](/docs/deployment/resources) — what each context creates, how to see it, and how to
delete it

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
