Skip to content

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

VariableRequiredDefaultDescription
DATABASE_URLYesSupabase PostgreSQL pooler connection string (port 6543). Used by the API at runtime for all Drizzle queries.
SUPABASE_URLYesSupabase project API URL (e.g. https://abc123.supabase.co)
SUPABASE_ANON_KEYYesPublic anonymous key from Supabase dashboard
SUPABASE_SERVICE_ROLE_KEYYesAdmin service role key from Supabase dashboard — keep secret, bypasses RLS
REDIS_URLYesRedis connection string (e.g. redis://localhost:6379)
ENCRYPTION_KEYYesAES-256 encryption key — exactly 64 hex characters (32 bytes). See Generating an Encryption Key
ENCRYPTION_KEY_PREVIOUSNoThe 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_ENVNodevelopmentEnvironment mode. One of: development, production, test
PORTNo3001API server listen port (coerced to number)
CORS_ORIGINSNohttp://localhost:3000,http://localhost:3001,http://localhost:8888Comma-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.

VariableRequiredDefaultDescription
FEATURE_ESIMNotrueComplete 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_STORENotrue outside production; false in productionGlobal 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.

VariableRequiredDefaultDescription
ESIM_STORE_PROVIDERSConditionalmock-esim outside production, empty in productionComma-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

VariableRequiredDefaultDescription
SUPABASE_JWT_SECRETYesJWT signing secret from Supabase dashboard (Settings → API → JWT Secret)
JWT_EXPIRY_SECONDSNo3600JWT 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.

VariableRequiredDefaultDescription
RATE_LIMIT_WINDOW_MSNo900000General rate-limit window in milliseconds (15 minutes)
RATE_LIMIT_MAX_REQUESTSNo100Max requests per IP per window for the general limiter
AUTH_RATE_LIMIT_WINDOW_MSNo900000Anonymous auth-surface rate-limit window in milliseconds (15 minutes)
AUTH_RATE_LIMIT_MAX_REQUESTSNo100Max requests per IP per window for the anonymous auth surface
VariableRequiredDefaultDescription
MAGIC_LINK_BASE_URLNohttps://app.skyte.sa/inviteBase URL the mobile app deep-links into with ?token= for B2B roster invites
APP_STORE_URL_IOSNohttps://apps.apple.com/app/skyteApp Store link sent in the activation invite email/SMS
APP_STORE_URL_ANDROIDNohttps://play.google.com/store/apps/details?id=sa.skyte.appGoogle Play link sent in the activation invite email/SMS

SMS Provider

VariableRequiredDefaultDescription
SMS_PROVIDERNomockSMS backend. One of: mock, twilio, taqnyat
TWILIO_ACCOUNT_SIDConditionalRequired when SMS_PROVIDER=twilio
TWILIO_AUTH_TOKENConditionalRequired when SMS_PROVIDER=twilio
TWILIO_PHONE_NUMBERConditionalRequired when SMS_PROVIDER=twilio
TAQNYAT_API_KEYConditionalRequired when SMS_PROVIDER=taqnyat
TAQNYAT_SENDERConditionalRequired 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

VariableRequiredDefaultDescription
PACKAGE_SYNC_INTERVAL_MSNo300000Package data sync interval in milliseconds (5 minutes default)

Payment Gateway (Stripe)

VariableRequiredDefaultDescription
PAYMENT_PROVIDERNomockPayment backend. One of: mock, stripe. mock is allowed for development/test only and is rejected when NODE_ENV=production.
STRIPE_SECRET_KEYConditionalRequired when PAYMENT_PROVIDER=stripe
STRIPE_PUBLISHABLE_KEYConditionalRequired when PAYMENT_PROVIDER=stripe
STRIPE_WEBHOOK_SECRETConditionalRequired when PAYMENT_PROVIDER=stripe
STRIPE_API_VERSIONNo2025-12-31.basilStripe API version string

FX Rates

VariableRequiredDefaultDescription
FX_PROVIDERNomockFX rate source. One of: mock, exchangerate-api. mock is refused in production when FEATURE_ESIM_STORE is enabled.
EXCHANGE_RATE_API_KEYConditionalRequired when FX_PROVIDER=exchangerate-api
FX_REFRESH_INTERVAL_MSNo86400000FX rate refresh interval in milliseconds (24 hours default)

STC Pay

VariableRequiredDefaultDescription
STCPAY_PROVIDERNomockSTC Pay backend. One of: mock, stcpay
STCPAY_MERCHANT_IDConditionalRequired when STCPAY_PROVIDER=stcpay
STCPAY_API_KEYConditionalRequired when STCPAY_PROVIDER=stcpay
STCPAY_BASE_URLConditionalRequired when STCPAY_PROVIDER=stcpay

.env File Example

A minimal .env for local development (place at the monorepo root):

env
# ── 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=mock

The canonical .env.example in the repository root lists all required variables. Copy it and fill in your values:

bash
cp .env.example .env

Test 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:

json
{
  "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=stripe

STC 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=stcpay

When 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-api

FX Rates (production gate):

FX_PROVIDER=mock is not allowed in production when FEATURE_ESIM_STORE is enabled

Generating an Encryption Key

ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes for AES-256-GCM). Generate one with Node.js:

bash
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.

yaml
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: 5s

Start and stop:

bash
docker compose up -d      # Start built apps and Redis in background
docker compose down        # Stop and remove containers
docker compose logs -f     # Follow stack logs

The 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:

VariablePortPurpose
DATABASE_URL6543Runtime — Supabase pooler (pgBouncer transaction mode). Used by the API for all queries via Drizzle. Configured in apps/api/src/config/database.ts.
DATABASE_URL_DIRECT5432Migrations — 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:

ts
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 .env

The 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:

ts
ssl: env.NODE_ENV === 'production'
  ? { rejectUnauthorized: true }
  : undefined,

Required vs Optional Settings Summary

Settings that cause a startup crash if missing:

VariableValidationError if missing
DATABASE_URLz.string().url()Zod parse error
SUPABASE_URLz.string().url()Zod parse error
SUPABASE_ANON_KEYz.string()Zod parse error
SUPABASE_SERVICE_ROLE_KEYz.string()Zod parse error
REDIS_URLz.string().url()Zod parse error
ENCRYPTION_KEYz.string().length(64)Zod parse error (must be exactly 64 chars)
SUPABASE_JWT_SECRETz.string().min(1)"SUPABASE_JWT_SECRET is required"
STRIPE_SECRET_KEYConditional"STRIPE_SECRET_KEY is required when PAYMENT_PROVIDER=stripe"
STRIPE_PUBLISHABLE_KEYConditional"STRIPE_PUBLISHABLE_KEY is required when PAYMENT_PROVIDER=stripe"
STRIPE_WEBHOOK_SECRETConditional"STRIPE_WEBHOOK_SECRET is required when PAYMENT_PROVIDER=stripe"
STCPAY_MERCHANT_IDConditional"STCPAY_MERCHANT_ID is required when STCPAY_PROVIDER=stcpay"
STCPAY_API_KEYConditional"STCPAY_API_KEY is required when STCPAY_PROVIDER=stcpay"
STCPAY_BASE_URLConditional"STCPAY_BASE_URL is required when STCPAY_PROVIDER=stcpay"
EXCHANGE_RATE_API_KEYConditional"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

VariableDefaultSet In
NODE_ENVdevelopmentenv.tsz.enum(...).default('development')
PORT3001env.tsz.coerce.number().default(3001)
JWT_EXPIRY_SECONDS3600env.tsz.coerce.number().default(3600)
FEATURE_ESIMtrueenv.tsz.enum(['true', 'false']).default('true')
FEATURE_ESIM_STOREtrue outside production; false in production when unsetenv.ts — resolved after parse from NODE_ENV unless explicitly set
SMS_PROVIDERmockenv.tsz.enum(...).default('mock')
PACKAGE_SYNC_INTERVAL_MS300000env.tsz.coerce.number().default(300000)
ESIM_SYNC_INTERVAL_MS300000env.tsz.coerce.number().default(300000)
FX_PROVIDERmockenv.tsz.enum(...).default('mock')
FX_REFRESH_INTERVAL_MS86400000env.tsz.coerce.number().default(86400000)
PAYMENT_PROVIDERmockenv.tsz.enum(...).default('mock')
STRIPE_API_VERSION2025-12-31.basilenv.tsz.string().default('2025-12-31.basil')
STCPAY_PROVIDERmockenv.tsz.enum(...).default('mock')
MAGIC_LINK_BASE_URLhttps://app.skyte.sa/inviteenv.tsz.string().default(...)
APP_STORE_URL_IOShttps://apps.apple.com/app/skyteenv.tsz.string().default(...)
APP_STORE_URL_ANDROIDhttps://play.google.com/store/apps/details?id=sa.skyte.appenv.tsz.string().default(...)
RATE_LIMIT_WINDOW_MS900000env.tsz.coerce.number().default(15 * 60 * 1000)
RATE_LIMIT_MAX_REQUESTS100env.tsz.coerce.number().default(100)
AUTH_RATE_LIMIT_WINDOW_MS900000env.tsz.coerce.number().default(15 * 60 * 1000)
AUTH_RATE_LIMIT_MAX_REQUESTS100env.tsz.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_URL must point to the Supabase pooler on port 6543
  • DATABASE_URL_DIRECT must point to port 5432 for migrations
  • Redis connection likely uses rediss:// (TLS) with a managed provider
  • NODE_ENV=production enables SSL on the database pool (rejectUnauthorized: true)
  • The payment, activation, and store workers run inside the API process. Every environment except test starts them after Redis connects. A dedicated worker deployment is a tracked follow-up
  • All provider variables must use real credentials (not mock)
  • ENCRYPTION_KEY must 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:

VariableUsed byPurpose
NEXT_PUBLIC_API_BASE_URLapps/web-b2c, future Next.js browser clientsAPI base URL for browser requests
NEXT_PUBLIC_SITE_URLNext.js web appsLink back to public landing site
VITE_API_BASE_URLapps/site, future Vite clientsAPI base URL for Vite browser requests
VITE_B2C_WEB_URLapps/siteLanding page CTA target for B2C web
VITE_SITE_URLVite clientsDeployment-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.

Internal documentation - Activation System