Skip to content

Deployment

This guide covers deploying the Activation System to production. The architecture follows a layered proxy pattern: Cloudflare (edge) → Hono API (Node.js origin) → Supabase / Redis / Operator APIs (data layer).

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        Cloudflare (Edge)                        │
│  DNS · TLS 1.3 termination · WAF · DDoS protection · Caching   │
└──────────────┬──────────────────────────┬──────────────────────┘
               │                          │
       /api/* routes               /admin/* routes
               │                          │
               ▼                          ▼
┌──────────────────────┐     ┌──────────────────────┐
│   Hono API (Node.js)  │     │  Next.js Admin/B2B    │
│   Port 3001           │     │  Dashboard            │
│                       │     │                        │
│  • BullMQ queues      │     │  • Super Admin portal  │
│  • Drizzle ORM        │     │  • B2B company portal  │
│  • Stripe/Twilio      │     │  • @supabase/ssr auth  │
└──────┬───────┬────────┘     └──────────┬─────────────┘
       │       │                          │
       ▼       ▼                          ▼
┌────────────┐ ┌────────┐     ┌────────────────────┐
│  Supabase  │ │ Redis  │     │     Supabase       │
│ PostgreSQL │ │  7.x   │     │  (Auth/Storage/    │
│  (Drizzle) │ │(BullMQ)│     │   Realtime)        │
└────────────┘ └────────┘     └────────────────────┘

The Flutter mobile app communicates with the Hono API and is distributed through app stores (not deployed as a server).

Prerequisites

RequirementVersionPurpose
Node.js22 LTSAPI server runtime (required for @hono/node-server + BullMQ TCP connections)
pnpm10+Monorepo package manager
Supabase projectManagedPostgreSQL database, Auth, Storage, Realtime
Redis instance7.xBullMQ job queues, caching, OTP storage, rate limiting
Cloudflare accountManagedDNS, TLS termination, WAF, DDoS protection
Stripe accountPayment processing (Mada, Visa, Mastercard, Apple Pay)
SMS provider accountTwilio or Taqnyat for OTP delivery

Environment Configuration

Production uses the same Zod-validated environment variables as development (defined in apps/api/src/config/env.ts), but with critical differences. See the Configuration guide for the full variable reference.

Key Production Differences

VariableDevelopmentProduction
NODE_ENVdevelopmentproduction
DATABASE_URLPooler on port 6543Same pooler URL, SSL enforced with rejectUnauthorized: true, TLS 1.3 minimum
DATABASE_URL_DIRECTPort 5432 for migrationsSame direct URL, SSL enforced, TLS 1.3 minimum
REDIS_URLredis://localhost:6379rediss:// with TLS
PAYMENT_PROVIDERmockstripe
SMS_PROVIDERmocktwilio or taqnyat
STCPAY_PROVIDERmockstcpay (if STC Pay is enabled)
ENCRYPTION_KEYPlaceholderSecurely generated 64-char hex key (AES-256)
CORS_ORIGINShttp://localhost:3000,http://localhost:3001,http://localhost:8888Comma-separated list of real owned production origins

The email backend is not in this table because it is not an environment variable. It is stored in the database and changed at runtime through PATCH /admin/plugins/email. A fresh deployment starts on the mock backend, which logs instead of sending, so configure SMTP through the admin portal before go-live. See Configuration.

Required Production Secrets

These variables must be set in the deployment platform's secret manager — never in code or unencrypted config:

  • DATABASE_URL and DATABASE_URL_DIRECT — Supabase pooler and direct connection strings
  • SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY — Supabase project credentials
  • SUPABASE_JWT_SECRET — JWT signing secret from Supabase dashboard
  • ENCRYPTION_KEY — Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  • ENCRYPTION_KEY_PREVIOUS — Only during a key rotation. Holds the retired key so rows written under it stay readable, and is removed once those ciphertexts are gone
  • REDIS_URL
  • STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET — When PAYMENT_PROVIDER=stripe
  • TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER — When SMS_PROVIDER=twilio
  • TAQNYAT_API_KEY, TAQNYAT_SENDER — When SMS_PROVIDER=taqnyat
  • STCPAY_MERCHANT_ID, STCPAY_API_KEY, STCPAY_BASE_URL — When STCPAY_PROVIDER=stcpay
  • EXCHANGE_RATE_API_KEY — When FX_PROVIDER=exchangerate-api, which production requires whenever the eSIM store is enabled

The app crashes at startup if any required variable is missing (Zod fail-fast validation in apps/api/src/config/env.ts).

Database Deployment

The database is Supabase managed PostgreSQL. All schema changes go through Drizzle ORM migrations.

Migration Commands

Two migration systems run sequentially via packages/database/src/migrate.ts:

  1. Drizzle Kit migrations — Schema DDL generated by drizzle-kit generate (stored in packages/database/drizzle/)
  2. Custom SQL migrations — Hand-written SQL for RLS policies, triggers, Realtime publication (stored in packages/database/drizzle/custom/)

Run migrations against the direct connection (port 5432), not the pooler (port 6543):

bash
# Generate a new migration from schema changes
pnpm db:generate

# Apply all migrations (Drizzle Kit + custom SQL)
pnpm db:migrate

# Verify the live Supabase database after migration
pnpm db:verify

The db:migrate script (tsx src/migrate.ts) does the following:

  1. Runs drizzle-kit migrate against DATABASE_URL_DIRECT (pgBouncer on port 6543 is incompatible with DDL)
  2. Applies custom SQL migrations from drizzle/custom/ in order (0000_, 0001_, etc.)
  3. Tracks applied custom migrations in a custom_migrations table with content hashing (skips already-applied, re-applies and updates the stored hash when content changes)
  4. Leaves live database verification to pnpm db:verify, which checks RLS flags, expected policies, custom migration hashes, Supabase Realtime publication, required triggers, and PostgreSQL extensions.

RLS Policies

Row Level Security policies are enforced at the database level. The executable policy SQL lives in packages/database/drizzle/custom/0004_apply_rls_policies.sql. Key policies include:

  • consent_records_user_insert — Authenticated users can INSERT their own consent records.
  • User/order/payment ownership policies — End users can read their own records while staff roles have scoped access.
  • Admin/support policiessuper_admin, support, activation_officer, and b2b_admin roles have role-specific access.
  • Auth triggerhandle_new_user() syncs auth.users to public.users on signup with SECURITY DEFINER and pgcrypto for phone hashing.
  • Cascade deletehandle_deleted_user() cleans up all public data when a user is deleted from Supabase Auth.

Run pnpm db:verify after every deployment to prove those policies are installed on the actual Supabase project.

Supabase Realtime

Custom migration 0003_enable_realtime.sql adds activations and activation_status_events to the supabase_realtime publication for WebSocket-based status tracking. The activations table uses REPLICA IDENTITY FULL so UPDATE/DELETE events include old values.

TLS Enforcement

Production database connections enforce TLS 1.3 minimum (defined in packages/database/src/utils/connection.ts):

ts
ssl: process.env.NODE_ENV === 'production'
  ? { rejectUnauthorized: true, minVersion: 'TLSv1.3' }
  : false,

API Server Deployment

Build

The API compiles from TypeScript to JavaScript using tsc:

bash
# From monorepo root — builds all workspace packages first, then the API
pnpm build

# Or build the API specifically
pnpm --filter @activation-sys/api build

The build outputs to apps/api/dist/. The tsconfig.json in apps/api/ targets ES2022 with NodeNext module resolution, outputting declarations and source maps.

Start

bash
NODE_ENV=production node apps/api/dist/index.js

The server binds to the port specified by the PORT environment variable (default 3001). On startup, it:

  1. Validates all environment variables via Zod (crashes if invalid)
  2. Registers the production SMS provider (registerProductionSmsProvider())
  3. Registers the production email provider (registerProductionEmailProvider())
  4. Schedules the package sync job (every 5 minutes by default)
  5. Initializes the status notification queue
  6. Starts listening via @hono/node-server

Health Check

The GET /health endpoint checks database and Redis connectivity with timeouts (5s for database, 3s for Redis):

json
{
  "status": "ok",
  "version": "1.0.0",
  "timestamp": "2026-05-05T08:10:54.000Z",
  "checks": {
    "database": "ok",
    "redis": "ok"
  }
}

When a dependency check fails, the response returns HTTP 503 with "status": "degraded":

json
{
  "status": "degraded",
  "version": "1.0.0",
  "timestamp": "2026-05-05T08:10:54.000Z",
  "checks": {
    "database": "error",
    "redis": "ok"
  }
}

Implementation: apps/api/src/routes/health.ts.

Graceful Shutdown

The server handles SIGTERM and SIGINT signals (in apps/api/src/index.ts):

  1. Closes the HTTP server (server.close())
  2. Disconnects Redis (await disconnectRedis())
  3. Closes the database connection pool (await closePool())
  4. Exits with code 0

This ensures in-flight requests complete and connections are cleaned up before the process exits. Container orchestrators should send SIGTERM and allow a grace period (30s recommended).

Admin Dashboard Deployment

The Next.js admin dashboard runs as a separate server behind Cloudflare.

Build

bash
# From monorepo root
pnpm build

# Or build specifically
pnpm --filter @activation-sys/admin build

Next.js outputs the standalone build to apps/admin/.next/standalone/. For production, enable standalone output mode in next.config.ts:

ts
const nextConfig: NextConfig = {
  output: 'standalone',  // Produces a self-contained server bundle
};

Start

bash
# Using the standalone server (smaller footprint, no node_modules required)
NODE_ENV=production node apps/admin/.next/standalone/server.js

The standalone mode bundles only the necessary dependencies, reducing the production image size significantly compared to running next start with the full node_modules.

Auth

The admin dashboard uses @supabase/ssr for server-side authentication with cookie-based sessions in Next.js App Router. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are available as environment variables.

Docker Deployment

Docker Compose

The project's docker-compose.yml at the monorepo root builds production app images and runs the apps as separate containers plus Redis 7.x. Each app container pins its own internal PORT; only the published host side is configurable. This prevents a deploy-level PORT=3001 from making every image listen on the API port.

yaml
services:
  admin:
    image: skyte-admin:latest
    environment:
      PORT: 3000
    ports:
      - "${ADMIN_HOST_PORT:-3000}:3000"

  api:
    image: skyte-api:latest
    environment:
      PORT: 3001
    ports:
      - "${API_HOST_PORT:-3001}:3001"
    depends_on:
      redis:
        condition: service_healthy

  web-b2c:
    image: skyte-web-b2c:latest
    environment:
      PORT: 3002
    ports:
      - "${WEB_B2C_HOST_PORT:-3002}:3002"

  site:
    image: skyte-site:latest
    environment:
      PORT: 3003
    ports:
      - "${SITE_HOST_PORT:-3003}:3003"

  docs:
    image: skyte-docs:latest
    environment:
      PORT: 5173
    ports:
      - "${DOCS_HOST_PORT:-5173}:5173"

  redis:
    image: redis:7-alpine
    ports:
      - "${REDIS_HOST_PORT:-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
    restart: unless-stopped
bash
docker compose up -d      # Start built apps and Redis in background
docker compose down        # Stop and remove containers

Production Docker for API Server

A Dockerfile for the API server follows this pattern:

dockerfile
FROM node:22-slim AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY packages/database/package.json packages/database/
COPY packages/queue/package.json packages/queue/
COPY packages/shared/package.json packages/shared/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build --filter @activation-sys/api

FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/package.json ./
ENV NODE_ENV=production
EXPOSE 3001
CMD ["node", "dist/index.js"]

BullMQ and Redis

BullMQ (in packages/queue/) requires persistent TCP connections to Redis via ioredis. This is why the API runs on Node.js rather than Cloudflare Workers — Cloudflare Workers' V8 isolates do not support persistent TCP sockets.

Production Redis should use a managed provider with TLS (rediss:// URLs). The ioredis client is configured with maxRetriesPerRequest: null as required by BullMQ.

Cloudflare Configuration

Cloudflare sits in front of both the API origin and the admin dashboard, providing TLS termination, WAF, DDoS protection, and caching. The API itself runs on a Node.js origin server — Cloudflare is the proxy, not the compute platform.

DNS Setup

Point the application domain to Cloudflare nameservers. Configure DNS records for the API and admin origins:

RecordTypeTargetProxy
<production-host>AAPI origin IPProxied (orange cloud)
<admin-production-host>AAdmin origin IPProxied (orange cloud)

SSL/TLS Termination

  • Set SSL mode to Full (strict) — Cloudflare terminates TLS 1.3 for clients, then connects to the origin over HTTPS with a valid certificate
  • Minimum TLS version: TLS 1.3 (per security requirements)
  • Enable Always Use HTTPS to redirect HTTP → HTTPS
  • Enable Automatic HTTPS Rewrites

WAF Rules

Configure Web Application Firewall rules to protect against common attacks:

  • SQL injection — Block requests with SQL patterns in query parameters
  • XSS — Block requests with script injection patterns
  • Bot management — Challenge or block known bot user agents
  • Country access — Restrict admin dashboard access to expected regions (Saudi Arabia + team locations)

Rate Limiting

Cloudflare provides rate limiting at the edge, complementing the application-level rate limiting in apps/api/src/middleware/rate-limit.ts:

  • Global rate limit — Protect the origin from traffic spikes
  • API-specific rules — Stricter limits on /auth/* endpoints to prevent brute-force OTP attempts
  • Admin dashboard — Moderate limits for authenticated admin users

Origin Server Protection

  • Authenticated Origin Pulls — Ensure only Cloudflare can connect to the origin server by verifying Cloudflare's client certificate
  • IP Access Rules — Allow only Cloudflare IP ranges to reach the origin server
  • Configure the origin server's firewall to accept inbound HTTPS only from Cloudflare IP ranges

Monitoring and Health Checks

Health Endpoint

GET /health — Returns system status with dependency checks.

Healthy response (HTTP 200):

json
{
  "status": "ok",
  "version": "1.0.0",
  "timestamp": "2026-05-05T08:10:54.000Z",
  "checks": {
    "database": "ok",
    "redis": "ok"
  }
}

Degraded response (HTTP 503):

json
{
  "status": "degraded",
  "version": "1.0.0",
  "timestamp": "2026-05-05T08:10:54.000Z",
  "checks": {
    "database": "error",
    "redis": "ok"
  }
}

Each check has a timeout (5s for database, 3s for Redis). A timed-out check is reported as "error".

Application Logging

The API uses Pino for structured JSON logging (pino@^9.0.0 in apps/api/package.json). All log entries include:

  • Timestamp
  • Log level (trace, debug, info, warn, error, fatal)
  • Request ID (from Hono middleware)
  • Structured fields (userId, route, method, statusCode, duration)

Production logs should be shipped to a centralized logging service for querying and alerting.

Analytics

PostHog (posthog-node for API, posthog_flutter for mobile) tracks:

  • Activation funnel events (package selection → identity verification → payment → activation)
  • Payment events and conversion metrics
  • Feature flag evaluation
CheckMethodAlert Condition
API healthGET /health every 30sStatus 503 or timeout
Database connectivityHealth check — database field"error" status
Redis connectivityHealth check — redis field"error" status
API error ratePino structured logs5xx rate > 1% over 5 minutes
BullMQ queue depthBullMQ metricsQueue depth > 1000 or jobs stuck > 10 minutes
SSL certificate expiryAutomated check< 30 days to expiry

Post-Deployment Verification

After deploying, verify each component is functioning correctly:

API Server

  • [ ] GET /health returns HTTP 200 with "status": "ok" and both checks passing
  • [ ] GET /health returns the expected version field
  • [ ] CORS headers present on preflight requests (OPTIONS)
  • [ ] API responds within 2 seconds at the 95th percentile
  • [ ] Graceful shutdown works: send SIGTERM, confirm process exits cleanly

Database

  • [ ] Migrations applied successfully with pnpm db:migrate
  • [ ] Live Supabase database verification passed with pnpm db:verify
  • [ ] RLS policies installed on all expected public tables
  • [ ] TLS 1.3 enforced on production connections
  • [ ] Supabase Realtime publication includes activations and activation_status_events
  • [ ] Connection pooling active on port 6543 (verify via DATABASE_URL)

Admin Dashboard

  • [ ] Admin dashboard loads at the production URL
  • [ ] Supabase Auth login works (cookie-based sessions via @supabase/ssr)
  • [ ] Admin, B2C web, and landing site pages render without console errors

Cloudflare

  • [ ] DNS resolves correctly for <production-host>
  • [ ] TLS 1.3 is the minimum negotiated version (check with curl -vvv https://<production-origin>)
  • [ ] WAF blocks test payloads (e.g., ?id=1' OR '1'='1)
  • [ ] Rate limiting triggers on burst requests
  • [ ] Origin pull authentication passes (only Cloudflare can reach origin)
  • [ ] HTTP requests redirect to HTTPS

Redis / BullMQ

  • [ ] Redis connection uses TLS in production (rediss:// URL)
  • [ ] BullMQ can enqueue and process test jobs
  • [ ] Package sync job runs on schedule (check logs for [API] Package sync job scheduled)
  • [ ] Status notification queue initialized (check logs for [API] Status notification queue initialized)

Payment Integration

  • [ ] Stripe webhook endpoint receives test events (/webhooks route)
  • [ ] Webhook signature verification passes with STRIPE_WEBHOOK_SECRET
  • [ ] Test payment completes end-to-end in Stripe test mode before switching to live

SMS Provider

  • [ ] OTP delivery works in production (Twilio or Taqnyat)
  • [ ] SMS logs confirm delivery (check Pino structured logs)

Rollback Procedure

If a deployment causes issues:

  1. API rollbacks — Redeploy the previous Docker image tag or revert the git commit and rebuild
  2. Database rollbacks — Drizzle does not auto-generate down migrations. Write a manual SQL migration to revert schema changes and add it to drizzle/custom/
  3. Admin dashboard rollbacks — Redeploy the previous standalone build or use the hosting platform's rollback feature
  4. Cloudflare — Use the Cloudflare dashboard to revert rule changes; DNS TTL changes may take up to 48 hours to propagate

Always verify the GET /health endpoint returns "ok" after any rollback.

Frontend Deployment Surfaces

WorkspaceBuild outputDeployment notes
apps/admin.next/**Deploy as the admin/B2B Next.js app behind authenticated routes.
apps/web-b2c.next/**Deploy as the customer web activation app; configure API origin and secure cookie domain intentionally.
apps/sitedist/**Deploy as a static Vite landing site through CDN/edge hosting.
docs.vitepress/dist/**Deploy separately from customer-facing surfaces; keep internal docs access-controlled where required.

Production CORS must include only deployed frontend origins that call apps/api. TLS termination remains at Cloudflare, and all sensitive activation, payment, identity, and compliance logic stays server-side.

Internal documentation - Activation System