> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theroyalglow.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Environment Variables

> Every environment variable for the Royal Glow platform — what they do, where they go, and how they're validated.

# Environment Variables

Royal Glow runs a monorepo with three Next.js apps (`apps/web`, `apps/admin`, `apps/cms`) plus the `apps/invoicing` PDF render service, deployed across Cloudflare Workers (`rgss-web`, `rgss-admin` — via the OpenNext adapter `@opennextjs/cloudflare`), Render (`rgss-cms`), and Cloud Run (`apps/invoicing`), and relies on \~15 external services.

<Callout type="warn">
  Environment variables are **validated at build time** using `@t3-oss/env-nextjs` + Zod. If any required variable is missing or malformed, the build fails immediately. Never use `process.env` directly — always import from `apps/web/src/env.ts`.
</Callout>

<Callout type="info">
  **Guarded-no-op pattern.** Optional integrations (Redis, R2, Resend, Brevo, web-push, Ably, Slack, QStash heartbeats) degrade gracefully. When their keys are absent the related call becomes a **no-op + log** instead of throwing: the health probe reports `skip` rather than `fail`, the Ably token route returns `503` so the client falls back to polling, and background jobs still complete and return `200`. This is why the app builds and runs with an incomplete `.env` — only the truly required core (database, auth) is hard-validated.
</Callout>

## Quick Start

<Steps>
  <Step>
    ### Copy templates

    ```bash theme={null}
    cp apps/web/.env.example apps/web/.env.local
    cp apps/admin/.env.example apps/admin/.env.local
    cp apps/cms/.env.example apps/cms/.env.local
    ```
  </Step>

  <Step>
    ### Fill in values

    Populate each `.env.local` with values from your service dashboards.
  </Step>

  <Step>
    ### Generate VAPID keys (one-time only)

    ```bash theme={null}
    bunx web-push generate-vapid-keys
    ```
  </Step>

  <Step>
    ### Validate

    The build fails fast if anything required is missing or malformed.

    ```bash theme={null}
    cd apps/web && bun run build
    ```
  </Step>
</Steps>

## File Structure

<Files>
  <File name=".env.example — root template (committed)" />

  <File name=".env.local — local dev secrets, NEVER commit" />

  <Folder name="apps/web" defaultOpen>
    <File name=".env.example — web-specific vars (committed)" />

    <File name=".env.local — local overrides (gitignored)" />
  </Folder>

  <Folder name="apps/admin" defaultOpen>
    <File name=".env.example — admin-specific vars (committed)" />
  </Folder>

  <Folder name="apps/cms" defaultOpen>
    <File name=".env.example — cms-specific vars (committed)" />
  </Folder>
</Files>

## Complete Variable Reference

### Database (Neon)

<TypeTable
  type={{
DATABASE_URL: {
  type: 'Secret · web, cms',
  description: 'Pooled connection via Neon pgBouncer — used by all app queries.',
  required: true,
},
DATABASE_URL_UNPOOLED: {
  type: 'Secret · web',
  description: 'Direct connection — for migrations only.',
  required: true,
},
}}
/>

<Callout type="info">
  **Per-environment branches:** Use GitHub Environments so `DATABASE_URL` points to the correct Neon branch per environment (`prod` / `pprd` / `test` / `dev`).
</Callout>

### Auth — Better Auth

<TypeTable
  type={{
BETTER_AUTH_SECRET: {
  type: 'Secret',
  description: 'Random string min 32 chars — signs sessions and tokens.',
  required: true,
},
BETTER_AUTH_URL: {
  type: 'string',
  description: 'Public app origin: https://theroyalglow.in',
  required: true,
},
GOOGLE_OAUTH_CLIENT_ID: {
  type: 'Secret',
  description: 'Google OAuth 2.0 client ID.',
  required: true,
},
GOOGLE_OAUTH_CLIENT_SECRET: {
  type: 'Secret',
  description: 'Google OAuth 2.0 client secret.',
  required: true,
},
}}
/>

### Email — Transactional (Resend)

