Environment Variables
Every variable Nuxfire reads, what it's for, and a direct link to create it on each platform.
Every variable below lives in .env.example (the template) and is loaded into SST's own secrets vault per stage — see Secrets for the loading commands. This page is the reference for where each value comes from, one platform at a time, for anyone setting this up for the first time.
You don't need every row — only fill in what a feature you're actually using requires. Everything not marked Always is gated by a flag in config.ts; leave it blank and disable the flag if you don't want that integration.
1. Branding — your identity
Set in the Branding section of your stage's env file — the single place your domain and sender email live; nothing in the code repeats them. Unlike everything below, these are not secrets: the deploy reads them straight from the file (and stops with the variable's name if one is missing or malformed), and a real environment variable of the same name takes precedence. Each stage reads its own file — .env.production for production, .env.stage for every other stage.
| Variable | Required | What it is |
|---|---|---|
BRAND_NAME | Always | Your product or company name: the emails' sender name, subject lines and logo alt text. Up to 80 characters. |
BRAND_DOMAIN | Always | Your production domain, name only (acme.io) — no https://, path or port. Must be an active zone in the same Cloudflare account that owns your API token. production answers on it; every other stage answers on <stage>.dev.<domain>. |
BRAND_EMAIL | Always | The complete sender address (contact@acme.io), address only — the display name comes from BRAND_NAME. Its domain must be verified in your email provider; it can be a subdomain such as mail.acme.io. |
2. Cloudflare — deploy authentication
Required before any sst command works at all, on every machine that deploys. Full walkthrough with screenshots (permissions, token scope) is in Cloudflare Access.
| Variable | Required | Get it at |
|---|---|---|
CLOUDFLARE_API_TOKEN | Always | dash.cloudflare.com → Manage Account → Account API Tokens → Create Token. Not the personal profile/api-tokens page — that lists a different kind of token. |
CLOUDFLARE_ACCOUNT_ID | Always | dash.cloudflare.com → the ⋮ menu next to your account name → Copy Account ID (also shown in the API section of any domain's Overview page) |
3. Database — PostgreSQL
Any standard Postgres works — the app speaks the native wire protocol, never a provider SDK. Using Supabase as the example (it's what this project's own .env.stage points at):
| Variable | Required | Get it at |
|---|---|---|
DATABASE_HOST | Always | supabase.com/dashboard → your project → Settings → Database → Direct connection tab. See warning below — don't use the pooler tab. |
DATABASE_PORT | Always | Same tab (5432) |
DATABASE_NAME | Always | Same tab |
DATABASE_USER | Always | Same tab — plain postgres, no suffix |
DATABASE_PASSWORD | Always | Same tab — set when the project was created; reset it there if you don't have it |
Use the Direct connection, not the Session pooler
Supabase's database settings page shows two connection modes. Use Direct connection — Cloudflare's own Hyperdrive docs are explicit about this: "you should use the Direct connection connection string rather than the pooled connection strings, as Hyperdrive will perform pooling of connections to ensure optimal access from Workers." Hyperdrive already pools connections at Cloudflare's edge — routing it through Supabase's own pooler (Supavisor) on top would be pooling behind a pooler, for no benefit.
The two modes use different, non-interchangeable host and username formats — never mix one mode's host with the other mode's username:
| Host | User | |
|---|---|---|
| Direct (use this) | db.<project-ref>.supabase.co | postgres |
Session pooler (don't use for DATABASE_HOST/DATABASE_USER) | aws-0-<region>.pooler.supabase.com | postgres.<project-ref> |
Pairing a direct-mode host with a pooler-mode username (or vice versa) doesn't fail loudly with a clear message — it deploys, then Cloudflare rejects it with Hyperdrive → 400: "Invalid database credentials" (error code 2013), which reads like a wrong password even when the password is correct.
The one case where you might still need the pooler: every deploy runs database migrations automatically from your own machine (infra/utils/nuxt.ts, a local command), using these same values — not just from Cloudflare's edge. Supabase's direct connection is IPv6-only. If your network has no real IPv6 route to the internet, that local migration step will fail to connect (a connection timeout, not the credentials error above) even though Hyperdrive itself would have worked fine from Cloudflare's own IPv6-capable network. If you hit that specific failure, switching to the Session pooler pair (table above) is the pragmatic fix — you're trading a small amount of double-pooling overhead for reachability from your own machine.
Using RDS, Neon, or self-hosted Postgres instead: the same five values come from that provider's own connection-info page — the app doesn't care which one, as long as the postgis extension is enabled (Supabase has it available by default; run CREATE EXTENSION IF NOT EXISTS postgis; on others if needed).
4. Email — pick at least one provider
Enable/disable each in config.ts → flags. Only resend is on by default.
| Variable | Provider | Required when | Get it at |
|---|---|---|---|
RESEND_API_KEY | Resend | flags.resend | resend.com/api-keys → Create API Key |
MAILGUN_API_KEY | Mailgun | flags.mailgun | app.mailgun.com → Settings → API Keys |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Amazon SES | flags.ses | console.aws.amazon.com/iam → create an IAM user with SES send permissions → Security credentials → Create access key. Not automated as infrastructure yet (see comment in infra/secret.ts) — the IAM user itself has to be created by hand. |
SPARKPOST_API_KEY | SparkPost | flags.sparkPost | app.sparkpost.com → Account → API Keys |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD | Generic SMTP | flags.smtp | Your SMTP provider's own dashboard — there's no single link here since this path accepts any SMTP server |
5. Social login
Both are optional, independently toggled (flags.githubAuth, flags.googleAuth). Each callback URL below must match exactly, including the /callback suffix — the OAuth provider will reject the login with a redirect-URI-mismatch error otherwise.
| Variable | Required when | Get it at |
|---|---|---|
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | flags.githubAuth | github.com/settings/developers → New OAuth App. Authorization callback URL: https://auth.<your-domain>/github/callback |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | flags.googleAuth | console.cloud.google.com/apis/credentials → Create Credentials → OAuth client ID (type: Web application). Authorized redirect URI: https://auth.<your-domain>/google/callback |
6. Payments
Stripe and Paddle can both be active at once — a Plan just picks one provider per row (see Payments & Billing).
| Variable | Required when | Get it at |
|---|---|---|
STRIPE_SECRET_KEY | flags.stripe | dashboard.stripe.com/apikeys |
STRIPE_WEBHOOK_SECRET_KEY | flags.stripe | dashboard.stripe.com/webhooks → Add endpoint → https://app.<your-domain>/api/webhooks/stripe → copy the Signing secret shown after creating it |
PADDLE_API_KEY | flags.paddle | vendors.paddle.com/authentication → Developer Tools → Authentication → API keys tab (sandbox testing: sandbox-vendors.paddle.com instead) |
PADDLE_WEBHOOK_SECRET | flags.paddle | vendors.paddle.com/notifications → New destination → Webhook → https://app.<your-domain>/api/webhooks/paddle → copy the secret key shown |
PADDLE_CLIENT_TOKEN | flags.paddle | Same Authentication page as PADDLE_API_KEY — Client-side tokens tab. This one is public by design (it ends up in the browser bundle), but is still set per stage like the others. |
There is no environment variable for which plan the landing page's Buy CTA sells — that's a database flag (Plan.isPublicOffer), toggled from /admin/plans and read live by apps/web over a Cloudflare service binding. See Payments & Billing for how it works.
PADDLE_API_KEY permissions — don't select "All"
When creating the API key, the app only ever calls two Paddle resources server-side (grep -rn "paddle\.\w*\.\w*(" apps/app/server to re-verify against a newer checkout of this repo):
- Customers — Read.
paddle.customers.get(customerId)in the webhook handler (server/api/webhooks/paddle.post.ts), to read the customer's email. - Customer portal sessions — Write.
paddle.customerPortalSessions.create(...)(server/trpc/routers/billing.ts), to generate the billing-portal link a customer uses to manage their subscription.
Nothing else — no Adjustments, Businesses, Addresses, Discounts, Products, Prices, Transactions, or Reports permission is used anywhere in the codebase. A broader key is unnecessary blast radius if it ever leaks; widen the scope later only if new code actually calls another Paddle resource. (paddle.webhooks.unmarshal(...), the third Paddle SDK call in the same webhook handler, verifies the webhook signature locally — it's not a network call and needs no API permission.)
7. Platform Admin console (/admin)
Not from any external platform — generate these yourself, locally. See Platform Admin Console for what they protect.
| Variable | Required | How to generate |
|---|---|---|
PLATFORM_MFA_SESSION_SECRET | Always, before /admin/* works | openssl rand -base64 32 (Git Bash/macOS/Linux) — or PowerShell: [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)). Safe to rotate — worst case it signs out active elevated sessions. |
PLATFORM_ADMIN_ENCRYPTION_KEY | Always, before /admin/* works | Same command. Do not rotate casually — it encrypts every admin's TOTP secret at rest; rotating it forces every enrolled admin to set up MFA again. Back it up somewhere durable (a password manager), not only in this file. |
8. Two-factor authentication (regular users)
Same generation command as the platform-admin pair above, same rules, but kept on separate secrets — see Platform Admin Console for why. Only required if a user actually turns on 2FA from Account Settings → Security; nothing else in the app depends on these.
| Variable | Required | How to generate |
|---|---|---|
USER_MFA_SESSION_SECRET | Before any user can enable 2FA | openssl rand -base64 32. Safe to rotate — worst case it re-prompts already-enrolled users for a code on their next request. |
USER_MFA_ENCRYPTION_KEY | Before any user can enable 2FA | Same command. Do not rotate casually — it encrypts every user's TOTP secret at rest; rotating it forces everyone who enrolled to set up 2FA again. |
9. Optional — AI & System One models (TypeSafe)
| Variable | Required | Get it at |
|---|---|---|
TYPESAFE_API_KEY | Optional | docs.typesafe.ai — API key for System One models returning typed judgments and probabilities. Server-only: never expose it as NUXT_PUBLIC_*. |
10. Optional — build tuning
| Variable | Required | Notes |
|---|---|---|
SST_BUILD_CONCURRENCY_SITE | No | Caps how many packages SST builds in parallel. Useful on a CPU-constrained CI runner; leave unset otherwise. |
What never goes in this file
NUXT_PUBLIC_* variables, worker URLs, client IDs exposed to the browser, and anything else derived at deploy time are computed automatically by infra/*.ts — never set those by hand here. The one exception: running seed/demo-accounts.ts manually needs AUTH_URL and AUTH_ADMIN_SECRET in your terminal's environment at the moment you run it (not in this file, not in the vault) — see Platform Admin Console.
Ready to build and launch your SaaS?
Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.
- 1. Branding — your identity
- 2. Cloudflare — deploy authentication
- 3. Database — PostgreSQL
- 4. Email — pick at least one provider
- 5. Social login
- 6. Payments
- 7. Platform Admin console (/admin)
- 8. Two-factor authentication (regular users)
- 9. Optional — AI & System One models (TypeSafe)
- 10. Optional — build tuning
- What never goes in this file