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, keyed per IP, and to the authenticated phone-change pair /auth/change-phone and /auth/verify-change-phone, keyed per signed-in user, regardless of client type. The phone-change pair is keyed per user because the admin portal calls it server-side without forwarding the browser IP.
| 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 | Auth-surface rate-limit window in milliseconds (15 minutes) |
AUTH_RATE_LIMIT_MAX_REQUESTS | No | 100 | Max requests per window for the auth surface: per IP on the anonymous routes, per user on the phone-change pair |
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 |
Email, SMS and push providers
Email, SMS and push each send through one active provider chosen in Admin → Plugins → Services, with each provider's keys saved once in the database. See Service providers.
The SMS variables below are a fallback only: they apply until an owner saves the SMS selection and keys in the admin portal, and can be removed after that.
| Variable | Required | Default | Description |
|---|---|---|---|
SMS_PROVIDER | No | mock | Fallback SMS provider before the registry is saved. One of: mock, twilio, taqnyat |
TWILIO_ACCOUNT_SID | No | — | Fallback Twilio keys |
TWILIO_AUTH_TOKEN | No | — | Fallback Twilio keys |
TWILIO_PHONE_NUMBER | No | — | Fallback Twilio keys |
TAQNYAT_API_KEY | No | — | Fallback Taqnyat key |
TAQNYAT_SENDER | No | — | Fallback SMS sender ID |
The SMTP row in plugin_settings (key = 'email') is likewise read only until email is saved through the registry.
Package Sync
| Variable | Required | Default | Description |
|---|---|---|---|
PACKAGE_SYNC_INTERVAL_MS | No | 300000 | Package data sync interval in milliseconds (5 minutes default) |
Payment gateway (Mock / Moyasar / Paymob)
| Variable | Required | Default | Description |
|---|---|---|---|
PAYMENT_PROVIDER | No | mock | Initial fallback: mock, moyasar or paymob. Saved plugin settings take precedence. Unconfigured production starts disabled. |
PUBLIC_API_URL | Optional | Unset | Pins the public HTTPS API base for gateway callbacks (/payment-return, Paymob's /webhooks/paymob). Unset, checkouts use the address each customer request reached. Use an HTTPS tunnel locally. |
Moyasar and Paymob keys, mode, webhook/HMAC secret, Paymob integration IDs and optional return override are managed in Admin → Plugins → Payments. Mock needs no keys and remains explicitly selectable. See payment setup.
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) |
.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
# ── STC Pay (mock for local dev) ──────────────────────────────────The 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",
"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
Payment credentials are validated when saving a plugin profile. First setup requires: Moyasar's three keys; Paymob's public key, secret key and card integration ID, plus the API key for production (the HMAC secret is optional). Key prefixes must match the environment. Blank key fields preserve saved encrypted values. Production without saved settings starts disabled. Stripe and direct STC Pay environment credentials are no longer read.
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" |
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') |
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.