Environment Variables
Securely store API keys, database URLs, and secrets. Encrypted at rest and only available to your backend functions.
On this page
Site vs Global Secrets — two stores, on purpose
There are two places a secret can live, matching the two kinds of functions:
| Site variables | Global secrets | |
|---|---|---|
| Belong to | one site | your whole account |
| Read by | that site's backend functions | account functions (Automations → Functions) |
| Set in | the site's Config tab | Automations → Secrets (or ask the AI) |
| Typical use | per-site keys — each client site's own Stripe key, CRM token | account-wide keys — your AI key, internal tools with no site |
| Duplicate names | adding a name that already exists is refused — you edit the existing entry to rotate its value, so a working key is never silently replaced | |
Why two stores, and no automatic fallback? Two reasons. First, the same name often needs different values per site — an agency running three client sites has three different STRIPE_SECRET_KEYs, and a global-only store couldn't express that. Second, explicit beats clever with secrets: a site function can never silently read an account-wide key, and a global automation can never silently pick up some site's value. Each function reads exactly one store — the one matching its scope — so there are no surprises about which secret was used. If a value is genuinely needed in both worlds, you set it in both, deliberately.
The same split explains the two kinds of functions: site backend functions belong to one site — they run with that site's data, members, and secrets, and its pages call them. Account functions belong to you — they span sites (or need no site at all) and are triggered by webhooks, email triggers, and schedules. See Account Functions & Webhooks.
Site Variables — create & use
A site variable belongs to one site and is read by that site's backend functions — the functions its pages call. To create one:
- Go to your app's Config tab in the dashboard
- Scroll to the Environment Variables section
- Enter a name (uppercase with underscores, e.g.,
STRIPE_SECRET_KEY) - Enter the value (your API key, secret, etc.)
- Click Add
Variable names must be alphanumeric with underscores only (e.g., MY_API_KEY). Values are masked in the UI after saving.
Then read it inside the site's backend functions with ctx.env.get():
Which secrets belong here? Env vars are readable by any code in your app (that is the point — your function needs the key). For an email mailbox use Mail Accounts and for a connected integration use connections: in both, the platform performs the privileged action and the credential never enters your function at all.
// Get an env var by name — ctx.env.get returns { value }
const value = await ctx.env.get('STRIPE_SECRET_KEY');
// A function can also WRITE its own config. Write-only, this site only, and
// the name must start with APP_ (so app code can never overwrite your keys).
await ctx.env.set('APP_ONBOARDING_STAGE', 'complete');
const result = await ctx.fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { 'Authorization': `Bearer ${value}` },
body: JSON.stringify({ amount: 1000, currency: 'usd' }),
});Global Secrets — create & use
A global secret belongs to your whole account and is read by your account functions — the ones triggered by email triggers, webhooks, and schedules, which may have no site at all. To create one:
- Go to Automations → Secrets in the dashboard (or just tell the AI: “save my Anthropic key as a global secret”)
- Click Add secret, enter the name (e.g.
ANTHROPIC_API_KEY) and paste the value - To change a value later, use the edit (pencil) action — adding a second secret with the same name is refused, so a working key is never replaced by accident
Account functions read it with exactly the same syntax site functions use for their own store:
// In an ACCOUNT function (e.g. one an email trigger runs):
const value = await ctx.env.get('ANTHROPIC_API_KEY');
// …or as a {{placeholder}} — resolved before the request is sent:
const res = await ctx.fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'x-api-key': '{{ANTHROPIC_API_KEY}}', 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model: 'claude-sonnet-5', max_tokens: 300,
messages: [{ role: 'user', content: 'Classify this email…' }] }),
});
// Account functions can also store their own state — code may only write
// APP_-prefixed names, so it can never overwrite your operator-set secrets:
await ctx.env.set('APP_LAST_SYNC_AT', new Date().toISOString());Same function code, different store: ctx.env.get() in a site function reads that site's variables; in an account function it reads your global secrets. A function never has to say which store — its scope decides, and the two never mix.
Placeholder Syntax (both stores)
In any function — site or account — you can use {{VAR_NAME}} placeholders directly in ctx.fetch URLs, headers and bodies. They resolve from the function's own store (site variables in site functions, global secrets in account functions) before the request is sent.
// Placeholders are resolved automatically
const result = await ctx.fetch('{{API_BASE_URL}}/users', {
headers: {
'Authorization': 'Bearer {{API_KEY}}',
'X-Custom-Header': '{{CUSTOM_VALUE}}',
},
});Placeholders work in the URL, headers, and body of ctx.fetch calls. They're resolved recursively in nested objects.
Where should a secret live?
| The secret is… | Put it in | Who can write it | Who can use it |
|---|---|---|---|
| A key YOU manage (Anthropic, Stripe…) | Site Config or Automations → Secrets | you (dashboard or your AI) — never app code | your functions (read-only) |
| A value the APP manages at runtime | APP_* env name via ctx.env.set | function code | function code |
| Sensitive data INSIDE a record (a customer's token, ID number) | a table secret field | whoever may edit the row | nobody programmatically — masked everywhere, reveal-on-demand for authorized people |
| An email mailbox password | Mail Accounts (Automations → Email) | you | nobody — the platform sends on your behalf; code names the mailbox, never sees the password |
The rule behind the whole table: code can never overwrite a secret a human set (function writes are limited to the APP_* namespace at both scopes), and the most protected stores are the ones where the value never enters code at all.
Security (both stores)
AES-256-GCM encryption at rest
Values are encrypted before storage and only decrypted when your backend function executes.
Backend-only by default
Every variable created from the dashboard's Add Variable form is marked secret — available only inside backend functions via ctx.env.get(), never sent to the browser or included in the published bundle. A variable can only be made non-secret (baked into the client bundle at build time) programmatically via isSecret: false — don't do this for anything sensitive.
Masked in UI
After saving, values show only the first 2 and last 2 characters. You can update or delete, but not view the full value.
Tip: Don't use Deno.env or process.env in your functions — they're blocked by the sandbox. Always use ctx.env.get() instead.