<TypeTable
  type={{
RESEND_API_KEY: {
  type: 'Secret',
  description: 'Starts with re_ — transactional emails (invoices, confirmations).',
  required: true,
},
RESEND_FROM_EMAIL: {
  type: 'string',
  description: 'Default From header: Royal Glow <contact@theroyalglow.in>',
  required: true,
},
}}
/>

### Email — Marketing (Brevo)

<TypeTable
  type={{
BREVO_API_KEY: {
  type: 'Secret',
  description: 'Bulk/marketing email sends (offers, birthday, re-engagement).',
  required: true,
},
}}
/>

### Realtime — Ably

<TypeTable
  type={{
NEXT_PUBLIC_ABLY_KEY: {
  type: 'Public',
  description: 'Publishable key — sent to browser for client-side subscriptions.',
  required: true,
},
ABLY_PRIVATE_KEY: {
  type: 'Secret',
  description: 'Server-only key — used in API routes for publishing events.',
  required: true,
},
}}
/>

### File Storage — Cloudflare R2

<TypeTable
  type={{
R2_ACCOUNT_ID: {
  type: 'Secret',
  description: 'Cloudflare account ID.',
  required: true,
},
R2_ACCESS_KEY_ID: {
  type: 'Secret',
  description: 'R2 S3-compatible access key.',
  required: true,
},
R2_SECRET_ACCESS_KEY: {
  type: 'Secret',
  description: 'R2 S3-compatible secret key.',
  required: true,
},
R2_BUCKET_NAME: {
  type: 'string',
  description: 'Bucket name: theroyalglow-uploads',
  required: true,
},
NEXT_PUBLIC_R2_PUBLIC_URL: {
  type: 'Public',
  description: 'Public CDN URL: https://uploads.theroyalglow.in',
  required: true,
},
}}
/>

### Cache — Upstash Redis

<TypeTable
  type={{
UPSTASH_REDIS_REST_URL: {
  type: 'Secret',
  description: 'Upstash Redis REST endpoint.',
  required: true,
},
UPSTASH_REDIS_REST_TOKEN: {
  type: 'Secret',
  description: 'Upstash Redis auth token.',
  required: true,
},
}}
/>

### Queue — Upstash QStash

<TypeTable
  type={{
QSTASH_TOKEN: {
  type: 'Secret',
  description: 'Publishing token — used to enqueue jobs.',
  required: true,
},
QSTASH_CURRENT_SIGNING_KEY: {
  type: 'Secret',
  description: 'Verifies incoming QStash callbacks — current key.',
  required: true,
},
QSTASH_NEXT_SIGNING_KEY: {
  type: 'Secret',
  description: 'Verifies incoming QStash callbacks — rotated key.',
  required: true,
},
}}
/>

### Web Push — VAPID Keys

<TypeTable
  type={{
NEXT_PUBLIC_VAPID_PUBLIC_KEY: {
  type: 'Public',
  description: 'Sent to browser to create push subscription.',
  required: true,
},
VAPID_PRIVATE_KEY: {
  type: 'Secret',
  description: 'Used server-side to send push notifications.',
  required: true,
},
VAPID_SUBJECT: {
  type: 'string',
  description: 'mailto:contact@theroyalglow.in',
  required: true,
},
}}
/>

<Callout type="info">
  Generate once: `bunx web-push generate-vapid-keys`. These never change unless you intentionally rotate (which invalidates all existing push subscriptions).
</Callout>

### Cloudflare — KV + CI/CD

<TypeTable
  type={{
CLOUDFLARE_ACCOUNT_ID: {
  type: 'Secret',
  description: 'Required for wrangler deploy and KV REST API writes.',
  required: true,
},
CLOUDFLARE_API_TOKEN: {
  type: 'Secret',
  description: 'Token with Workers + KV permissions.',
  required: true,
},
CLOUDFLARE_KV_NAMESPACE_ID: {
  type: 'string',
  description: 'KV namespace ID for edge-cached service listings.',
  required: true,
},
}}
/>

### Observability — Sentry

