# Environment variables

Single source of truth for every env var the app reads. Schema is enforced
by Zod in `lib/env.ts` — anything missing or malformed throws at boot (or at
first call for lazy-validated vars).

Legend:

- 🔴 **required** — app won't boot / feature won't work without it
- 🟡 **lazy** — optional at boot, the feature that needs it throws when called
- 🟢 **optional** — has a sane default or is purely opt-in

> ⚠️ **Never commit a real value.** `.env.local` is gitignored. Production
> values live on the host (Vercel, fly.io, your VPS, etc.) — set them via the
> platform's env UI / CLI, not the repo.

---

## Core

### `DATABASE_URL` 🔴

Postgres connection string. Must start with `postgres://` or `postgresql://`.

```
DATABASE_URL="postgresql://user:pass@host:5432/eleva_speed"
```

### `NODE_ENV` 🟢

`development` | `production` | `test`. Defaults to `development`. Set by the
hosting platform in prod — don't set it manually in `.env.local`.

---

## Auth — Better Auth

### `BETTER_AUTH_SECRET` 🔴 (prod only)

Random secret used to sign session cookies. **≥ 32 chars in production**
(Zod refine enforces it). Generate with:

```bash
openssl rand -hex 32
```

```
BETTER_AUTH_SECRET="<64-hex-chars>"
```

### `BETTER_AUTH_URL` 🔴 (prod)

Public URL of the app. Used to build links in transactional emails
(invitations, etc.). Without it, `lib/auth.ts` throws when sending an
invitation in production (no `http://localhost:3000` fallback ships to
end-users).

```
BETTER_AUTH_URL="https://app.elevaseo.com"
```

In dev, falls back to `http://localhost:3000` silently.

---

## Resend — transactional email

Domain `elevaseo.com` is verified on Resend (region eu-west-1). Sender
configured as `ElevaSEO Speed <speed@elevaseo.com>`.

### `RESEND_API_KEY` 🟡

API key from https://resend.com/api-keys. Without it, `lib/email/otp.ts` and
`lib/email/invitations.ts` throw on first send.

```
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxx"
```

### `RESEND_FROM_EMAIL` 🟡

Sender used for every transactional email. **No fallback** — the helper
throws if it's missing (the previous `onboarding@resend.dev` fallback was
removed because Resend's sandbox sender only delivers to the account owner).

The value MUST use a sender on a domain verified at
https://resend.com/domains.

```
RESEND_FROM_EMAIL="ElevaSEO Speed <speed@elevaseo.com>"
```

**DNS records on `elevaseo.com` (already set, Resend status: Verified)** —
keep them in place; removing them breaks deliverability:

| Type | Name                | Content                                                               | Priority |
| ---- | ------------------- | --------------------------------------------------------------------- | -------- |
| TXT  | `resend._domainkey` | `p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDewgGqwiD/Swkk2QgWg…` (DKIM) | —        |
| MX   | `send`              | `feedback-smtp.eu-west-1.amazonses.com`                               | 10       |
| TXT  | `send`              | `v=spf1 include:amazonses.com ~all`                                   | —        |

---

## Stripe — billing

### `STRIPE_SECRET_KEY` 🟡

Server key. Must start with `sk_`. Used by `lib/billing/stripe.ts` and the
webhook handler.

```
STRIPE_SECRET_KEY="sk_test_…"      # or sk_live_… in prod
```

### `STRIPE_WEBHOOK_SECRET` 🟡

Signs incoming webhook events. Must start with `whsec_`. In dev you get this
from `stripe listen` (paste into `.env.local`); in prod from the dashboard
endpoint config. Webhook handler returns 503 if missing.

```
STRIPE_WEBHOOK_SECRET="whsec_…"
```

### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` 🟢

Public Stripe.js key (`pk_…`). Currently only referenced in docs — set it
the day client-side Stripe Elements is wired in.

### `STRIPE_TAX_ENABLED` 🟢

`true` | `false`. Defaults to `false`. Flip to `true` once tax registration /
nexus decisions are made.

### `STRIPE_PRICE_*` 🟡

Six price IDs (Starter / Pro / Business × Monthly / Yearly). Populated by
`scripts/stripe-bootstrap.ts` — run it once, it prints the env block to
paste. `createCheckoutSession()` throws if the requested plan/interval is
unset.

