Secure deploy to Cloudflare

An API token for one account, kept out of the build - step by step, including old chunks after a deploy

A guide you can follow even without deep Cloudflare experience: how to create a token with the right permissions, keep it out of the deployed code, lock deploys to one account, check that it worked and deploy without breaking pages that visitors have open.

Vladimír NeporAuthorVladimír Nepor
Published
Secure deploy: the API token lives in .env.deploy, a bash wrapper loads it only for one command, and wrangler.jsonc locks the deploy to one Cloudflare account. The build never sees the token..env.deployCLOUDFLARE_API_TOKEN=•••••••token for this projectchmod 600 · .gitignore✕ build never reads itnpm run deploybash -c 'set -a; . …'exec wrangler deploytoken only forthis one commandCloudflare"account_id": "a1b2…"one account onlywrangler.jsonc03 · TOKENA token for one accountminimal permissions + IP filterchapter →04 · PROJECTToken outside the buildaccount_id + .env.deploy + scriptschapter →05 · VERIFYCheck before deployingwhoami + scan of the buildchapter →06 · DEPLOYDeploy without broken pageskeep the old chunkschapter →
Guide contents

00 · Overview

Secure deploy at a glance

This guide sets up deploys to Cloudflare Workers so that the API token never ends up in the deployed code, every deploy goes to exactly one account, and visitors do not get broken pages after a deploy. It is written for Next.js on OpenNext, and most steps apply to any Worker (Express, Hono, plain Workers). Every command can be copied with one click.

The whole setup in short

  1. One token per project - an account token with only the permissions the project uses, limited to your IP address
  2. Lock the account - "account_id" in wrangler.jsonc, so a deploy cannot land in another account
  3. Keep the token out of the build - the token lives in .env.deploy, which no framework reads; never in .env
  4. Load it only for one command - npm scripts load the file in their own bash; npm run -s wr -- replaces npx wrangler
  5. Verify, then deploy - check the account and the build output, deploy, and keep the old JS chunks so open pages keep working

What are you trying to do?

QuestionWhere to find the answer
Why is a token in .env or wrangler login a problem?Why →
What do I need before I start?Before you start →
Which permissions should the token have?Create the token →
How do I set up the project files?Set up the project →
How do I check that the token is not in the build?Verify →
After a deploy, open pages fail with ChunkLoadError.Deploy and old chunks →
We already have a token in .env. What now?Moving from the old setup →
I get a 403, an OAuth window or "not authenticated".Troubleshooting →
Scope

State as of October 2026: Wrangler 4, @opennextjs/cloudflare 1.x, account-owned API tokens. The setup runs in production on this website (Next.js on OpenNext, D1, R2, Workers Cache). The commands need bash: Linux, macOS, or WSL on Windows.

01 · Why

What is wrong with wrangler login and a token in .env

The common setup, wrangler login or a token in .env, works, but it has two weak spots. It is easy to deploy into the wrong account, and the token can quietly end up inside the code that runs in production.

wrangler login opens every account

npx wrangler login signs you in through OAuth as a person. Wrangler then has access to every account you are a member of: your own and all your clients'. One command run in the wrong folder, or a project without a fixed account, and the deploy, a D1 query or a deleted bucket lands in the wrong place. A token created for one account cannot do that.

A token in .env ends up in production

Wrangler reads .env automatically, so putting the token there looks natural. But the frameworks read it too, and some of them copy it into the build:

Top: the token in .env is read by the OpenNext build, written into next-env.mjs and ends up in the production Worker. Bottom: the token in .env.deploy is loaded only by the wrapper for the wrangler CLI, the build does not read it and the Worker contains no token.✕ Token in .env.envCLOUDFLARE_API_TOKEN=…OpenNext buildreads every .env*Production Workertoken in process.env✓ Token in .env.deploy.env.deployCLOUDFLARE_API_TOKEN=…bash wrapperonly for wranglerCloudflare APIdeploy to one accountThe build reads only .env, .env.local, .env.<mode>: the Worker contains no token
The same token, two files. Only the bottom path keeps it out of the deployed Worker.
ProjectHow the token gets into the deployed code
Next.js on OpenNextthe build writes all values from .env, .env.local, .env.<mode> and .env.<mode>.local into .open-next/cloudflare/next-env.mjs and loads them into process.env at runtime
Next.js standalone.next/standalone contains a copy of .env
Docker / Cloudflare ContainersCOPY . . without .env* in .dockerignore copies the file into the image
Express or another Workerdotenv at build time or esbuild define can inline values into the bundle
Any frameworkvariables with a public prefix (NEXT_PUBLIC_, VITE_) end up in the JavaScript sent to the browser
Why it matters