<TypeTable
  type={{
NEXT_PUBLIC_SENTRY_DSN: {
  type: 'Public',
  description: 'Sentry DSN — browser-side error capture.',
  required: true,
},
SENTRY_AUTH_TOKEN: {
  type: 'Secret',
  description: 'CI/CD only — uploads source maps during build.',
  required: true,
},
SENTRY_ORG: {
  type: 'string',
  description: 'Your Sentry organisation slug.',
  required: true,
},
SENTRY_PROJECT: {
  type: 'string',
  description: 'Your Sentry project slug: rgss',
  required: true,
},
}}
/>

### Observability — BetterStack

<TypeTable
  type={{
BETTER_STACK_TOKEN: {
  type: 'Secret',
  description: 'Log drain source token.',
  required: true,
},
BETTER_STACK_HEARTBEAT_NIGHTLY_SALES: {
  type: 'string',
  description: 'Heartbeat for nightly sales/GST/offer/gems jobs.',
  required: false,
},
BETTER_STACK_HEARTBEAT_PPRD_SYNC: {
  type: 'string',
  description: 'GitHub Actions: pprd DB sync.',
  required: false,
},
BETTER_STACK_HEARTBEAT_REMINDERS: {
  type: 'string',
  description: 'QStash: appointment reminder scheduler.',
  required: false,
},
BETTER_STACK_HEARTBEAT_MEMBERSHIP_EXPIRY: {
  type: 'string',
  description: 'QStash: membership auto-expire + expiry alerts.',
  required: false,
},
BETTER_STACK_HEARTBEAT_SESSION_CLEANUP: {
  type: 'string',
  description: 'QStash: session cleanup.',
  required: false,
},
BETTER_STACK_HEARTBEAT_BACKUP: {
  type: 'string',
  description: 'GitHub Actions: weekly R2 backup.',
  required: false,
},
BETTER_STACK_DEPLOY_WEBHOOK: {
  type: 'string',
  description: 'Deployment marker webhook.',
  required: false,
},
BETTER_STACK_INCIDENT_WEBHOOK: {
  type: 'string',
  description: 'Incident webhook on deploy/backup failure.',
  required: false,
},
}}
/>

### Analytics — PostHog & Clarity

<TypeTable
  type={{
NEXT_PUBLIC_POSTHOG_KEY: {
  type: 'Public',
  description: 'PostHog project API key: phc_xxx',
  required: true,
},
NEXT_PUBLIC_POSTHOG_HOST: {
  type: 'Public',
  description: 'https://us.i.posthog.com',
  required: true,
},
NEXT_PUBLIC_CLARITY_ID: {
  type: 'Public',
  description: 'Microsoft Clarity project ID.',
  required: true,
},
}}
/>

### Ads & Tracking — Meta

<TypeTable
  type={{
NEXT_PUBLIC_META_PIXEL_ID: {
  type: 'Public',
  description: 'Meta Pixel ID.',
  required: true,
},
META_PIXEL_ACCESS_TOKEN: {
  type: 'Secret',
  description: 'Conversions API (CAPI) access token.',
  required: true,
},
META_CAPI_WEBHOOK_TOKEN: {
  type: 'Secret',
  description: 'Verifies incoming Meta webhook payloads.',
  required: true,
},
META_TEST_EVENT_CODE: {
  type: 'Secret · dev/pprd only',
  description: 'Routes CAPI events to test panel — omit in production.',
  required: false,
},
}}
/>

### Reporting

<TypeTable
  type={{
SLACK_WEBHOOK_URL: {
  type: 'Secret',
  description: 'Slack incoming webhook — daily/weekly sales reports.',
  required: false,
},
DAILY_REPORT_EMAIL_RECIPIENTS: {
  type: 'string',
  description: 'Comma-separated emails for reports.',
  required: false,
},
}}
/>

### CRM — AiSensy

<TypeTable
  type={{
AISENSY_API_KEY: {
  type: 'Secret',
  description: 'AiSensy API key for WhatsApp integration.',
  required: false,
},
AISENSY_WEBHOOK_SECRET: {
  type: 'Secret',
  description: 'Verifies incoming AiSensy webhook payloads.',
  required: false,
},
}}
/>

