Configuration
All configuration is managed through environment variables validated at startup by Zod in apps/api/src/config/env.ts. The application crashes immediately if any required variable is missing or invalid (fail-fast behavior).
Environment Variables
Core
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | Yes | — | Supabase PostgreSQL pooler connection string (port 6543). Used by the API at runtime for all Drizzle queries. |
SUPABASE_URL | Yes | — | Supabase project API URL (e.g. https://abc123.supabase.co) |
SUPABASE_ANON_KEY | Yes | — | Public anonymous key from Supabase dashboard |
SUPABASE_SERVICE_ROLE_KEY | Yes | — | Admin service role key from Supabase dashboard — keep secret, bypasses RLS |
REDIS_URL | Yes | — | Redis connection string (e.g. redis://localhost:6379) |
ENCRYPTION_KEY | Yes | — | AES-256 encryption key — exactly 64 hex characters (32 bytes). See Generating an Encryption Key |
ENCRYPTION_KEY_PREVIOUS | No | — | The old key during a rotation. Decryption falls back to it when the current key fails, so rows written before the rotation stay readable. Unset it once the old ciphertexts are gone — re-encrypted or aged out by retention |
NODE_ENV | No | development | Environment mode. One of: development, production, test |
PORT | No | 3001 | API server listen port (coerced to number) |
CORS_ORIGINS | No | http://localhost:3000,http://localhost:3001,http://localhost:8888 | Comma-separated list of allowed browser origins. Set real owned origins in production. |
eSIM Feature Lanes
The activation and store surfaces use separate flags. Both accept only the string values true and false; leaving a flag unset lets the API apply the defaults below.
| Variable | Required | Default | Description |
|---|---|---|---|
FEATURE_ESIM | No | true | Complete eSIM activation surface: eSIM validation, activation creation, and QR/profile delivery. Set false to disable eSIM activation while physical-SIM activation remains available. |
FEATURE_ESIM_STORE | No | true outside production; false in production | Global eSIM store lane: catalog sync, browse, purchase, admin store operations, and FX refresh. An explicit true or false overrides the environment-based default. |
These flags are independent: FEATURE_ESIM=false disables the complete eSIM activation surface but does not by itself disable the store, while FEATURE_ESIM_STORE=false disables store routes and store jobs but does not disable physical or complete-lane activation behavior.
| Variable | Required | Default | Description |
|---|---|---|---|
ESIM_STORE_PROVIDERS | Conditional | mock-esim outside production, empty in production | Comma-separated eSIM provider plugin keys registered at boot by the provider registry. Must be set in production whenever FEATURE_ESIM_STORE=true — the boot fails with an enabled store and no registered provider. mock-esim is refused in production, and an unknown key fails the boot. |
Auth / JWT
| Variable | Required | Default | Description |
|---|---|---|---|
SUPABASE_JWT_SECRET | Yes | — | JWT signing secret from Supabase dashboard (Settings → API → JWT Secret) |
JWT_EXPIRY_SECONDS | No | 3600 | JWT token expiry in seconds (1 hour default) |
Rate Limiting
Two independently tunable guards share the same counting core. The general guard is mounted on all traffic but exempts requests carrying a web session cookie with X-Client-Type: web (so a logged-in admin/portal session doesn't share a bucket with anonymous traffic). The auth-surface guard applies only to /auth/register, /auth/login, /auth/verify-otp, and /auth/magic-link, regardless of client type.
| Variable | Required | Default | Description |
|---|---|---|---|
RATE_LIMIT_WINDOW_MS | No | 900000 | General rate-limit window in milliseconds (15 minutes) |
RATE_LIMIT_MAX_REQUESTS | No | 100 | Max requests per IP per window for the general limiter |
AUTH_RATE_LIMIT_WINDOW_MS | No | 900000 | Anonymous auth-surface rate-limit window in milliseconds (15 minutes) |
AUTH_RATE_LIMIT_MAX_REQUESTS | No | 100 | Max requests per IP per window for the anonymous auth surface |
B2B Magic Links
| Variable | Required | Default | Description |
|---|---|---|---|
MAGIC_LINK_BASE_URL | No | https://app.skyte.sa/invite | Base URL the mobile app deep-links into with ?token= for B2B roster invites |
APP_STORE_URL_IOS | No | https://apps.apple.com/app/skyte | App Store link sent in the activation invite email/SMS |
APP_STORE_URL_ANDROID | No | https://play.google.com/store/apps/details?id=sa.skyte.app | Google Play link sent in the activation invite email/SMS |
SMS Provider
| Variable | Required | Default | Description |
|---|---|---|---|
SMS_PROVIDER | No | mock | SMS backend. One of: mock, twilio, taqnyat |
TWILIO_ACCOUNT_SID | Conditional | — | Required when SMS_PROVIDER=twilio |
TWILIO_AUTH_TOKEN | Conditional | — | Required when SMS_PROVIDER=twilio |
TWILIO_PHONE_NUMBER | Conditional | — | Required when SMS_PROVIDER=twilio |
TAQNYAT_API_KEY | Conditional | — | Required when SMS_PROVIDER=taqnyat |
TAQNYAT_SENDER | Conditional | — | Required when SMS_PROVIDER=taqnyat |
Email Provider
The email backend is not configured by environment variable. It is stored in the database, in the plugin_settings row keyed email, and is read at send time by EmailProviderSettingsService.
Change it through the admin API, GET and PATCH /admin/plugins/email, or the Plugins screen in the admin portal. POST /admin/plugins/email/test sends a test message using the saved settings.
Two backends are implemented: mock (the default when no row is saved, which logs instead of sending) and smtp (host, port, TLS flag, credentials, and from name/address, all stored on the same row). The SMTP password is written encrypted and is never returned by the read endpoint, which reports only whether one is configured.
Package Sync
| Variable | Required | Default | Description |
|---|---|---|---|
PACKAGE_SYNC_INTERVAL_MS | No | 300000 | Package data sync interval in milliseconds (5 minutes default) |
Payment Gateway (Stripe)
| Variable | Required | Default | Description |
|---|---|---|---|
PAYMENT_PROVIDER | No | mock | Payment backend. One of: mock, stripe. mock is allowed for development/test only and is rejected when NODE_ENV=production. |
STRIPE_SECRET_KEY | Conditional | — | Required when PAYMENT_PROVIDER=stripe |
STRIPE_PUBLISHABLE_KEY | Conditional | — | Required when PAYMENT_PROVIDER=stripe |
STRIPE_WEBHOOK_SECRET | Conditional | — | Required when PAYMENT_PROVIDER=stripe |
STRIPE_API_VERSION | No | 2025-12-31.basil | Stripe API version string |
FX Rates
| Variable | Required | Default | Description |
|---|---|---|---|
FX_PROVIDER | No | mock | FX rate source. One of: mock, exchangerate-api. mock is refused in production when FEATURE_ESIM_STORE is enabled. |
EXCHANGE_RATE_API_KEY | Conditional | — | Required when FX_PROVIDER=exchangerate-api |
FX_REFRESH_INTERVAL_MS | No | 86400000 | FX rate refresh interval in milliseconds (24 hours default) |
STC Pay
| Variable | Required | Default | Description |
|---|---|---|---|
STCPAY_PROVIDER | No | mock | STC Pay backend. One of: mock, stcpay |
STCPAY_MERCHANT_ID | Conditional | — | Required when STCPAY_PROVIDER=stcpay |
STCPAY_API_KEY | Conditional | — | Required when STCPAY_PROVIDER=stcpay |
STCPAY_BASE_URL | Conditional | — | Required when STCPAY_PROVIDER=stcpay |
.env File Example
A minimal .env for local development (place at the monorepo root):
# ── Core ──────────────────────────────────────────────────────────
DATABASE_URL=postgresql://postgres.ucrhrdhvisoyzngdomqj:[YOUR-DB-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres
DATABASE_URL_DIRECT=postgresql://postgres.ucrhrdhvisoyzngdomqj:[YOUR-DB-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:5432/postgres
SUPABASE_URL=https://ucrhrdhvisoyzngdomqj.supabase.co
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SUPABASE_JWT_SECRET=your-jwt-secret
REDIS_URL=redis://localhost:6379
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
NODE_ENV=development
PORT=3001
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:8888
# ── eSIM feature lanes ────────────────────────────────────────────
# Complete eSIM activation surface; defaults to true when unset.
FEATURE_ESIM=true
# Store lane defaults on outside production and off in production when unset.
# FEATURE_ESIM_STORE=true
# ── JWT ───────────────────────────────────────────────────────────
JWT_EXPIRY_SECONDS=3600
# ── Rate limiting ─────────────────────────────────────────────────
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
AUTH_RATE_LIMIT_WINDOW_MS=900000
AUTH_RATE_LIMIT_MAX_REQUESTS=100
# ── B2B magic links ────────────────────────────────────────────────
MAGIC_LINK_BASE_URL=https://app.skyte.sa/invite
APP_STORE_URL_IOS=https://apps.apple.com/app/skyte
APP_STORE_URL_ANDROID=https://play.google.com/store/apps/details?id=sa.skyte.app
# ── SMS (mock for local dev) ──────────────────────────────────────
SMS_PROVIDER=mock
# ── Package Sync ──────────────────────────────────────────────────
PACKAGE_SYNC_INTERVAL_MS=300000
# ── FX rates (mock for local dev) ─────────────────────────────────
FX_PROVIDER=mock
EXCHANGE_RATE_API_KEY=
FX_REFRESH_INTERVAL_MS=86400000
# ── Payment (mock for local dev) ──────────────────────────────────
PAYMENT_PROVIDER=mock
STRIPE_API_VERSION=2025-12-31.basil
# ── STC Pay (mock for local dev) ──────────────────────────────────
STCPAY_PROVIDER=mockThe canonical .env.example in the repository root lists all required variables. Copy it and fill in your values:
cp .env.example .envTest Mode Defaults
When NODE_ENV=test, the Zod parser is bypassed and hardcoded defaults are used so the test suite runs without a real .env file. These defaults are defined in apps/api/src/config/env.ts:
{
"DATABASE_URL": "postgresql://test:test@localhost:5432/test",
"SUPABASE_URL": "https://test.supabase.co",
"SUPABASE_ANON_KEY": "test-anon-key",
"SUPABASE_SERVICE_ROLE_KEY": "test-service-role-key",
"REDIS_URL": "redis://localhost:6379",
"ENCRYPTION_KEY": "0000000000000000000000000000000000000000000000000000000000000000",
"NODE_ENV": "test",
"PORT": 3001,
"SUPABASE_JWT_SECRET": "test-jwt-secret-minimum-32-characters!!",
"JWT_EXPIRY_SECONDS": 3600,
"SMS_PROVIDER": "mock",
"FEATURE_ESIM": true,
"FEATURE_ESIM_STORE": true,
"PACKAGE_SYNC_INTERVAL_MS": 300000,
"ESIM_SYNC_INTERVAL_MS": 300000,
"FX_PROVIDER": "mock",
"EXCHANGE_RATE_API_KEY": null,
"FX_REFRESH_INTERVAL_MS": 86400000,
"PAYMENT_PROVIDER": "mock",
"STRIPE_SECRET_KEY": "sk_test_mock",
"STRIPE_PUBLISHABLE_KEY": "pk_test_mock",
"STRIPE_WEBHOOK_SECRET": "whsec_mock",
"STRIPE_API_VERSION": "2025-12-31.basil",
"STCPAY_PROVIDER": "mock",
"MAGIC_LINK_BASE_URL": "https://app.skyte.sa/invite",
"APP_STORE_URL_IOS": "https://apps.apple.com/app/skyte",
"APP_STORE_URL_ANDROID": "https://play.google.com/store/apps/details?id=sa.skyte.app",
"RATE_LIMIT_WINDOW_MS": 900000,
"RATE_LIMIT_MAX_REQUESTS": 100,
"AUTH_RATE_LIMIT_WINDOW_MS": 900000,
"AUTH_RATE_LIMIT_MAX_REQUESTS": 100
}Any of these can be overridden by setting the corresponding environment variable before running tests (e.g. DATABASE_URL=... pnpm test).
Provider-Specific Validation
Beyond Zod's schema validation, startup validation enforces that production cannot use mock payments and that credentials are present when a real provider is selected. If any credential is missing, the app throws an error at startup:
Stripe (when PAYMENT_PROVIDER=stripe):
STRIPE_SECRET_KEY is required when PAYMENT_PROVIDER=stripe
STRIPE_PUBLISHABLE_KEY is required when PAYMENT_PROVIDER=stripe
STRIPE_WEBHOOK_SECRET is required when PAYMENT_PROVIDER=stripeSTC Pay (when STCPAY_PROVIDER=stcpay):
STCPAY_MERCHANT_ID is required when STCPAY_PROVIDER=stcpay
STCPAY_API_KEY is required when STCPAY_PROVIDER=stcpay
STCPAY_BASE_URL is required when STCPAY_PROVIDER=stcpayWhen using mock providers outside production, no credentials are validated — this is the safe default for local development and CI. PAYMENT_PROVIDER=mock is rejected when NODE_ENV=production.
FX Rates (when FX_PROVIDER=exchangerate-api):
EXCHANGE_RATE_API_KEY is required when FX_PROVIDER=exchangerate-apiFX Rates (production gate):
FX_PROVIDER=mock is not allowed in production when FEATURE_ESIM_STORE is enabledGenerating an Encryption Key
ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes for AES-256-GCM). Generate one with Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Paste the output directly into your .env file as the ENCRYPTION_KEY value. Never commit a real encryption key to version control.
Docker Compose
docker-compose.yml at the monorepo root builds production app images and runs each app in a separate container plus Redis 7.x for BullMQ queuing and caching. Supabase remains managed and is configured through .env for the API container.
The API container receives an explicit variable list, so a variable that is not in that list never reaches the app even when it is set in .env. The two eSIM lanes use separate flags: FEATURE_ESIM controls complete eSIM activation (validation, creation, and QR/profile delivery), while FEATURE_ESIM_STORE controls the global store catalog, browse, purchase, admin, FX, and sync surface. Both flags are passed through without a Compose default so an unset value keeps the API's own resolution: FEATURE_ESIM defaults to true, while FEATURE_ESIM_STORE defaults to true outside production and false in production unless explicitly set.
services:
api:
image: skyte-api:latest
ports:
- "3001:3001"
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5sStart and stop:
docker compose up -d # Start built apps and Redis in background
docker compose down # Stop and remove containers
docker compose logs -f # Follow stack logsThe healthcheck uses redis-cli ping to confirm the server is ready. The ioredis client in apps/api/src/config/redis.ts is configured with maxRetriesPerRequest: null (required by BullMQ) and a retry strategy that gives up after 3 attempts.
Drizzle Configuration
Drizzle ORM uses two database connection URLs for different purposes:
| Variable | Port | Purpose |
|---|---|---|
DATABASE_URL | 6543 | Runtime — Supabase pooler (pgBouncer transaction mode). Used by the API for all queries via Drizzle. Configured in apps/api/src/config/database.ts. |
DATABASE_URL_DIRECT | 5432 | Migrations — Direct connection bypassing pgBouncer. Used by drizzle-kit for DDL operations (CREATE TABLE, ALTER TABLE). pgBouncer on port 6543 is incompatible with DDL. Configured in packages/database/drizzle.config.ts. |
The Drizzle config in packages/database/drizzle.config.ts loads DATABASE_URL_DIRECT with fallback to DATABASE_URL:
const dbUrl = process.env.DATABASE_URL_DIRECT || process.env.DATABASE_URL;
export default defineConfig({
schema: "./src/schema",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: dbUrl,
},
});If neither variable is set, the config throws:
DATABASE_URL_DIRECT or DATABASE_URL must be set in .envThe runtime database connection in apps/api/src/config/database.ts uses a pg.Pool with 10 max connections, 30s idle timeout, and 5s connect timeout. In production, SSL is enforced with rejectUnauthorized: true:
ssl: env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: undefined,Required vs Optional Settings Summary
Settings that cause a startup crash if missing:
| Variable | Validation | Error if missing |
|---|---|---|
DATABASE_URL | z.string().url() | Zod parse error |
SUPABASE_URL | z.string().url() | Zod parse error |
SUPABASE_ANON_KEY | z.string() | Zod parse error |
SUPABASE_SERVICE_ROLE_KEY | z.string() | Zod parse error |
REDIS_URL | z.string().url() | Zod parse error |
ENCRYPTION_KEY | z.string().length(64) | Zod parse error (must be exactly 64 chars) |
SUPABASE_JWT_SECRET | z.string().min(1) | "SUPABASE_JWT_SECRET is required" |
STRIPE_SECRET_KEY | Conditional | "STRIPE_SECRET_KEY is required when PAYMENT_PROVIDER=stripe" |
STRIPE_PUBLISHABLE_KEY | Conditional | "STRIPE_PUBLISHABLE_KEY is required when PAYMENT_PROVIDER=stripe" |
STRIPE_WEBHOOK_SECRET | Conditional | "STRIPE_WEBHOOK_SECRET is required when PAYMENT_PROVIDER=stripe" |
STCPAY_MERCHANT_ID | Conditional | "STCPAY_MERCHANT_ID is required when STCPAY_PROVIDER=stcpay" |
STCPAY_API_KEY | Conditional | "STCPAY_API_KEY is required when STCPAY_PROVIDER=stcpay" |
STCPAY_BASE_URL | Conditional | "STCPAY_BASE_URL is required when STCPAY_PROVIDER=stcpay" |
EXCHANGE_RATE_API_KEY | Conditional | "EXCHANGE_RATE_API_KEY is required when FX_PROVIDER=exchangerate-api" |
| — | Conditional | "FX_PROVIDER=mock is not allowed in production when FEATURE_ESIM_STORE is enabled" |
All other variables have defaults and will not cause a crash.
Defaults Reference
| Variable | Default | Set In |
|---|---|---|
NODE_ENV | development | env.ts — z.enum(...).default('development') |
PORT | 3001 | env.ts — z.coerce.number().default(3001) |
JWT_EXPIRY_SECONDS | 3600 | env.ts — z.coerce.number().default(3600) |
FEATURE_ESIM | true | env.ts — z.enum(['true', 'false']).default('true') |
FEATURE_ESIM_STORE | true outside production; false in production when unset | env.ts — resolved after parse from NODE_ENV unless explicitly set |
SMS_PROVIDER | mock | env.ts — z.enum(...).default('mock') |
PACKAGE_SYNC_INTERVAL_MS | 300000 | env.ts — z.coerce.number().default(300000) |
ESIM_SYNC_INTERVAL_MS | 300000 | env.ts — z.coerce.number().default(300000) |
FX_PROVIDER | mock | env.ts — z.enum(...).default('mock') |
FX_REFRESH_INTERVAL_MS | 86400000 | env.ts — z.coerce.number().default(86400000) |
PAYMENT_PROVIDER | mock | env.ts — z.enum(...).default('mock') |
STRIPE_API_VERSION | 2025-12-31.basil | env.ts — z.string().default('2025-12-31.basil') |
STCPAY_PROVIDER | mock | env.ts — z.enum(...).default('mock') |
MAGIC_LINK_BASE_URL | https://app.skyte.sa/invite | env.ts — z.string().default(...) |
APP_STORE_URL_IOS | https://apps.apple.com/app/skyte | env.ts — z.string().default(...) |
APP_STORE_URL_ANDROID | https://play.google.com/store/apps/details?id=sa.skyte.app | env.ts — z.string().default(...) |
RATE_LIMIT_WINDOW_MS | 900000 | env.ts — z.coerce.number().default(15 * 60 * 1000) |
RATE_LIMIT_MAX_REQUESTS | 100 | env.ts — z.coerce.number().default(100) |
AUTH_RATE_LIMIT_WINDOW_MS | 900000 | env.ts — z.coerce.number().default(15 * 60 * 1000) |
AUTH_RATE_LIMIT_MAX_REQUESTS | 100 | env.ts — z.coerce.number().default(100) |
Per-Environment Overrides
Development
All providers default to mock, so no external credentials are required. Redis runs locally via Docker Compose on redis://localhost:6379. The API starts the payment, activation, and store BullMQ workers in its own process after Redis connects, so local mock payment settlement can hand off to activation completion. The .env file is loaded from the monorepo root (../../.env relative to apps/api/) first, then from the current working directory.
Production
DATABASE_URLmust point to the Supabase pooler on port 6543DATABASE_URL_DIRECTmust point to port 5432 for migrations- Redis connection likely uses
rediss://(TLS) with a managed provider NODE_ENV=productionenables SSL on the database pool (rejectUnauthorized: true)- The payment, activation, and store workers run inside the API process. Every environment except
teststarts them after Redis connects. A dedicated worker deployment is a tracked follow-up - All provider variables must use real credentials (not
mock) ENCRYPTION_KEYmust be a securely generated 64-char hex string (not the test placeholder)- Cloudflare WAF sits in front of the API origin — configure allowed IPs and rate limits in the Cloudflare dashboard
Test
Set NODE_ENV=test to activate built-in test defaults. No .env file is required — the parser returns hardcoded safe values. Override any default by setting the environment variable explicitly before running the test command.
Frontend Public Configuration
The current frontend workspaces use public browser environment variables only for non-secret routing values:
| Variable | Used by | Purpose |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL | apps/web-b2c, future Next.js browser clients | API base URL for browser requests |
NEXT_PUBLIC_SITE_URL | Next.js web apps | Link back to public landing site |
VITE_API_BASE_URL | apps/site, future Vite clients | API base URL for Vite browser requests |
VITE_B2C_WEB_URL | apps/site | Landing page CTA target for B2C web |
VITE_SITE_URL | Vite clients | Deployment-specific site URL |
Secrets must not use NEXT_PUBLIC_ or VITE_ prefixes. Provider credentials, service-role keys, encryption keys, and payment secrets remain server-side only.
Local API CORS defaults include http://localhost:3000, http://localhost:3002, and http://localhost:3003 for admin, B2C web, and landing site development.