A deploy token inside the Worker means that anyone who gets to its code, its environment or its logs (a bug, a dependency, a debug endpoint) can deploy, read databases and delete storage in your account. The token stays valid until you delete it, even after you remove it from .env.

02 · Before you start

What you need

Ten minutes and these few things. If something is missing, sort it out first, the later steps rely on it.

  • A project with a Wrangler config: wrangler.jsonc, wrangler.json or wrangler.toml in the project root.
  • bash: Linux and macOS have it. On Windows, work in WSL (Ubuntu), Git Bash is enough for the commands too.
  • Access to the Cloudflare dashboard of the target account, with the right to create API tokens.
  • The project in git, so you can see exactly what changed.

Which kind of project do you have?

You see in the projectProject typeWhat to follow
@opennextjs/cloudflare in package.jsonNext.js on OpenNexteverything, including the old chunks in chapter 06
wrangler.jsonc with "main", no Next.jsa Worker (Express, Hono…)everything except chapter 06; use the shorter scripts
Dockerfile + containers in the configCloudflare Containerseverything, plus .env* in .dockerignore
Express on your own server (no Wrangler)not a Workerthe server needs no Cloudflare token at all, keep it only on the machine you deploy from

03 · Token

Create a token for one project

Create a separate token for every project. If you ever suspect a leak, you delete one token and the other projects keep running. Cloudflare shows the token value only once, so have the project open and ready to paste it.

Step 1: Open the token form in the right account

In the dashboard, switch to the project's account and open Manage Account → Account API Tokens → Create Token → Custom token. An account token belongs to the account and can never reach another one. A user token (My Profile → API Tokens) works too, but you have to limit it to one account yourself (step 3).

Step 2: Name the token and add only the permissions the project uses

Name: e.g. my-app deploy. Then click Add more for every row you need. Look at the bindings in your wrangler.jsonc: d1_databases means D1, r2_buckets means R2, kv_namespaces means KV.

Workers Scripts covers deploys, secrets, Durable Objects and logs. If a deploy later fails with an error about a missing permission (Queues, Containers, Images), add just that one.

Permission (type → item → level)When
Account → Workers Scripts → Editalways
Account → Account Settings → Readalways
Account → D1 → Editd1_databases in the config
Account → Workers R2 Storage → Editr2_buckets (also the OpenNext incremental cache)
Account → Workers KV Storage → Editkv_namespaces
Zone → Workers Routes → Editonly with routes in the config, and limit Zone Resources to that one zone

Step 3: Limit the token to one account

Only in the user token form: under Account Resources keep Include and replace All accounts with the project's account. This is the most important field of the whole form. The account token form does not have it, because the token is bound to the account.

Step 4: Allow the token only from your IP address

Under Client IP Address Filtering keep Allow and enter your public address. A leaked token is then useless anywhere else. Find your addresses with:

Terminal
# your public IPv4 address
curl -4 -s https://ifconfig.me; echo

# your public IPv6 address (no output = you have none, skip it)
curl -6 -s --max-time 5 https://ifconfig.me; echo

If you also have IPv6, add it with Add more (the address, or its /64 range). Otherwise Wrangler may connect over IPv6 and get a 403. You can edit the list later, e.g. when your provider changes your address.

Step 5: Set the expiry, create the token and copy it

TTL is optional; a year is a good default, then you roll the token. Click Continue to summary → Create Token and copy the value. Do not send it in chat or email. It goes straight into the project in the next chapter. Copy the Account ID too: on Account home click the dots next to the account name and choose Copy account ID. The Account ID is not a secret.

04 · Project

Set up the project

Five small changes in the project. All of them are files you commit, except .env.deploy, which stays only on your computer.

Step 1: Write the Account ID into wrangler.jsonc

Add "account_id" right below "name". From now on every deploy and wrangler command of this project targets this account only. If the project has more Wrangler configs (e.g. a separate static Worker), add it to each of them.

wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-app",
  // Locked to one account: a deploy or any wrangler command cannot land in another account.
  // The API token lives in .env.deploy (see package.json scripts "deploy" and "wr").
  "account_id": "<ACCOUNT_ID>",
  // ... the rest of your config stays as it is
}

Step 2: Put the token into .env.deploy

Create the file in the project root and paste the token. Next.js, OpenNext and Vite load only .env, .env.local, .env.<mode> and .env.<mode>.local, so this file never gets into a build.