### CMS — Payload

<TypeTable
  type={{
PAYLOAD_SECRET: {
  type: 'Secret · cms',
  description: 'Random secret used by Payload to encrypt tokens and cookies.',
  required: true,
},
SERVICE_SYNC_ENABLED: {
  type: 'Private · cms',
  description:
    'Gates the service-catalogue sync hooks that mirror cms.* writes into public.*. Default enabled — only the literal string "false" disables it. Set to false while seeding, and as the primary rollback lever.',
  required: false,
  default: 'true',
},
}}
/>

### App Configuration

<TypeTable
  type={{
APP_ENV: {
  type: 'Private',
  description: 'Distinguishes the 4 deployment environments: dev / test / pprd / prod.',
  required: true,
  default: 'dev',
},
NEXT_PUBLIC_APP_URL: {
  type: 'Public',
  description: 'Prod: https://theroyalglow.in (dev: http://localhost:3000).',
  required: true,
  default: 'http://localhost:3000',
},
NEXT_PUBLIC_ADMIN_URL: {
  type: 'Public',
  description: 'Admin portal origin — Prod: https://admin.theroyalglow.in (dev: http://localhost:3001).',
  required: false,
  default: 'http://localhost:3001',
},
}}
/>

<Callout type="info">
  **`NODE_ENV` vs `APP_ENV`:** `NODE_ENV` is always `development` or `production` (Next.js convention). `APP_ENV` distinguishes between our 4 deployment environments: `dev`, `test`, `pprd`, `prod`. Use `APP_ENV` for environment-specific logic.
</Callout>

## Variable Count Summary

| Category                          | Count  |
| --------------------------------- | ------ |
| Database                          | 2      |
| Auth                              | 4      |
| Email (transactional + marketing) | 3      |
| Realtime (Ably)                   | 2      |
| File Storage (R2)                 | 5      |
| Cache (Redis)                     | 2      |
| Queue (QStash)                    | 3      |
| Web Push (VAPID)                  | 3      |
| Cloudflare KV + CI/CD             | 3      |
| Sentry                            | 4      |
| BetterStack                       | 9      |
| Analytics                         | 3      |
| Meta Pixel + CAPI                 | 4      |
| Reporting                         | 2      |
| AiSensy                           | 2      |
| Payload                           | 2      |
| App Config                        | 3      |
| **Total**                         | **56** |

## Platform Injection

| Platform               | Where to set                                               | Applied to                       |
| ---------------------- | ---------------------------------------------------------- | -------------------------------- |
| **Cloudflare Workers** | `wrangler secret` / Workers & Pages → Settings → Variables | `apps/web` + `apps/admin` (edge) |
| **Render**             | Service → Environment tab                                  | `apps/cms` (Payload CMS)         |
| **Cloud Run**          | Service → Variables & Secrets                              | `apps/invoicing` (PDF render)    |

All platforms inject vars at runtime — no `.env` file is needed or present in production.

## GitHub Secrets

Set under **Repository Settings → Secrets and variables → Actions**:

<Files>
  <File name="DATABASE_URL_DEV / TEST / PPRD / PROD" />

  <File name="DATABASE_URL_UNPOOLED_DEV / TEST / PPRD / PROD" />

  <File name="BETTER_AUTH_SECRET" />

  <File name="GOOGLE_OAUTH_CLIENT_ID / SECRET" />

  <File name="... (all other secrets)" />
</Files>

Reference in GitHub Actions:

```yaml theme={null}
env:
  DATABASE_URL: ${{ secrets.DATABASE_URL_PROD }}
  BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
```


## Related topics

- [Git Workflow](/content/docs/git-workflow.md)
- [Getting Started](/content/docs/getting-started.md)
- [Security](/content/docs/security.md)
- [Realtime (Ably)](/content/docs/realtime.md)
- [FAQ](/content/docs/faq.md)
