- Vrealmatic
- Cloudflare
- Secure deploy
Secure deploy to Cloudflare
An API token for one account, kept out of the code - step by step for Next.js and Express, 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. Every command can be copied with one click.
Choose your technology
Most steps are the same for every project. Where Next.js and Express differ, the guide shows tabs like these. Your choice applies to the whole page and is remembered.
- The risk: the OpenNext build copies every value from
.envinto the Worker, so a token there runs in production. - Follow: all chapters, including old chunks in chapter 06.
- Static export (
output: "export") on Workers Static Assets: chapters 03 to 05 like a Worker, plus the old chunks.
- The risk: Wrangler does not put
.envinto the deployed bundle, butwrangler devhands every variable in it to your code, and an account variable there is easy to get wrong. - Follow: all chapters except the old chunks (unless you serve hashed JS from a frontend build).
- Containers / Docker: as a Worker, plus
.env*in.dockerignore.
The whole setup in short
- One token per project - a token for one account 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 code - the token lives in
.env.deploy, which no framework and nowrangler devreads; 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 (Next.js) 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. | 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) and on our Express Workers. 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 weak spots. It is easy to deploy into the wrong account, and the token can end up where it does not belong: in production code, or in reach of your own code and its dependencies.
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.
Where a token in .env ends up
Wrangler reads .env automatically, so putting the token there looks natural. But other tools read the file too, and what happens next depends on the technology:
| Where | How the token gets into the deployed code |
|---|---|
| OpenNext 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 | COPY . . without .env* in .dockerignore copies the file into the image |
| Browser | variables prefixed NEXT_PUBLIC_ and values in next.config → env end up in the public JavaScript |
With OpenNext a token in .env is therefore in the production Worker, readable through process.env by your code and every dependency.
A plain wrangler deploy does not put .env into the deployed bundle. The token is still not safe there:
| Where | What happens |
|---|---|
wrangler dev | turns every variable in .env into a binding of the Worker (when there is no .dev.vars). Locally your code and every dependency can read env.CLOUDFLARE_API_TOKEN. |
wrangler types | writes those variables into the Env type (worker-configuration.d.ts), so the name of the token ends up in git |
| Your own build | dotenv loaded at build time or esbuild define can inline values into the bundle, and that bundle goes to production |
| Containers / Docker | COPY . . without .env* in .dockerignore copies the file into the image |
| Frontend build | variables prefixed VITE_ (or similar) end up in the JavaScript sent to the browser |
So with an Express Worker a token in .env usually is not in production, but it is one build change away from it, and it is in reach of everything that runs under wrangler dev.
Whoever gets hold of a deploy token (through the Worker's code, its environment, its logs, a compromised dependency) can deploy, read databases and delete storage in your account. The token stays valid until you delete it in the dashboard, 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 | Tab and what to follow |
|---|---|---|
@opennextjs/cloudflare in package.json | Next.js on OpenNext | Next.js, all chapters including the old chunks |
wrangler.jsonc with "main", no Next.js | a Worker (Express, Hono…) | Workers, all chapters, old chunks only with a hashed frontend build |
"assets" without "main", a static build (Next.js output: "export", Vite) | static site | Workers. A token can reach the output only through NEXT_PUBLIC_ / VITE_, so the lock, .env.deploy and its own token are enough. Old chunks apply too (CHUNKS_DIR). |
Dockerfile + containers in the config | Cloudflare Containers | Workers, 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 |
The template is convenient, but it grants much more than a deploy needs: besides Workers Scripts also KV, R2, Tail, user details and Workers Routes on all your zones. Start from Custom token and add only the rows from the table above.
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, and wrangler dev only .dev.vars or .env. Nothing reads .env.deploy on its own.
# 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, .dev.vars and similar. 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>/' .envWhy the Account ID too, when it is no secret? For Workers commands the account_id in wrangler.jsonc wins (tested with Wrangler 4.98 and 4.149), but wrangler pages … commands prefer the variable, and wrangler dev hands it to your code as a binding. One place for the account avoids both.
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. Replace the matching scripts in package.json:
"scripts": {
"dev": "bash -c 'set -a; . ./.env.deploy; set +a; exec next dev \"$@\"' 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"
}- OpenNext does not write values from the shell environment into the build, so loading the token for
cfbuildanddeployis safe. devneeds the token only when a binding has"remote": true(it then reads production data). Without the wrapper it would open the OAuth window.scripts/keep-old-chunks.shused bydeployis in chapter 06.
"scripts": {
// your own build step, if the project has one (templates, CSS…); it needs no token
"build": "node scripts/build.mjs",
"dev": "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler dev \"$@\"' dev",
"start": "npm run dev",
"deploy": "npm run build && bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler deploy'",
"wr": "bash -c 'set -a; . ./.env.deploy; set +a; exec wrangler \"$@\"' wr",
// local only, needs no token
"cf-typegen": "wrangler types"
}- A build step of your own (templates, CSS, a bundle) runs before the wrapper and without the token. Only
wrangler deploygets it. devandstartneed the token only when a binding has"remote": true. The wrapper does not leak it into the Worker:wrangler devpasses only.dev.vars/.envto it.wrangler typesneeds no token. If an older run wroteCLOUDFLARE_*intoworker-configuration.d.ts, delete those lines by hand: regenerating the file with a newer Wrangler produces a huge diff.
# Did wrangler types put Cloudflare variables into the Env type? (must print nothing)
grep -n 'CLOUDFLARE_' worker-configuration.d.tsStep 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. In production they are Worker secrets. 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 listOpenNext copies Worker secrets into process.env at runtime, and they take precedence over values from the build. The code stays the same: process.env.MY_SECRET.
Workers read secrets from env in the handler. process.env works only with nodejs_compat and compatibility_date 2025-04-01 or later. Check how your code reads them, do not assume.
// Workers: read secrets from env in the handler
export default {
async fetch(request, env) {
const key = env.MY_SECRET;
// ...
},
};
// Express with nodejs_compat and compatibility_date 2025-04-01 or later:
// bindings and secrets are in process.env as well
const key = process.env.MY_SECRET;Local values for wrangler dev go into .dev.vars (or .env), production values into secret put. Cloudflare tokens belong in neither.
# Local values for wrangler dev (only app secrets, never Cloudflare tokens).
# With .dev.vars present, wrangler dev ignores .env.
MY_SECRET="local-test-value"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
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. A user token also prints Unable to retrieve email… User Details Read: that is only information, no permission needs to be added.
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. The 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.
# Build the bundle exactly as a deploy would, but upload nothing
# (with your own build step: npm run build first)
npm run -s wr -- deploy --dry-run --outdir /tmp/wr-check
# No value from .env.deploy in the bundle. 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" /tmp/wr-check 2>/dev/null | sed "s|^|$k found in: |"; done < .env.deployAnd locally: the bindings that wrangler dev passes to your code must not include any Cloudflare variable.
npm run dev
# The table "Your Worker has access to the following bindings" must not contain CLOUDFLARE_*.
# If it does, the variable is still in .env or .dev.vars.Something found in the bundle? Look for dotenv or define in your build script. In the bindings? The variable is still in .env or .dev.vars.
06 · Deploy
Deploy, rollback, CI and old chunks
With the scripts from chapter 04 a deploy is a single command. This chapter also covers rollback, CI and, for Next.js and static sites, how to keep pages that visitors have open working after a deploy.
Deploy
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. How the archive works is described below.
npm run deploy
# 1. your own build step (if any) 2. wrangler deploy with the token from .env.deployWrangler bundles the Worker at deploy time. A static site with hashed JS (Next.js export, Vite) should keep its old chunks too, see below.
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. 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-assetsThe chunk archive has to survive between runs, e.g. with actions/cache on OLD_ASSETS_DIR.
# GitHub Actions: the token goes only to the deploy step, never into a .env file
- run: npm ci
- run: npm run build --if-present # your own build step, no token
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Old chunks (Next.js and static sites)
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: keep an archive of old chunks and add it to every deploy. An Express Worker without a hashed frontend does not need this.
- a visitor has the page open and clicks a link (client-side navigation loads more 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>
# Chunk folder: $CHUNKS_DIR, default .open-next/assets/_next/static (OpenNext); static export: out/_next/static
set -euo pipefail
BUILD="${CHUNKS_DIR:-.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: Call it around the deploy
merge before the deploy, save only after a successful one, so files that never went live do not get into the archive. The OpenNext deploy script from chapter 04 already does it. A static Next.js export only sets CHUNKS_DIR:
# Static Next.js export (output: "export") deployed as Workers Static Assets
"deploy": "next build && CHUNKS_DIR=out/_next/static scripts/keep-old-chunks.sh merge && bash -c 'set -a; . ./.env.deploy; set +a; wrangler deploy' && CHUNKS_DIR=out/_next/static scripts/keep-old-chunks.sh save"The archive grows by a few hundred kilobytes per deploy. If it ever gets large, delete the oldest build folders in it (after a few weeks nobody needs them).
07 · Migration
Moving from a token in .env
Moving from a token in .env. Go in this order, so the site keeps running and the old token stops working only when nothing needs it anymore. Start while the old token is still in .env: the first two steps read it from there.
The OpenNext build has written it into the Worker that runs right now. Delete it only after the new deploy (step 6), until then it is still valid and readable in production.
A plain wrangler deploy does not bundle .env, so you can delete the old token as soon as the new one works. Exception: your own build with dotenv / define, or Docker without .dockerignore. Then deploy first, as with Next.js.
Step 2: Find the old token in the dashboard
An account token is under Manage Account → Account API Tokens, a user token under My Profile → API Tokens. The call below prints the token's ID and sets its Last used to now, so you recognise it in the list. Do not be surprised by the fresh date.
# Which token is it? Prints only its ID, never the token.
# The call also sets "Last used" of that token in the dashboard to now: that is how you spot it in the list.
T=$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2- | tr -d '"')
# account token (Manage Account → Account API Tokens)
curl -s -H "Authorization: Bearer $T" "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/tokens/verify" | grep -o '"id":"[^"]*"'
# user token (My Profile → API Tokens)
curl -s -H "Authorization: Bearer $T" "https://api.cloudflare.com/client/v4/user/tokens/verify" | grep -o '"id":"[^"]*"'
unset TStep 3: Create a new token
As in chapter 03, one per project.
Step 4: Set up the project
account_id, .env.deploy with the new token, clean .env, scripts (chapter 04).
Step 5: Verify the account and the build
Step 6: Deploy
npm run deploy. The Worker running now has no token inside.
Step 7: Delete the old token
In the dashboard, the token you found in step 2. With Next.js only now; with a plain Worker you could already after step 5.
Step 8: 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 7) 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 -- |
npm run dev opens the OAuth window | a binding has "remote": true and dev is not wrapped | wrap dev like the other scripts (chapter 04) |
| You are not authenticated | no token in the environment | use the npm script; do not run wrangler login |
| Unable to retrieve email… User Details Read | a user token without that permission | only information, nothing to add |
| 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 through the npm script (npm run cfbuild), not with npx … |
ChunkLoadError after a deploy | the deploy did not contain the old chunks | check the archive path in the deploy output (old chunks) |
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 (OpenNext bundles it, wrangler dev hands it to your code) and never wrangler login.
- Check the build output before the first deploy. After switching, delete the old token only when nothing needs it, with Next.js after the new deploy.
- Next.js and static sites: 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, .dev.vars and .env for local development.
- process.env in WorkersWhen bindings and secrets are available in process.env.
- 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.
- Express on Cloudflare WorkersExpress through httpServerHandler, nodejs_compat and bindings.
- 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.