.env.deploy
# Only for deploys and the wrangler CLI. Next.js, OpenNext and Vite never read this file.
CLOUDFLARE_API_TOKEN="<API_TOKEN>"
Terminal
# only you can read the file
chmod 600 .env.deploy

# must print a .gitignore rule; if it prints nothing, add one
git check-ignore -v .env.deploy || echo '.env*' >> .gitignore
Terminal
# Docker only: keep all env files out of the image
echo '.env*' >> .dockerignore

Step 3: Remove Cloudflare variables from all other env files

Delete the lines CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID from .env, .env.local, .env.production and similar. The account is in wrangler.jsonc now, so there is no second source of truth. The first command below must print nothing.

Terminal
# Which env files still contain Cloudflare variables? (prints names, never values)
grep -l 'CLOUDFLARE_' .env .env.* .dev.vars 2>/dev/null | grep -v '^.env.deploy$'

# Show the variable names in .env without their values
sed 's/=.*/=<redacted>/' .env

Step 4: Let the npm scripts load the token

Each script starts its own bash, loads .env.deploy into it and runs one command. The token does not stay in your terminal, and OpenNext does not write values from the shell into the build. Replace the matching scripts in package.json:

package.json (Next.js on OpenNext)
"scripts": {
  "dev":     "bash -c 'set -a; . ./.env.deploy; set +a; exec next dev'",
  "cfbuild": "bash -c 'set -a; . ./.env.deploy; set +a; exec opennextjs-cloudflare build'",
  "preview": "bash -c 'set -a; . ./.env.deploy; set +a; opennextjs-cloudflare build && exec opennextjs-cloudflare preview'",
  "deploy":  "bash -c 'set -a; . ./.env.deploy; set +a; opennextjs-cloudflare build && scripts/keep-old-chunks.sh merge && opennextjs-cloudflare deploy && scripts/keep-old-chunks.sh save'",
  "wr":      "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler \"$@\"' wr"
}
package.json (other Workers)
"scripts": {
  "dev":    "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler dev'",
  "deploy": "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler deploy'",
  "wr":     "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler \"$@\"' wr"
}

dev needs the token only when a binding has "remote": true (it then reads production data). The script scripts/keep-old-chunks.sh used by deploy is in chapter 06.

Step 5: Use npm run -s wr -- instead of npx wrangler

Everything after -- goes to Wrangler. Replace npx wrangler in your README, docs and scripts as well, so nobody runs a command without the token.

Terminal
# instead of:  npx wrangler <command>
npm run -s wr -- whoami
npm run -s wr -- tail
npm run -s wr -- d1 execute <DB_NAME> --remote --command "SELECT 1"
npm run -s wr -- r2 object get <BUCKET>/<KEY> --remote

Why not Wrangler's own --env-file? It works only after the command name (wrangler whoami --env-file=…), in front of it Wrangler prints just the help. The wrapper is simpler and the same for every command.

Secrets the app needs at runtime

API keys your server code reads while it runs (payments, e-mail, maps on the server) do not belong in .env either. Store them as Worker secrets. OpenNext copies them into process.env at runtime, so the code stays the same. Note that secret put creates a new version of the Worker in production.

Terminal
# Runtime secrets of the app (third-party API keys etc.), never in .env.
# The value is read from .env and piped in, so it never shows up on screen or in the shell history.
grep '^MY_SECRET=' .env | cut -d= -f2- | npm run -s wr -- secret put MY_SECRET

# check (prints names only), then delete the line from .env
npm run -s wr -- secret list

05 · Verify

Check before the first deploy

Three checks before the first deploy. Each one takes a few seconds and tells you exactly what is wrong if it fails.

Step 1: Wrangler sees the token and the right account

Terminal
npm run -s wr -- whoami
# → "You are logged in with an Account API Token" (or "User API Token")
#   and the account you expect

If you see a different account or an error, check the token value in .env.deploy and that you run the command in the project root.

Step 2: Without the wrapper, Wrangler is not signed in

Terminal
npx wrangler whoami
# → "You are not authenticated. Please run `wrangler login`."
#   That is correct: without the wrapper there is no token. Do NOT run wrangler login.

If it says you are logged in, either an old OAuth session is active (run npx wrangler logout) or the token is still in .env or in your shell (open a new terminal and go back to step 3 of chapter 04).

Step 3: The build output contains no token

Build locally (this deploys nothing) and search the output. Both checks print only names, never the token itself.

Terminal
npm run cfbuild

# OpenNext: no Cloudflare variable in the Worker bundle (must print 0)
grep -c CLOUDFLARE_ .open-next/cloudflare/next-env.mjs

