Skip to content

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.

PUBLIC_* values are frozen into the JavaScript bundle and the prerendered /app shell at build time. Everything the server reads (env.X) is resolved at run time from wrangler.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.

FileRead atBy which toolHoldsCommitted?
.envbuildVite / AstroDefault PUBLIC_* client vars (e.g. PUBLIC_VAPID_KEY)gitignored
.dev.varsbuild and local run (wrangler dev)@astrojs/cloudflareprocess.env; wrangler dev runtimeLocal Clerk dev keys, PUBLIC_* overrides, E2E creds, Sentry build credsgitignored
.dev.vars.stagingbuild (staging only)scripts/deploy-staging.mjs swaps it in as .dev.varsStaging PUBLIC_* values baked into the staging buildgitignored
wrangler.jsoncdeploy / runwrangler + the live WorkerBindings (D1/KV/DO/email), non-secret vars, cron triggers, routescommitted
secret storerunthe 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.

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.vars wins over .env. During the build, @astrojs/cloudflare copies .dev.vars into process.env, and those override .env. That is the entire reason deploy:staging exists — see below.
  • CLOUDFLARE_ENV selects the target env at build time, not wrangler deploy --env. @astrojs/cloudflare v13 reads CLOUDFLARE_ENV and writes the chosen env section into the generated dist/server/wrangler.json. A build without it bakes the top-level (prod) config and deploys the tasuku Worker — 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.

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.vars is never uploaded. It is read only by wrangler dev for the local runtime. Staging/prod use wrangler.jsonc vars + the secret store.
  • vars vs. secrets. Non-secret runtime values (ENVIRONMENT, VAPID_PUBLIC_KEY, VAPID_SUBJECT, SENTRY_DSN, SUPPORT_FROM_ADDRESS) live in wrangler.jsonc vars and are visible in git. Secrets (CLERK_SECRET_KEY, VAPID_PRIVATE_KEY) go through wrangler secret put and never touch a committed file.
  • Named envs do not inherit. env.staging does not inherit top-level bindings, cron triggers, or observability — each must be restated under env.staging. Forgetting this is the classic “works in prod, missing in staging” bug.
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 inApply 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.stagingrebuild / deploy:staging
A non-secret server valuewrangler.jsonc vars (+ env.staging.vars)wrangler deploy
A server secretwrangler secret put NAME [--env staging]wrangler deploy
A local-only dev secret.dev.varswrangler dev (auto)

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.

Terminal window
# .env (default for local + prod builds)
PUBLIC_FEATURE_X=on

If 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.

Read in Worker code as env.SOME_API_KEY, so it is run-time.

Terminal window
# Local: add to .dev.vars (wrangler dev reads it)
echo 'SOME_API_KEY=sk_test_…' >> .dev.vars
# Staging + prod: secret store, never a file
npx wrangler secret put SOME_API_KEY --env staging
npx wrangler secret put SOME_API_KEY

Add the binding’s type to src/env.d.ts so the Worker is typed. No rebuild needed for staging/prod — just wrangler deploy.

wrangler.jsonc
{
"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).

  1. A PUBLIC_* change didn’t take effect. It is baked at build — you must rebuild, not wrangler secret put.
  2. Staging shows dev/prod values. Either you forgot CLOUDFLARE_ENV=staging (build baked prod config) or you forgot to restate a binding/var under env.staging (named envs don’t inherit). Use npm run deploy:staging, which sets CLOUDFLARE_ENV for you.
  3. .dev.vars silently overrode .env. During the build, .dev.vars wins. If a value looks wrong, check .dev.vars first.
  4. 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_KEY in wrangler.jsonc) are different settings — update both.