Skip to content

Environment and secrets

Markdown

Everything configurable that is not in site.config.ts is a variable or a secret, and each one has exactly one home. Putting a value in the wrong home is the most common deploy-day mistake, so this page is the map: what exists, where it goes, when it is read, and what you see when it is missing.

Home Reaches Locally In production
Build-time variables the build only — never the Worker .env (written from .env.example by pnpm dev) Workers Builds → Settings → Build → Variables and secrets, or the GitHub Actions job env
Runtime secrets the Worker at request time — never the build .dev.vars (written from .dev.vars.example by pnpm dev) pnpm cf:secrets, which pipes each value to wrangler secret put
CI tokens the deploy job only Workers Builds mints its own; GitHub Actions needs three repository secrets

The rule underneath is Cloudflare’s. A public astro:env field is inlined into the bundle as a constant at build time, so anything secret is declared context: 'server', access: 'secret' in astro.config.ts and read only through src/server/env.ts (pnpm check greps for that). Build variables never reach the runtime, and runtime secrets never reach the build.

From .env.example. Values are not secrets except D1_READ_TOKEN.

Variable Values Needed when Set where Page
LISTINGS_SOURCE files (default) or d1 always. Unset falls back to files with a warning, not an error — a build that was meant to read the database quietly reads Markdown instead; the build log says [zerodirs-files] where you expected [zerodirs-d1] .env; Workers Builds variable; Actions env Listings from D1
ZERODIRS_DATASET ai-tools, indie-tools, local-businesses previewing a seed dataset without touching src/content/ (files mode only) .env Datasets
CLOUDFLARE_ACCOUNT_ID the id pnpm exec wrangler whoami prints LISTINGS_SOURCE=d1 .env; Workers Builds variable Listings from D1
D1_DATABASE_ID database_id in wrangler.jsonc LISTINGS_SOURCE=d1 same same
D1_READ_TOKEN an account API token whose only permission is D1 › Read — never the deploy token, and not named CLOUDFLARE_API_TOKEN LISTINGS_SOURCE=d1 .env; Workers Builds build secret; GitHub repository secret same
OG_RENDERER native (default) or wasm generating OG images where the native resvg binary cannot load .env OG images

From .dev.vars.example, which also carries each key’s own instructions. pnpm dev copies it to .dev.vars with placeholder values; pnpm cf:setup replaces the two admin placeholders with generated values and sets them on the Worker. Every other key is yours to add when you turn the feature on.

Secret What it looks like, and where it comes from Read when Without it Page
ADMIN_SECRET 32 or more random bytes, base64url. cf:setup generates it; node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" makes one by hand always nobody can sign in to /admin/; the placeholder from the example file is reported as an error while it is in use Admin
TOKEN_SECRET same recipe, a different value — the two must not be equal always status links, checkout links and the admin cookie cannot be signed Admin
STRIPE_SECRET_KEY a restricted key (rk_test_… in test mode) that can only write Checkout Sessions — Stripe → Developers → API keys → Create restricted key features.payments on and providers.payment: 'stripe' checkout answers 503 and /admin/ reports “Paid tiers cannot complete a checkout” Plans and payments
STRIPE_WEBHOOK_SECRET whsec_…, shown on the endpoint’s page after you add https://<your site>/api/stripe/webhook/ (with the trailing slash) for checkout.session.completed and checkout.session.async_payment_succeeded same same; one Stripe secret without the other degrades the provider to none Plans and payments
RESEND_API_KEY re_…, sending-only — Resend → API Keys → Create → Sending access providers.email: 'resend' nothing is sent: the console provider logs each message and marks its row sent; /admin/ reports the missing key (its line still says the mail stays queued, which is not what happens) Email
DEPLOY_HOOK_URL the hook URL itself is the credential — Workers & Pages → your Worker → Settings → Build → Deploy hooks providers.rebuild: 'deploy-hook' approvals will not trigger a build; /admin/ reports an error and offers “Publish now” Publishing on approval
GITHUB_TOKEN and GITHUB_REPO a fine-grained token (github_pat_…) with Contents: Read and write on the repository that builds the site; the repository as owner/repo providers.rebuild: 'github-dispatch' the trigger falls back to none and approvals will not publish Publishing on approval
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET an OAuth client (Web application) whose id ends in .apps.googleusercontent.com and whose secret begins GOCSPX-, with the redirect URI https://<your site>/auth/google/callback/ features.accounts — optional no “Continue with Google” button; sign-in by emailed link still works. This is the one pair /admin/ does not report as a gap Submitter accounts