# Any project: no value from .env.deploy anywhere in the build output.
# Prints file names only, never the values. Empty output = OK.
while IFS='=' read -r k v; do v=${v//\"/}; [ -n "$v" ] && [ "${k#\#}" = "$k" ] && \
  grep -rlF -- "$v" .open-next .next dist build 2>/dev/null | sed "s|^|$k found in: |"; done < .env.deploy

Something found? A Cloudflare variable is still in one of the .env files the build reads. Remove it and build again.

06 · Deploy

Deploy and keep the old chunks

Every Next.js build gives its JS and CSS files new names. Pages that visitors already have open, or have in the browser cache, still ask for the old names. If the new deploy does not contain them, the page breaks. The fix is simple: keep an archive of old chunks and add it to every deploy.

A page opened before the deploy (version 1) asks for its own chunk a1.js. After a plain deploy of version 2 the file is gone: 404 and ChunkLoadError. With the archive merged in, version 2 also contains the old chunks and the page keeps working.Open tabpage loaded from v1GET a1.jsDeploy v2 without the archivea2.jsb2.jsa1.js✕ 404 → ChunkLoadErrorDeploy v2 + archived chunksa2.jsb2.jsa1.jsb1.jsfrom the archive ↓✓ 200 → page keeps working
A tab opened before the deploy asks for its own chunks. Only the deploy with the archive still has them.

When it hurts

  • a visitor has the page open and clicks a link (client-side navigation loads new chunks of the old version),
  • the browser shows a cached copy of the HTML (on this site up to 10 minutes),
  • an edge or CDN cache keeps HTML across deploys (Workers Cache with cross_version_cache).

The visitor sees a blank or frozen page and the console shows ChunkLoadError or a 404 for /_next/static/…. Chunk names contain a content hash, so old and new files never collide and it is safe to keep both.

Step 1: Add the script that keeps old chunks

Save it as scripts/keep-old-chunks.sh. merge copies archived chunks into the fresh build (it never overwrites a new file), save adds the deployed chunks to the archive. The archive lives outside the project, in ~/next-deployments-old-assets/<project folder>, so a clean checkout or rm -rf .open-next cannot delete it.

scripts/keep-old-chunks.sh
#!/usr/bin/env bash
# Keeps the JS/CSS chunks of earlier builds available after a deploy.
#   merge  before the deploy: copy archived chunks into the fresh build (new files are never overwritten)
#   save   after a successful deploy: add the deployed chunks to the archive
# Archive: $OLD_ASSETS_DIR, default ~/next-deployments-old-assets/<project folder name>
set -euo pipefail

BUILD=".open-next/assets/_next/static"
ARCHIVE="${OLD_ASSETS_DIR:-$HOME/next-deployments-old-assets/$(basename "$PWD")}/$BUILD"

# Copy files from $1 to $2 that do not exist in $2 yet; the count goes to COPIED.
# Paths keep the leading "./" (build folders may start with "-").
COPIED=0
copy_missing() {
  local src=$1 dst=$2 f
  COPIED=0
  while IFS= read -r -d '' f; do
    if [ ! -e "$dst/$f" ]; then
      mkdir -p -- "$(dirname -- "$dst/$f")"
      cp -p -- "$src/$f" "$dst/$f"
      COPIED=$((COPIED + 1))
    fi
  done < <(cd "$src" && find . -type f -print0)
}

# Delete Windows "Zone.Identifier" files (created when files are copied through Windows Explorer into WSL).
remove_zone_ids() {
  local dir n
  for dir in "$@"; do
    [ -d "$dir" ] || continue
    n=$(find "$dir" -type f -name '*:Zone.Identifier' -print -delete | wc -l)
    [ "$n" -gt 0 ] && echo "keep-old-chunks: deleted $n Zone.Identifier files in $dir"
  done
  return 0
}

[ -d "$BUILD" ] || { echo "keep-old-chunks: $BUILD not found, run the build first" >&2; exit 1; }
remove_zone_ids "$ARCHIVE" "$BUILD"

case "${1:-}" in
  merge)
    if [ -d "$ARCHIVE" ]; then
      copy_missing "$ARCHIVE" "$BUILD"
      echo "keep-old-chunks: added $COPIED old files from $ARCHIVE"
    else
      echo "keep-old-chunks: no archive yet ($ARCHIVE), it starts with this deploy"
    fi
    ;;
  save)
    mkdir -p "$ARCHIVE"
    copy_missing "$BUILD" "$ARCHIVE"
    echo "keep-old-chunks: archived $COPIED new files to $ARCHIVE"
    ;;
  *)
    echo "usage: $0 merge|save" >&2
    exit 2
    ;;
