pb_secrets
Every piece of configuration a project has, declared in one file, each key saying who is allowed to read it. The values live beside it in a file that never enters version control, so a project needs no .env at all.
Two files
main.ts is the declaration and belongs in the repository. secrets.json holds the values on your machine and is git-ignored, which voidbase init has already arranged.
// pb_secrets/main.ts
import { defineSecrets, secret, server, browser, local, flag, string, number, boolean } from "@voidbase-cloud/voidbase/secrets";
export default defineSecrets({
SMTP_PASSWORD: secret(string(), "the mail provider's password"),
STRIPE_SECRET_KEY: secret(string().optional(), "set it and the stripe plugin takes money"),
MAX_UPLOAD_MB: server(number().default(10)),
VOIDBASE_MAIL_DOMAIN: server(string().optional(), "mail leaves through Cloudflare from this domain"),
PUBLIC_SITE_URL: browser(string().default("https://example.com")),
FEATURE_DIGEST: flag(boolean().default(false), "a Flagship feature flag, evaluated per request"),
VOIDBASE_DEPLOY_CF_API_KEY: local(string(), "the deploy token"),
VOIDBASE_DEPLOY_NAME: local(string().default("blog-api"), "the Worker this project deploys to"),
});{
"SMTP_PASSWORD": "...",
"STRIPE_SECRET_KEY": "...",
"VOIDBASE_DEPLOY_CF_API_KEY": "..."
}Who may read it
Every key is wrapped in one of five words, and a bare validator is refused. That is deliberate: the person maintaining this file is the one answering for who can see what, so the file makes them say it rather than letting it be inferred.
| Wrapper | Where the value ends up |
|---|---|
secret() | The deployed instance only, as an encrypted secret. Never listed back, never in a build. Stored on the first deploy and never silently replaced afterwards. |
server() | A plain variable on the instance, readable by hooks and routes. Set by every deploy. |
browser() | The same, plus inlined into any client build. Treat it as public, because it is; the listing calls this tier public. |
flag() | A boolean feature flag from Cloudflare Flagship. The deploy creates a Flagship app named after the Worker with every declared flag at its default and binds it; a change in the dashboard wins without a deploy. On every request the flag is evaluated with a targeting key, the signed-in record or else the client's address, so a percentage rollout is sticky per person, and $os.getenv sees the answer without knowing it is a flag. Where Flagship is not reachable, the default answers. |
local() | Your own tooling: the deploy token, the Worker's name. Read on your machine and in CI, deployed nowhere. |
Validation, defaults and types
The validators are string(), number(), boolean(), url(), email(), oneOf() and json(), each with .optional() and .default(value), and any Standard Schema validator works inside the wrappers. A default is a real value, so a key with one needs no entry in secrets.json and no entry in production either. A deploy stops on a missing or invalid value and names the key, never the value, so a typo is caught before it ships rather than after.
Reading them
// in a hook, the way PocketBase reads configuration: the stored string
const limit = Number($os.getenv("MAX_UPLOAD_MB"));// in TypeScript, the typed values through the same declaration
import config from "../pb_secrets/main";
const { MAX_UPLOAD_MB, FEATURE_DIGEST } = await config.read((n) => $os.getenv(n)); // a number and a booleanLocally the declared values, defaults included, are put into the environment when the server starts: the shell outranks secrets.json, which outranks .env. On Cloudflare the secrets are the Worker's secrets and the rest are its variables, so the same call works in both places.
The knobs a deploy reads from here
Some names mean something to the deploy itself, and the shipped plugins read theirs from the same place. A declared server() or secret() key with one of these names is enough; the deploy wires the binding, and the plugin does the rest.
| Key | What it turns on |
|---|---|
VOIDBASE_DEPLOY_NAME, VOIDBASE_DOMAINS | The Worker's name, and the hostnames the domains plugin attaches, the first canonical. |
VOIDBASE_DATABASE=durable | The data in a SQLite-backed Durable Object instead of D1; a batch is a real transaction. |
VOIDBASE_MAIL_DOMAIN | The send_email binding: mail from that domain leaves through Cloudflare Email Service. |
VOIDBASE_AI | The Workers AI binding, and POST /api/ai/chat over the instance's own tools. |
VOIDBASE_TRANSLATABLE, VOIDBASE_LOCALES | Which fields have translations and in which locales; the records API answers in ?locale=. |
VOIDBASE_SITE_URL, VOIDBASE_SITEMAP, VOIDBASE_SEO | What robots.txt, the sitemap, llms.txt and the page metadata are generated from. |
VOIDBASE_BACKUP_KIND, VOIDBASE_BACKUP_KEEP, VOIDBASE_BACKUP_S3_* | What the scheduled backup writes, how many it keeps, and the bucket outside the account it copies to. |
STRIPE_SECRET_KEY, POLAR_ACCESS_TOKEN, LEMONSQUEEZY_API_KEY | Which payments plugin answers, each with its webhook secret beside it. |
VOIDBASE_CORS_ORIGINS, VOIDBASE_HSTS, VOIDBASE_CSP | The response policy the hardening plugin applies; all off unless set. |
VOIDBASE_SECRETS_STORE | The id of the account's Secrets Store. Every secret() is then stored there as <worker>__<KEY> and bound by name instead of living on the Worker; nothing in the app changes. |
The full list, with what each accepts, is the environment table in docs/deploy.md.
Seeing the state of it
voidbase secretspb_secrets: 8 declared (2 secret, 2 server, 1 public, 1 flag, 2 local, never deployed), 3 valued in secrets.json, worker "blog-api" has 2 of the secrets
SMTP_PASSWORD secret local value on the worker the mail provider's password
STRIPE_SECRET_KEY secret local value on the worker set it and the stripe plugin takes money
MAX_UPLOAD_MB server default 10
VOIDBASE_MAIL_DOMAIN server optional, unset mail leaves through Cloudflare from this domain
PUBLIC_SITE_URL public default "https://example.com"
FEATURE_DIGEST flag default false a Flagship feature flag, evaluated per request
VOIDBASE_DEPLOY_CF_API_KEY local local value the deploy token
VOIDBASE_DEPLOY_NAME local default "blog-api" the Worker this project deploys toOne row per declared key: its tier, whether it has a value here or a default, and, when the deploy token is available, whether the Worker already has it (or the Secrets Store, when one is named). A value in secrets.json that the declaration does not name is listed as never deployed.
voidbase secrets push # store the local secrets on the Worker, replacing, without redeployingA deploy stores a secret the Worker lacks and leaves one it already holds alone, because a checkout whose secrets.json carries dev values must not overwrite production by deploying. Replacing is explicit, and that is what push is for. It is also what keeps CI simple: a checkout without secrets.json deploys with nothing but the deploy token, because the secrets were pushed once from a machine that has them.
secrets.json is the one file in a project that must never be committed. Check your .gitignore has it before the first commit, especially in a repository that did not come from voidbase init.
Fix it yourself. The link below opens this file in GitHub's editor and forks the repository for you if you need one, and your change becomes a pull request without leaving the browser.