跳到內容

Bindings:`env` 是整個平台的介面

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

第 2 篇教了怎麼在 wrangler.jsonc 裡設定 binding,第 3 篇說了 env 是三個 handler 參數之一。這一篇要回答為什麼它長這樣

因為如果你把 env 當成「Cloudflare 版的 process.env」,你會錯過整個設計裡最重要的東西 —— 而且會寫出比實際需要更不安全的程式碼。

三個具體問題:

  1. env.DBDATABASE_URL 差在哪? 差在前者不是字串。
  2. import { env } from "cloudflare:workers" 和 handler 參數的 env 是同一個嗎? 實測:不是同一個物件
  3. 開了 nodejs_compat 之後,你的 secret 會出現在哪裡? 答案可能會讓你想改設定。

傳統後端連資料庫長這樣:

DATABASE_URL=postgres://user:pa55w0rd@db.internal:5432/prod

這個字串裡藏了四件事:位置(主機、port)、身分(帳密)、授權(這組帳密能做什麼)、網路可達性(你的 process 得連得到 db.internal)。四件事全部混在一起,而且全部是明文、可複製、可外洩的。

Workers 的 binding 長這樣:

const { results } = await env.DB.prepare("SELECT * FROM links WHERE slug = ?")
.bind(slug)
.all();

env.DB 不是字串,是一個物件。你的程式碼裡沒有主機、沒有密碼、沒有 port、沒有連線池設定。

三個直接後果:

① 沒有 credential 可以外洩。 沒有密碼寫在設定裡、沒有密碼在記憶體裡、console.log(env.DB) 也印不出任何可以拿去別處用的東西。

② 沒有網路設定。 不需要 VPC、security group、IP 允許清單。存取權來自 Worker 的身分本身。

③ 無法從外部偽造或列舉。 binding 是部署時由平台注入的。攻擊者就算完全控制了送進來的 request,也沒辦法讓 env 多出一個原本不存在的 binding。

這是 capability-based security持有那個物件本身就是授權。你的 Worker 能做的事,精確等於你在 wrangler.jsonc 裡給它的那幾個 binding —— 不多不少。這也是為什麼第 33 篇跑使用者提供的程式碼時,「只把該給的 binding 放進 env」就是完整的沙箱策略。

順帶一提,這個設計讓「最小權限」變成預設值而不是紀律。傳統架構要達到同樣效果,得靠 IAM policy、VPC 切分、密鑰輪替三層工程;這裡就是「沒寫進設定檔就是沒有」。

要看有哪些 binding,最可靠的來源不是文件頁,是 Wrangler 自己的 JSON schema:

Terminal window
cat node_modules/wrangler/config-schema.json | jq '.properties | keys'

2026 年 7 月的完整清單(30+ 種),按用途分組:

分類設定鍵
設定與機密varssecretssecrets_store_secretsversion_metadata
儲存kv_namespacesd1_databasesr2_bucketsdurable_objectshyperdrivevectorize
訊息與工作流queuesworkflowspipelines
運算與組合servicesdispatch_namespacescontainersworker_loadersassets
AIaiai_searchai_search_namespaces
媒體imagesstreammediabrowser
可觀測性analytics_engine_datasetstail_consumersstreaming_tail_consumers
網路與安全ratelimitsmtls_certificatesvpc_servicessend_email

其中有四個很少出現在教學裡,但相當實用:

ratelimits —— runtime 內建的限流器