esac
Terminal
chmod +x scripts/keep-old-chunks.sh

Another location? Set OLD_ASSETS_DIR. The script deletes Windows Zone.Identifier files from the archive and the build, they appear when you copy files through Windows Explorer into WSL.

Step 2: Deploy

The deploy script from chapter 04 already calls both steps. The archive is updated only after a successful deploy, so a failed deploy never puts files that never went live into it.

Terminal
npm run deploy
# 1. build  2. add old chunks  3. deploy  4. archive the deployed chunks
# Step 4 runs only when the deploy succeeded.

The first deploy prints no archive yet, which is fine. From the second one on you see added N old files. The archive grows by a few hundred kilobytes per deploy. If it ever gets large, delete the oldest build folders in it (older than a few weeks nobody needs them).

Rollback

Static files are part of each Worker version, so a rollback brings back that version's files too. No rebuild is needed.

Terminal
npm run -s wr -- deployments list   # recent versions
npm run -s wr -- rollback           # back to the previous version

Deploying from CI

In GitHub Actions or GitLab, store the token as a CI secret and pass it only to the steps that need it. Never write a .env file from CI secrets before the build, that brings back the original problem. The chunk archive has to survive between runs (e.g. actions/cache). GitHub-hosted runners have changing IP addresses, so the IP filter does not work there: use a separate CI token with a short TTL, or a self-hosted runner with a fixed address.

.github/workflows/deploy.yml (steps)
# GitHub Actions: the token goes only to the steps that need it, never into a .env file
- run: npm ci
- run: npx opennextjs-cloudflare build
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}   # only if bindings use "remote": true
- run: scripts/keep-old-chunks.sh merge
  env:
    OLD_ASSETS_DIR: ${{ runner.temp }}/old-assets             # restore it with actions/cache
- run: npx opennextjs-cloudflare deploy
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- run: scripts/keep-old-chunks.sh save
  env:
    OLD_ASSETS_DIR: ${{ runner.temp }}/old-assets

07 · Migration

Moving from a token in .env

If the token has been in .env until now, it is built into the Worker that runs in production right now. Go in this order, so the site keeps running and the old token stops working only when nothing needs it anymore.

  1. Create a new token (chapter 03).
  2. Set up the project: account_id, .env.deploy with the new token, clean .env, scripts (chapter 04).
  3. Verify the account and the build (chapter 05).
  4. Deploy (npm run deploy). The Worker running now has no token inside.
  5. Only now, delete the old token in the dashboard. Until the deploy it was still in production and valid.
  6. Revoke the OAuth access, if you ever used wrangler login:
Terminal
npx wrangler logout
# then in the dashboard: My Profile → Access Management → Connected Applications → revoke Wrangler
Was the token ever in git?

Check with git log --all --oneline -- .env. If it prints anything, the token is in the repository history. Deleting the token in the dashboard (step 5) is what protects you; rewriting the history is optional.

08 · Troubleshooting

When something does not work

The most common problems and what to do about them, roughly in the order you may meet them.

What you seeCauseWhat to do
An OAuth window: Wrangler wants to access your accounta command runs without the token (npx wrangler … directly)click Cancel, run it again through npm run -s wr --
You are not authenticatedthe same: no token in the environmentuse the npm script; do not run wrangler login
403 / Authentication erroryour IP address changed, you connect over IPv6, or a permission is missingcheck your IP (step 4) and the token's IP list, then the permissions
.env.deploy: No such file or directorythe command is not run in the project rootcd into the folder with package.json
Wrangler prints only its help--env-file placed before the command nameuse the wrapper, or put the flag after the command
Build fails reading D1 / R2a binding has "remote": true and the build runs without the tokenbuild with npm run cfbuild, not with npx opennextjs-cloudflare build
ChunkLoadError after a deploythe deploy did not contain the old chunkscheck the archive path in the deploy output (chapter 06)
bash: command not found on Windowsnpm runs scripts in cmd or PowerShellwork in WSL (Ubuntu), or set npm's script-shell to Git Bash
Key takeaways
  • One project = one token, one account in account_id, the token in .env.deploy.
  • Never a Cloudflare token in .env and never wrangler login. An OAuth window means a command runs without the token: Cancel.
  • Check the build output before the first deploy. After switching, delete the old token only once the new deploy is live.
  • Keep the old _next/static chunks in an archive outside the project and add them to every deploy.

Sources

Official documentation and further reading

Deploying to several Cloudflare accounts?

We set up tokens, deploys and the whole Cloudflare stack so that nothing secret ends up in production code.

Contact Us