# Environment and secrets

> Every variable and secret the site reads — which of the three homes each one lives in, when it is needed, what breaks without it, and how to check the Worker.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/deploy/environment/

---

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.

> **Free edition**
>
> The free edition has no Worker and no secrets. Of everything below it reads only
> `LISTINGS_SOURCE` (always `files`) and `ZERODIRS_DATASET`.

## Build-time variables

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](https://zerodirs.com/docs/deploy/listings-from-d1/) |
| `ZERODIRS_DATASET` | `ai-tools`, `indie-tools`, `local-businesses` | previewing a seed dataset without touching `src/content/` (files mode only) | `.env` | [Datasets](https://zerodirs.com/docs/configure/data/#datasets) |
| `CLOUDFLARE_ACCOUNT_ID` | the id `pnpm exec wrangler whoami` prints | `LISTINGS_SOURCE=d1` | `.env`; Workers Builds variable | [Listings from D1](https://zerodirs.com/docs/deploy/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](https://zerodirs.com/docs/configure/og-images/) |

## Runtime secrets

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](https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/submissions/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](https://zerodirs.com/docs/submissions/publishing/) |
| `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](https://zerodirs.com/docs/submissions/publishing/) |
| `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](https://zerodirs.com/docs/submissions/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.

## Bindings

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 |

## CI tokens

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](https://zerodirs.com/docs/deploy/git-push/) has the setup.

## Build time and run time

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` |

## Putting secrets in

```sh title="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).

## Verify

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

## Files

- .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'`
- scripts/
  - put-secrets.ts `pnpm cf:secrets`
- src/server/
  - env.ts the only reader of the secrets
  - secrets.ts `configGaps()`, the report `/admin/` renders

## Related

- [Go live](https://zerodirs.com/docs/deploy/go-live/) — The one command that creates the bindings and sets the two admin secrets.
- [Deploy on git push](https://zerodirs.com/docs/deploy/git-push/) — Where build variables go on Workers Builds and in GitHub Actions.
- [Security](https://zerodirs.com/docs/run/security/) — Secret hygiene, the admin cookie, and the two optional Cloudflare protections.
- [Configuration reference](https://zerodirs.com/docs/configure/reference/) — The `features` and `providers` fields each secret is read for.