{
"ratelimits": [
{ "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } }
]
}
const { success } = await env.LIMITER.limit({ key: userId });
if (!success) return new Response("Too many requests", { status: 429 });

不需要 KV、不需要 DO、不需要外部服務,而且本機開發就能跑。實測 limit 5 / period 10:

Terminal window
$ for i in $(seq 7); do curl -s -o /dev/null -w "%{http_code} " localhost:8787/limit; done
200 200 200 200 200 429 429

第 41 篇會比較它和 DO-based 限流的取捨(簡述:這個是每個 colo 獨立計數的近似值,要精確全域計數才需要 DO)。

version_metadata —— 知道自己是哪個版本

{ "version_metadata": { "binding": "VERSION" } }
{ "id": "19cc8aac-1016-4c42-9f66-acc9d6283610", "tag": "", "timestamp": "2026-07-28T04:56:31.361Z" }

VERSION.id 塞進每一筆 log 和每一個錯誤回報,漸進式發佈(第 40 篇)出問題時就能立刻分辨是哪個版本在噴錯。成本是零,價值在出事那天才會顯現。

secrets_store_secrets —— 帳號層級的共用 secret

{
"secrets_store_secrets": [
{ "binding": "STRIPE_KEY", "store_id": "<store>", "secret_name": "stripe-live" }
]
}

wrangler secret put 的差別:後者是每個 Worker 一份拷貝,輪替金鑰要逐一更新;Secrets Store 是集中存放、多個 Worker 共用引用,輪替一次就好。多 Worker 的專案值得一開始就用它。

vpc_services —— 連進你自己的私有網路

{ "vpc_services": [{ "binding": "INTERNAL_API", "service_id": "<id>" }] }

用來從 Worker 打自架的內部服務。這是把 binding 的 capability 模型延伸到私有網路的做法 —— 一樣沒有 IP 允許清單要維護。

存取 binding 有兩條路:

// A. handler parameter
export default {
async fetch(request, env, ctx) { return new Response(env.APP_NAME); },
};
// B. module-level import
import { env } from "cloudflare:workers";

直覺上會以為 B 只是 A 的別名。實測:

Terminal window
$ curl -s localhost:8787/identity
{
"sameObject": false,
"topLevelKeys": ["API_KEY","APP_NAME","CACHE","LIMITER","PUBLIC_URL","VERSION"],
"handlerKeys": ["API_KEY","APP_NAME","CACHE","LIMITER","PUBLIC_URL","VERSION"]
}

鍵完全相同,但是兩個不同的物件。 所以 topLevelEnv === envfalse,任何依賴引用相等的程式碼(放進 Map 當 key、用 === 比對)都會出乎意料。

該用哪一個?

handler 參數 envimport { env } from "cloudflare:workers"
讀 vars / secrets✅(模組頂層就能讀
呼叫 binding 做 I/O⚠️ 只能在請求上下文內
深層 util function 取用要一路傳下去✅ 直接 import
測試時替換容易(傳參數)較難

實務建議:

  • 設定值(例如 env.PUBLIC_URL、feature flag)用 import 版本,省去把 env 一路傳到第五層函式的痛苦。
  • I/O(KV、D1、Queue)用 handler 參數,因為那是明確的依賴,測試時好替換。

⚠️ 再強調一次第 1 篇的規則:import 版本讓你在模組頂層讀取值,但不允許在頂層做 I/O

這個 import 也解釋了為什麼 React Router v8 能拿掉 AppLoadContext(第 24 篇)—— loader 直接 import { env } 就好,不需要框架幫忙把 env 傳進來。

三層,用途不同:

存哪可見性用途
varswrangler.jsonc(進版控)dashboard 看得到、型別檔看得到值非機密設定
secretCloudflare(wrangler secret put寫入後讀不回來該 Worker 專用的機密
Secrets Store帳號層級集中管理、可稽核多 Worker 共用的機密

本機的 secret 放 .dev.vars務必進 .gitignore)。

secrets.required:宣告式的 secret 契約

Section titled “secrets.required:宣告式的 secret 契約”

這個功能很少被提到。在 wrangler.jsonc 裡:

{ "secrets": { "required": ["API_KEY", "WEBHOOK_SIGNING_KEY"] } }

它做兩件事:

① 產生型別,而且不需要 .dev.vars 實測把 .dev.vars 移走再跑 wrangler types

interface __BaseEnv_CloudflareBindings {
CACHE: KVNamespace;
LIMITER: RateLimit;
VERSION: WorkerVersionMetadata;
APP_NAME: "ch04-bindings";
PUBLIC_URL: "https://example.com";
API_KEY: string; // ← 只靠 secrets.required 就有了
}

② 把「這個 Worker 需要哪些 secret」變成 repo 裡看得到的宣告,而不是散落在部署腳本和某人的記憶裡。新人 clone 下來看設定檔就知道要準備什麼。

⚠️ 官方文件把它描述為部署時的必要性檢查。我在沙箱裡無法驗證真實 deploy 的行為(--dry-run 不會檢查,因為那需要查詢帳號已存的 secret)。型別生成的部分我實測過,部署時的強制行為請自行驗證。

🔴 nodejs_compat 會把 secret 送進 process.env

Section titled “🔴 nodejs_compat 會把 secret 送進 process.env”

Workers 沒有 process 全域物件 —— 除非開了 nodejs_compat。實測,不開的時候:

Terminal window
$ curl -s localhost:8787/procenv
{"hasProcess":false,"appName":null,"apiKey":null}

加上 "compatibility_flags": ["nodejs_compat"] 之後:

Terminal window
$ curl -s localhost:8787/procenv
{"hasProcess":true,"appName":"ch04-bindings","apiKey":"<present>"}

注意 apiKey —— process.env 裡不只有 vars,secret 也在裡面。

好處是很多 npm 套件靠 process.env.SOMETHING 讀設定,這讓它們能直接運作。壞處是:

開了 nodejs_compat,你 bundle 裡的任何一行第三方程式碼都能讀到你所有的 secret。

一個在錯誤處理時 dump process.env 的日誌套件、一個做環境偵測的 SDK,都會把你的 API key 寫進 log。這在 Node.js 世界一直都是這樣,只是 Workers 的預設本來更安全 —— 開這個 flag 等於主動放棄那層保護。

不是叫你別開(很多場景非開不可,例如第 28 篇的 Hyperdrive)。而是:開了就要意識到 secret 的暴露面變了,並且對依賴多一分審視。

缺少的 binding 是 undefined,不是錯誤

Section titled “缺少的 binding 是 undefined,不是錯誤”
Terminal window
$ curl -s localhost:8787/missing
{"value":null}

存取一個不存在的 binding 不會拋錯。你會拿到 undefined,然後在下一行 env.NOPE.get(...) 的地方收到 TypeError: Cannot read properties of undefined

這正是第 2 篇那個「環境不繼承 binding」的坑會如此致命的原因:wrangler deploy --env staging 只給 warning,部署成功,然後在第一個真實請求時炸掉。

兩道防線:

  1. wrangler types --check 進 CI(第 2 篇)。
  2. 部署後跑 smoke test,實際打到每個會用到 binding 的路徑(第 40 篇)。

完整程式碼:examples/ch04-bindings/

Terminal window
cd examples/ch04-bindings
npm install
echo 'API_KEY="local-dev-key"' > .dev.vars
npm run cf-typegen
npm run dev
Terminal window
# 兩個 env 是不是同一個物件?
curl -s localhost:8787/identity
# process.env 存在嗎?
curl -s localhost:8787/procenv
# 我是哪個版本?
curl -s localhost:8787/version
# 限流器(第 6 次會變 429)
for i in $(seq 7); do curl -s -o /dev/null -w "%{http_code} " localhost:8787/limit; done; echo
# 不存在的 binding
curl -s localhost:8787/missing

練習:把 wrangler.jsoncnodejs_compat 那一行的註解拿掉,重跑 /procenv。看著你的 API key 出現在 process.env 裡,然後決定這個專案要不要開這個 flag。


LinkForge 有五個 Worker,每個的 binding 需求都不同。這一篇定下三個規則。

規則一:每個 Worker 只拿它需要的 binding

Section titled “規則一:每個 Worker 只拿它需要的 binding”
// apps/redirector — hot path. Deliberately minimal.
{
"kv_namespaces": [{ "binding": "LINKS", "id": "..." }],
"queues": { "producers": [{ "binding": "CLICKS", "queue": "click-events" }] },
"version_metadata": { "binding": "VERSION" },
"ratelimits": [{ "name": "ABUSE", "namespace_id": "1", "simple": { "limit": 100, "period": 60 } }]
}
// apps/api — needs the database, redirector does not.
{
"d1_databases": [{ "binding": "DB", "database_name": "linkforge", "database_id": "..." }],
"kv_namespaces": [{ "binding": "LINKS", "id": "..." }],
"durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "LinkCounter" }] },
"r2_buckets": [{ "binding": "EXPORTS", "bucket_name": "linkforge-exports" }],
"version_metadata": { "binding": "VERSION" },
"secrets": { "required": ["JWT_SECRET", "RESEND_API_KEY"] }
}

redirector 拿不到 D1。就算它被攻破,攻擊者也碰不到使用者資料表 —— 這不是靠程式碼裡的檢查,是靠它的 env 裡根本沒有那個物件。這就是 capability 模型的實際價值。

規則二:version_metadata 每個 Worker 都要,而且進每一筆 log

Section titled “規則二:version_metadata 每個 Worker 都要,而且進每一筆 log”
function log(event: Record<string, unknown>, env: CloudflareBindings) {
console.log(JSON.stringify({ ...event, v: env.VERSION.id, ts: Date.now() }));
}

第 39、40 篇會用到。現在多寫這一行,之後漸進式發佈出事時就能一眼分辨版本。

規則三:secret 用 secrets.required 宣告,不要只存在部署腳本裡

Section titled “規則三:secret 用 secrets.required 宣告,不要只存在部署腳本裡”

每個 app 的 wrangler.jsonc 都列出它需要的 secret。這份清單同時是文件、是型別、是新人的 checklist。

dev(本機)/ staging / production 三個。每個環境的每個 binding 都要完整重寫(第 2 篇的坑)。第 26 篇會談用腳本生成設定來消除這個重複。

本篇交付物:五個 app 的完整 wrangler.jsonc binding 規劃、共用的 log() helper、secrets.required 清單。


① 把 secret 寫進 vars

vars 會出現在 dashboard,而且會以 literal type 出現在生成的型別檔裡(連值都在)。secret 一律走 wrangler secret put / .dev.vars / Secrets Store。

② 開了 nodejs_compat 卻沒意識到 secret 進了 process.env

bundle 裡任何第三方程式碼都讀得到。

③ 以為兩個 env 是同一個物件

鍵相同,但 ===false

④ 期待缺少的 binding 會報錯

它是 undefined。搭配第 2 篇的「環境不繼承 binding」,這是 staging 環境最常見的爆炸原因。

⑤ 在模組頂層用 binding 做 I/O

import { env } from "cloudflare:workers";
const config = await env.CACHE.get("config"); // ❌ Disallowed in global scope

讀 vars/secrets 可以,I/O 不行。

⑥ 一份 Env 介面給所有 Worker 共用

binding 是 per-Worker 的。共用一份介面等於騙過型別系統,然後在 runtime 拿到 undefined。每個 Worker 跑自己的 wrangler types

⑦ 用 Service Environments

已棄用。用 Wrangler Environments(env.<name>)。


  1. **binding 是能力不是連線字串。**沒有 credential、沒有網路設定、無法從外部偽造 —— 最小權限是預設值,不是紀律。
  2. **nodejs_compat 會把 secret 放進 process.env。**bundle 裡每一行第三方程式碼都讀得到。
  3. **缺少的 binding 是 undefined 而不是錯誤。**這就是為什麼型別檢查要進 CI、smoke test 要打到每個 binding。


下一篇05. 用 Hono 打好 API 骨架 —— routing、middleware、型別安全驗證,以及 Hono 的 Env 和 Wrangler 的 Env 撞名該怎麼解。