- Vrealmatic
- Cloudflare
- Secure deploy
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.
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
- One token per project - an account token with only the permissions the project uses, limited to your IP address
- Lock the account -
"account_id"inwrangler.jsonc, so a deploy cannot land in another account - Keep the token out of the build - the token lives in
.env.deploy, which no framework reads; never in.env - Load it only for one command - npm scripts load the file in their own bash;
npm run -s wr --replacesnpx wrangler - 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?
| Question | Where 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 → |
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:
| Project | How the token gets into the deployed code |
|---|---|
| Next.js on OpenNext | the 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 Containers | COPY . . without .env* in .dockerignore copies the file into the image |
| Express or another Worker | dotenv at build time or esbuild define can inline values into the bundle |
| Any framework | variables with a public prefix (NEXT_PUBLIC_, VITE_) end up in the JavaScript sent to the browser |
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.jsonorwrangler.tomlin 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 project | Project type | What to follow |
|---|---|---|
@opennextjs/cloudflare in package.json | Next.js on OpenNext | everything, including the old chunks in chapter 06 |
wrangler.jsonc with "main", no Next.js | a Worker (Express, Hono…) | everything except chapter 06; use the shorter scripts |
Dockerfile + containers in the config | Cloudflare Containers | everything, plus .env* in .dockerignore |
| Express on your own server (no Wrangler) | not a Worker | the 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 → Edit | always |
| Account → Account Settings → Read | always |
| Account → D1 → Edit | d1_databases in the config |
| Account → Workers R2 Storage → Edit | r2_buckets (also the OpenNext incremental cache) |
| Account → Workers KV Storage → Edit | kv_namespaces |
| Zone → Workers Routes → Edit | only 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:
# 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; echoIf 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.
{
"$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.
# Only for deploys and the wrangler CLI. Next.js, OpenNext and Vite never read this file.
CLOUDFLARE_API_TOKEN="<API_TOKEN>"# 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# Docker only: keep all env files out of the image
echo '.env*' >> .dockerignoreStep 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.
# 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>/' .envStep 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:
"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"
}"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.
# 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> --remoteWhy 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.
# 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 list05 · 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
npm run -s wr -- whoami
# → "You are logged in with an Account API Token" (or "User API Token")
# and the account you expectIf 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
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.
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.deploySomething 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.
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.
#!/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
;;
esacchmod +x scripts/keep-old-chunks.shAnother 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.
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.
npm run -s wr -- deployments list # recent versions
npm run -s wr -- rollback # back to the previous versionDeploying 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 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-assets07 · 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.
- Create a new token (chapter 03).
- Set up the project:
account_id,.env.deploywith the new token, clean.env, scripts (chapter 04). - Verify the account and the build (chapter 05).
- Deploy (
npm run deploy). The Worker running now has no token inside. - Only now, delete the old token in the dashboard. Until the deploy it was still in production and valid.
- Revoke the OAuth access, if you ever used
wrangler login:
npx wrangler logout
# then in the dashboard: My Profile → Access Management → Connected Applications → revoke WranglerCheck 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 see | Cause | What to do |
|---|---|---|
| An OAuth window: Wrangler wants to access your account | a command runs without the token (npx wrangler … directly) | click Cancel, run it again through npm run -s wr -- |
| You are not authenticated | the same: no token in the environment | use the npm script; do not run wrangler login |
| 403 / Authentication error | your IP address changed, you connect over IPv6, or a permission is missing | check your IP (step 4) and the token's IP list, then the permissions |
.env.deploy: No such file or directory | the command is not run in the project root | cd into the folder with package.json |
| Wrangler prints only its help | --env-file placed before the command name | use the wrapper, or put the flag after the command |
| Build fails reading D1 / R2 | a binding has "remote": true and the build runs without the token | build with npm run cfbuild, not with npx opennextjs-cloudflare build |
ChunkLoadError after a deploy | the deploy did not contain the old chunks | check the archive path in the deploy output (chapter 06) |
bash: command not found on Windows | npm runs scripts in cmd or PowerShell | work in WSL (Ubuntu), or set npm's script-shell to Git Bash |
- 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
This guide is based on the Cloudflare and OpenNext documentation and on our production setup:
- Account API tokensAccount-owned tokens and how they differ from user tokens.
- Create an API tokenPermissions, resources, IP filtering and TTL.
- Wrangler environment variablesCLOUDFLARE_API_TOKEN and other variables Wrangler reads.
- Wrangler configurationaccount_id, bindings and remote bindings.
- SecretsRuntime secrets of a Worker.
- OpenNext: environment variablesWhich .env files OpenNext reads and how they reach the Worker.
Related
- Cloudflare wikiAPI tokens, R2 from the command line, limits of the plans.
- Next.js on CloudflareStatic build, OpenNext worker, D1, R2 and incremental cache.
- Cloudflare Workers CacheEdge cache in front of the Worker, purge and deploys.
How to cite this publication (APA 7)Nepor, V. (2026, October 23). Secure Cloudflare Deploy with an API Token (Wrangler, OpenNext). Vrealmatic. https://vrealmatic.com/cloudflare/deploy
Deploying to several Cloudflare accounts?
We set up tokens, deploys and the whole Cloudflare stack so that nothing secret ends up in production code.