Configuration & Secrets — Compile vs. Deploy
Tasuku reads configuration from four places. They trip people up because two of them are consumed at compile time (baked into immutable artifacts) and two at deploy/run time (read live by the Worker). Setting a value in the wrong one fails silently — the build succeeds, the deploy succeeds, and the value is just… wrong.
This page explains what each file is, when it is read, by which component, and where to put a new value.
The one rule that explains everything
Section titled “The one rule that explains everything”
PUBLIC_*values are frozen into the JavaScript bundle and the prerendered/appshell at build time. Everything the server reads (env.X) is resolved at run time fromwrangler.jsonc+ the secret store.
So a client-visible value (PUBLIC_CLERK_PUBLISHABLE_KEY, PUBLIC_SENTRY_DSN,
PUBLIC_VAPID_KEY) can only be changed by rebuilding. A server value
(CLERK_SECRET_KEY, VAPID_PRIVATE_KEY, SENTRY_DSN, the D1/KV/DO bindings) is changed
by editing wrangler.jsonc or running wrangler secret put and re-deploying — no
rebuild needed.
At a glance
Section titled “At a glance”| File | Read at | By which tool | Holds | Committed? |
|---|---|---|---|---|
.env | build | Vite / Astro | Default PUBLIC_* client vars (e.g. PUBLIC_VAPID_KEY) | gitignored |
.dev.vars | build and local run (wrangler dev) | @astrojs/cloudflare → process.env; wrangler dev runtime | Local Clerk dev keys, PUBLIC_* overrides, E2E creds, Sentry build creds | gitignored |
.dev.vars.staging | build (staging only) | scripts/deploy-staging.mjs swaps it in as .dev.vars | Staging PUBLIC_* values baked into the staging build | gitignored |
wrangler.jsonc | deploy / run | wrangler + the live Worker | Bindings (D1/KV/DO/email), non-secret vars, cron triggers, routes | committed |
| secret store | run | the live Worker (env.X) | Server secrets: CLERK_SECRET_KEY, VAPID_PRIVATE_KEY, … | n/a (wrangler secret put) |
Only the *.example templates and wrangler.jsonc are in git. Never commit a real
.dev.vars* or .env.
Compile time — what gets baked
Section titled “Compile time — what gets baked”npm run build (and deploy:staging, which runs the build first) produces three
immutable outputs: the client JS bundle, the prerendered /app shell, and the Worker
bundle. The PUBLIC_* values are inlined into the first two and can never change without a
rebuild.
flowchart TD
ENV[".env<br/>(PUBLIC_* defaults)"]
DV[".dev.vars<br/>(or .dev.vars.staging,<br/>swapped in by deploy:staging)"]
CFENV["CLOUDFLARE_ENV<br/>(env var: staging?)"]
SENTRY["SENTRY_AUTH_TOKEN / ORG / PROJECT<br/>(from active .dev.vars)"]
subgraph build["npm run build (Vite + @astrojs/cloudflare)"]
VITE["Vite inlines import.meta.env.PUBLIC_*<br/>(.dev.vars WINS over .env)"]
ADAPTER["@astrojs/cloudflare<br/>copies .dev.vars → process.env<br/>+ generates dist/.../wrangler.json"]
MAPS["sentry-vite-plugin<br/>uploads source maps (or no-op)"]
end
ENV --> VITE
DV --> VITE
DV --> ADAPTER
CFENV --> ADAPTER
SENTRY --> MAPS
VITE --> CLIENT["dist/client/*.js<br/>+ prerendered /app shell<br/>(PUBLIC_* values FROZEN here)"]
ADAPTER --> WJSON["dist/server/wrangler.json<br/>(which env wrangler will deploy)"]
style CLIENT fill:#e8f5e9
style WJSON fill:#fff3e0
Key points:
.dev.varswins over.env. During the build,@astrojs/cloudflarecopies.dev.varsintoprocess.env, and those override.env. That is the entire reasondeploy:stagingexists — see below.CLOUDFLARE_ENVselects the target env at build time, notwrangler deploy --env.@astrojs/cloudflarev13 readsCLOUDFLARE_ENVand writes the chosen env section into the generateddist/server/wrangler.json. A build without it bakes the top-level (prod) config and deploys thetasukuWorker — even if you later say--env staging.- Source-map upload is opt-in.
SENTRY_AUTH_TOKEN(read from the active.dev.vars) turns on hidden source maps + upload; absent, the build is byte-identical and uploads nothing.
Why deploy:staging swaps .dev.vars
Section titled “Why deploy:staging swaps .dev.vars”Clerk bakes PUBLIC_CLERK_PUBLISHABLE_KEY into the prerendered /app shell at build
time. Because .dev.vars wins over everything, a normal staging build would bake your
local dev key into /app — producing the “development keys” banner and a sign-out
that bounces back. scripts/deploy-staging.mjs therefore copies .dev.vars.staging over
.dev.vars for the build only, then always restores .dev.vars afterwards:
.dev.vars.staging ──(deploy:staging copies it to)──▶ .dev.vars ──▶ build bakes staging PUBLIC_* into /app (restored afterwards, even on failure)Deploy / run time — what the Worker reads
Section titled “Deploy / run time — what the Worker reads”wrangler deploy uploads the Worker bundle and reads wrangler.jsonc for bindings,
vars, cron triggers, and routes. Secrets come from the secret store, never a file.
At run time the Worker reads them all through its env parameter.
flowchart TD
WJSONC["wrangler.jsonc<br/>bindings + vars + cron + routes<br/>(env.staging restates non-inheritable bits)"]
SECRETS["secret store<br/>wrangler secret put [--env staging]"]
WJSONC --> DEPLOY["wrangler deploy"]
SECRETS --> RUNTIME
DEPLOY --> WORKER["Live Worker"]
WORKER --> RUNTIME["Worker code reads env.X<br/>(vars + bindings + secrets)"]
CLIENTART["Prerendered /app + client JS<br/>(PUBLIC_* baked at build)"] --> BROWSER["Browser reads import.meta.env.PUBLIC_*"]
style CLIENTART fill:#e8f5e9
style RUNTIME fill:#e3f2fd
Key points:
.dev.varsis never uploaded. It is read only bywrangler devfor the local runtime. Staging/prod usewrangler.jsoncvars+ the secret store.varsvs. secrets. Non-secret runtime values (ENVIRONMENT,VAPID_PUBLIC_KEY,VAPID_SUBJECT,SENTRY_DSN,SUPPORT_FROM_ADDRESS) live inwrangler.jsoncvarsand are visible in git. Secrets (CLERK_SECRET_KEY,VAPID_PRIVATE_KEY) go throughwrangler secret putand never touch a committed file.- Named envs do not inherit.
env.stagingdoes not inherit top-level bindings, cron triggers, or observability — each must be restated underenv.staging. Forgetting this is the classic “works in prod, missing in staging” bug.
Where do I set a new value?
Section titled “Where do I set a new value?”flowchart TD
Q1{"Does the BROWSER<br/>need to read it?"}
Q1 -- "Yes (PUBLIC_*)" --> Q2{"Is it different<br/>on staging?"}
Q1 -- "No (server only)" --> Q3{"Is it a secret?"}
Q2 -- "No / same everywhere" --> A1[".env<br/>(rebuild to apply)"]
Q2 -- "Yes" --> A2[".dev.vars (local) +<br/>.dev.vars.staging (staging build)"]
Q3 -- "Yes" --> A3["wrangler secret put [--env staging]<br/>(re-deploy, no rebuild)"]
Q3 -- "No" --> A4["wrangler.jsonc vars<br/>(also under env.staging)"]
style A1 fill:#e8f5e9
style A2 fill:#e8f5e9
style A3 fill:#e3f2fd
style A4 fill:#e3f2fd
Quick reference:
| I want to set… | Put it in | Apply with |
|---|---|---|
| A client/browser value, same in all envs | .env (PUBLIC_X) | rebuild |
| A client value that differs on staging | .dev.vars (local) + .dev.vars.staging | rebuild / deploy:staging |
| A non-secret server value | wrangler.jsonc vars (+ env.staging.vars) | wrangler deploy |
| A server secret | wrangler secret put NAME [--env staging] | wrangler deploy |
| A local-only dev secret | .dev.vars | wrangler dev (auto) |
Worked examples
Section titled “Worked examples”1. Add a new client-visible flag (PUBLIC_FEATURE_X)
Section titled “1. Add a new client-visible flag (PUBLIC_FEATURE_X)”It is read in the browser via import.meta.env.PUBLIC_FEATURE_X, so it is build-time.
# .env (default for local + prod builds)PUBLIC_FEATURE_X=onIf staging needs a different value, also add it to both .dev.vars (local) and
.dev.vars.staging (so the staging build bakes it). Then rebuild — a wrangler secret put PUBLIC_FEATURE_X would do nothing, because the browser never reads env.
2. Add a new server secret (SOME_API_KEY)
Section titled “2. Add a new server secret (SOME_API_KEY)”Read in Worker code as env.SOME_API_KEY, so it is run-time.
# Local: add to .dev.vars (wrangler dev reads it)echo 'SOME_API_KEY=sk_test_…' >> .dev.vars
# Staging + prod: secret store, never a filenpx wrangler secret put SOME_API_KEY --env stagingnpx wrangler secret put SOME_API_KEYAdd the binding’s type to src/env.d.ts so the Worker is typed. No rebuild needed for
staging/prod — just wrangler deploy.
3. Add a non-secret staging runtime var
Section titled “3. Add a non-secret staging runtime var”{ "vars": { "ENVIRONMENT": "production", "FEATURE_TIER": "full" }, "env": { "staging": { // env.staging does NOT inherit top-level vars — restate it "vars": { "ENVIRONMENT": "staging", "FEATURE_TIER": "full" } } }}Then npm run deploy:staging. No rebuild required (it is read live by the Worker).
The four footguns, in one place
Section titled “The four footguns, in one place”- A
PUBLIC_*change didn’t take effect. It is baked at build — you must rebuild, notwrangler secret put. - Staging shows dev/prod values. Either you forgot
CLOUDFLARE_ENV=staging(build baked prod config) or you forgot to restate a binding/var underenv.staging(named envs don’t inherit). Usenpm run deploy:staging, which setsCLOUDFLARE_ENVfor you. .dev.varssilently overrode.env. During the build,.dev.varswins. If a value looks wrong, check.dev.varsfirst.- The same value lives in two places by design. A public key the client bakes
(
PUBLIC_VAPID_KEY) and the server var it pairs with (VAPID_PUBLIC_KEYinwrangler.jsonc) are different settings — update both.
See also
Section titled “See also”- Infrastructure & Deployment — the deploy targets and bindings.
- Operations & Configuration — runbooks and operational tasks.
scripts/deploy-staging.mjs— the build-env swap, commented in full.