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, so a working key is never silently replaced. To rotate a value, edit the entry in Automations → Secrets; in a site's Config, delete the variable and add it again, or ask the AI to update it | |
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 gives you the value as a string:
A function can only use a secret you have ticked for it. Open the function, go to Access, then Powers & secrets. Tick the secret under "Secrets this function can read". To read any other setting with ctx.env.get(), switch on "Read and write secrets" there too. Email, users, paid access, social posting and starting agents are separate switches in the same place. A new function has none of them on. You switch the powers on yourself; in a workspace with members, the Owner, an Admin or a Developer does, and ticking a secret needs the Publish permission.
Which secrets belong here? A function reads only the secrets you have allowed for it (see above). Values a function writes with ctx.env.set (names starting with APP_) can be read back by any function that has the "Read and write secrets" power. For an email mailbox use a mailbox in Sending accounts (Automations → Sending 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 the value as a string
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. Only secrets ticked for that function are filled in; a name that is not ticked is sent as the literal text.
// 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.
From your site's pages (serverFetch)
Pages call serverFetch with the same placeholders. Because a visitor's browser chooses the address, a visitor's call fills in a secret only when the address's host is on that secret's Allowed hosts (Site Config → Environment Variables → Edit hosts), for example api.openai.com or *.example.com. With no hosts, visitors' calls that use the secret are refused. Your own preview is not limited, and tells you which host to add.
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 reads it back — masked everywhere, even for the owner; people who may edit the row can replace it |
| An email mailbox password | Sending accounts (Automations → Sending accounts) | 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, a value is never shown again, not even partly. In a site's Config you can delete a variable and edit its allowed hosts. To change a value, delete it and add it again, or ask the AI to update it.
Tip: Don't use Deno.env or process.env in your functions — they're blocked by the sandbox. Always use ctx.env.get() instead.