providers.email: 'cloudflare', the shipped default, needs no secret at all. It needs the EMAIL binding below and a sending domain on your zone.

Declared in wrangler.jsonc, not secrets, and not variables: they are the Cloudflare resources the Worker is attached to. pnpm cf:setup creates the first two and writes them into the file. After any change to this list, run pnpm cf:typegen, or pnpm check type-errors on the binding.

Binding Type Created by Needed for
DB D1 database cf:setup (wrangler d1 create) submissions, the admin, accounts, the review queue, click counts, the D1 newsletter
MEDIA R2 bucket cf:setup (wrangler r2 bucket create) uploaded and seeded logos; media.baseUrl in site.config.ts is the bucket’s custom domain
EMAIL send_email shipped in the file; cf:setup leaves it out while the account has no sending domain providers.email: 'cloudflare'
0 * * * * cron trigger shipped in the file the hourly jobs. A deploy without it clears the schedule rather than leaving it alone

Workers Builds generates its own deploy token and needs nothing from you. GitHub Actions needs three, and three is the point: a leaked build log cannot deploy, and a leaked deploy token cannot drop a table.

Repository secret Scope Can it…
CLOUDFLARE_API_TOKEN Edit Cloudflare Workers deploy code to your domain
CLOUDFLARE_D1_WRITE_TOKEN D1 Edit only change the schema
D1_READ_TOKEN D1 Read only neither — and it is the only one the build itself gets

Plus one repository variable, USE_GITHUB_DEPLOY=true, without which the shipped deploy.yml stays skipped. Deploy on git push has the setup.

The distinction that decides which home a value belongs in:

Build-time variable Runtime secret
Read by astro build (import.meta.env.X ?? process.env.X) the Worker, per request, through src/server/env.ts
Ends up in the generated HTML and the build log — so it must not be secret, except D1_READ_TOKEN, which is only ever read and never written into the output the Worker’s secret store; never in a file, never in the bundle
Wrong home looks like a secret set as a build variable: the Worker never sees it, /admin/ reports it missing a build variable set with cf:secrets: the build never sees it, LISTINGS_SOURCE falls back to files
Set with .env / dashboard variables pnpm cf:secrets
Terminal
pnpm cf:secrets # what the Worker holds, what site.config.ts needs
pnpm cf:secrets STRIPE_SECRET_KEY # one key: shows its instructions, prompts, uploads
pnpm cf:secrets # with no names: walks every key your config needs

It prints each key’s help text from .dev.vars.example, prompts for the value without echoing it, checks the shape before uploading — the placeholder from the example file, a whsec_ value pasted into STRIPE_SECRET_KEY, a publishable key where a secret key belongs, an admin secret shorter than 32 characters — and pipes what passes to wrangler secret put. It never prints a value and never writes one to disk.

Two places a secret must never go: vars in wrangler.jsonc (deployed in plain text) and .env (read by the build, and by anything that reads the build log).

Where You should see
What the Worker holds pnpm cf:secrets with no arguments, or pnpm exec wrangler secret list the names (never the values) of the secrets set on the deployed Worker
What is still missing https://<your site>/admin/ the configuration report: an error is something switched on that cannot work, a warning is something working differently from what you configured
Which source the build used the build log on Workers Builds [zerodirs-d1] D1: N approved listings when you meant the database, [zerodirs-files] listings: N when you meant Markdown
  • .env.example every build-time variable, with its comment
  • .dev.vars.example every runtime secret, with the instructions cf:secrets shows you
  • wrangler.jsonc the bindings and the cron trigger
  • astro.config.ts the env.schema that declares each secret context: 'server', access: 'secret'
  • Directoryscripts/
    • put-secrets.ts pnpm cf:secrets
  • Directorysrc/server/
    • env.ts the only reader of the secrets
    • secrets.ts configGaps(), the report /admin/ renders