```
STRIPE_PRICE_STARTER_MONTHLY="price_…"
STRIPE_PRICE_STARTER_YEARLY="price_…"
STRIPE_PRICE_PRO_MONTHLY="price_…"
STRIPE_PRICE_PRO_YEARLY="price_…"
STRIPE_PRICE_BUSINESS_MONTHLY="price_…"
STRIPE_PRICE_BUSINESS_YEARLY="price_…"
```

### `STRIPE_DEVICE_NAME` 🟢 (dev only)

Cosmetic name the Stripe CLI uses to identify this dev box in the dashboard.

---

## Sitespeed.io — perf testing backend

### `SITESPEED_API_URL` 🟡

Base URL of the sitespeed worker (the docker-compose stack at
`speed.elevaseo.app`).

```
SITESPEED_API_URL="https://speed.elevaseo.app"
```

### `SITESPEED_API_KEY` 🟡

Shared bearer between Next.js and the sitespeed worker. **Rotate before
prod** — current value was exposed in a Claude Code transcript on
2026-05-24 (cf. `pre-prod-checklist.md`). Set the same value in the
worker's `.env` and here.

```
SITESPEED_API_KEY="<rotate-before-prod>"
```

---

## Integrations — vitrine ↔ app bridge

### `ELEVASPEED_INTEGRATION_API_KEY` 🔴 (prod)

Bearer shared between the marketing site (`elevaseo.com`) and this app's
`/api/integrations/*` endpoints. **≥ 32 chars** (Zod enforces). Required in
production (Zod superRefine throws at boot).

Name is prefixed `ELEVASPEED_*` because the vitrine talks to multiple
internal services and uses a unique key per service; same name on both
sides (Vercel for elevaseo.com + `.env.prod` for the Docker stack) so the
value is plugged identically.

```
ELEVASPEED_INTEGRATION_API_KEY="<openssl rand -hex 32>"
```

---

## Cron — scheduled audits

### `CRON_SECRET` 🟡

Bearer protecting `/api/cron/run-audits`. **≥ 32 chars**. Endpoint returns
503 without it (safe default: no work happens). Set before wiring pg_cron
or host cron in prod, otherwise the cron log fills with 503s.

```
CRON_SECRET="<openssl rand -hex 32>"
```

---

## Setup checklist

### Local dev (`.env.local`)

- [ ] `DATABASE_URL`
- [ ] `BETTER_AUTH_SECRET` (any string in dev, ≥32 chars recommended)
- [ ] `BETTER_AUTH_URL="http://localhost:3000"` (optional — falls back)
- [ ] `RESEND_API_KEY`
- [ ] `RESEND_FROM_EMAIL="ElevaSEO Speed <speed@elevaseo.com>"`
- [ ] `STRIPE_SECRET_KEY="sk_test_…"`
- [ ] `STRIPE_WEBHOOK_SECRET="whsec_…"` (from `stripe listen`)
- [ ] `STRIPE_PRICE_*` (run `pnpm tsx scripts/stripe-bootstrap.ts`)
- [ ] `SITESPEED_API_URL`
- [ ] `SITESPEED_API_KEY`

### Production env

Everything above **plus**:

- [ ] `BETTER_AUTH_SECRET` ≥ 32 chars (enforced)
- [ ] `BETTER_AUTH_URL="https://app.elevaseo.com"` (or whatever the public URL is)
- [ ] `RESEND_FROM_EMAIL` on a verified domain (already verified: `elevaseo.com`)
- [ ] `ELEVASPEED_INTEGRATION_API_KEY` ≥ 32 chars (enforced — boot fails without it)
- [ ] `CRON_SECRET` ≥ 32 chars (if cron enabled)
- [ ] `STRIPE_SECRET_KEY="sk_live_…"`
- [ ] `STRIPE_WEBHOOK_SECRET` from dashboard endpoint config
- [ ] `STRIPE_PRICE_*` from a live-mode bootstrap run
- [ ] Rotate `SITESPEED_API_KEY` (cf. pre-prod checklist)
- [ ] `NODE_ENV=production` (usually set automatically by the platform)
