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_URLYes—Supabase PostgreSQL pooler connection string (port 6543). Used by the API at runtime for all Drizzle queries.
SUPABASE_URLYes—Supabase project API URL (e.g. https://abc123.supabase.co)
SUPABASE_ANON_KEYYes—Public anonymous key from Supabase dashboard
SUPABASE_SERVICE_ROLE_KEYYes—Admin service role key from Supabase dashboard — keep secret, bypasses RLS
REDIS_URLYes—Redis connection string (e.g. redis://localhost:6379)
ENCRYPTION_KEYYes—AES-256 encryption key — exactly 64 hex characters (32 bytes). See Generating an Encryption Key
ENCRYPTION_KEY_PREVIOUSNo—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_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_SECRETYes—JWT 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, 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.

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_MSNo900000Auth-surface rate-limit window in milliseconds (15 minutes)
AUTH_RATE_LIMIT_MAX_REQUESTSNo100Max requests per window for the auth surface: per IP on the anonymous routes, per user on the phone-change pair
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

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.

VariableRequiredDefaultDescription
SMS_PROVIDERNomockFallback SMS provider before the registry is saved. One of: mock, twilio, taqnyat
TWILIO_ACCOUNT_SIDNo—Fallback Twilio keys
TWILIO_AUTH_TOKENNo—Fallback Twilio keys
TWILIO_PHONE_NUMBERNo—Fallback Twilio keys
TAQNYAT_API_KEYNo—Fallback Taqnyat key
TAQNYAT_SENDERNo—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 ​

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

Payment gateway (Mock / Moyasar / Paymob) ​

VariableRequiredDefaultDescription
PAYMENT_PROVIDERNomockInitial fallback: mock, moyasar or paymob. Saved plugin settings take precedence. Unconfigured production starts disabled.
PUBLIC_API_URLOptionalUnsetPins 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 ​

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

.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

# ── 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:

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",
  "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-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"
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.ts — z.enum(...).default('development')
PORT3001env.ts — z.coerce.number().default(3001)
JWT_EXPIRY_SECONDS3600env.ts — z.coerce.number().default(3600)
FEATURE_ESIMtrueenv.ts — z.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.ts — z.enum(...).default('mock')
PACKAGE_SYNC_INTERVAL_MS300000env.ts — z.coerce.number().default(300000)
ESIM_SYNC_INTERVAL_MS300000env.ts — z.coerce.number().default(300000)
FX_PROVIDERmockenv.ts — z.enum(...).default('mock')
FX_REFRESH_INTERVAL_MS86400000env.ts — z.coerce.number().default(86400000)
PAYMENT_PROVIDERmockenv.ts — z.enum(...).default('mock')
MAGIC_LINK_BASE_URLhttps://app.skyte.sa/inviteenv.ts — z.string().default(...)
APP_STORE_URL_IOShttps://apps.apple.com/app/skyteenv.ts — z.string().default(...)
APP_STORE_URL_ANDROIDhttps://play.google.com/store/apps/details?id=sa.skyte.appenv.ts — z.string().default(...)
RATE_LIMIT_WINDOW_MS900000env.ts — z.coerce.number().default(15 * 60 * 1000)
RATE_LIMIT_MAX_REQUESTS100env.ts — z.coerce.number().default(100)
AUTH_RATE_LIMIT_WINDOW_MS900000env.ts — z.coerce.number().default(15 * 60 * 1000)
AUTH_RATE_LIMIT_MAX_REQUESTS100env.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_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