Skip to main content

PayCraft Self-Hosting (Enterprise)

This runbook walks you through standing up the full PayCraft platform — Postgres, GoTrue auth, Kong gateway, Edge Functions, and the Next.js dashboard — on a single host using docker compose. Time-to-first-checkout from a clean VM: ~10 minutes.

License: the server stack (infra/, server/, supabase/, dashboard/) ships under Business Source License v1.1. Production use requires an Enterprise license from MobileByteLabs — see Enterprise license.


Table of contents

  1. Prerequisites
  2. Quick start
  3. Environment variables
  4. Migration bootstrap
  5. SDK wiring
  6. Smoke test
  7. Troubleshooting
  8. Updating
  9. Enterprise license

1. Prerequisites

RequirementWhyHow to check
Docker Engine ≥ 24Runs the stackdocker --version
Docker Compose v2docker compose (no hyphen) syntaxdocker compose version
4 GB free RAMPostgres + Kong + Edge runtime + Node buildfree -m (Linux) / Activity Monitor (macOS)
5 GB free diskContainer images + Postgres volumedf -h
Ports 54321-54324 free on the hostKong (54321), Postgres (54322), reserved (54323, 54324)lsof -iTCP -sTCP:LISTEN -n -P | grep 5432
Port 3000 free on the hostDashboardlsof -iTCP:3000 -sTCP:LISTEN
opensslGenerate JWT + encryption secretsopenssl version
curlSmoke test (Section 6)curl --version
psql (optional)Migration bootstrap; falls back to docker exec if absentpsql --version

The stack has been validated on macOS 14 (Apple Silicon + Intel), Ubuntu 22.04 LTS, and Debian 12. Windows users should run inside WSL2 with the Docker Desktop backend.


2. Quick start

The minimum path from clean checkout to a green smoke test:

cd infra/selfhost

# 1. Copy the env template and edit it (DB password + Stripe Connect platform creds)
cp .env.example .env
$EDITOR .env

# 2. Generate JWT-derived keys (anon + service role) and append them to .env
./generate-keys.sh > .env.generated
cat .env.generated >> .env

# 3. Boot Postgres + GoTrue + Kong first (the bootstrap script needs the DB up)
docker compose up -d db auth kong

# 4. Wait ~30s for Postgres to finish initdb, then apply migrations
sleep 30
./migrations-bootstrap.sh

# 5. Boot the edge runtime + dashboard
docker compose up -d edge-runtime dashboard

# 6. Open the dashboard — the first user who signs up is auto-promoted to
# `owner` of a fresh tenant.
open http://localhost:3000 # macOS
# xdg-open http://localhost:3000 # Linux

The whole sequence is idempotent — re-running it on top of an existing stack upgrades it without data loss.


3. Environment variables

Every variable below MUST be set in .env before docker compose up. The compose file refuses to start if any :?required variable is empty.

VariableDescriptionHow to generate / source
PAYCRAFT_DB_PASSWORDPostgres superuser password. Stored in the paycraft-db volume — pick something strong on day 1, you cannot rotate it without a db reset.openssl rand -base64 24
PAYCRAFT_JWT_SECRETHMAC secret used by GoTrue and the Edge runtime to sign / verify every JWT. Same value must appear everywhere.openssl rand -base64 32
PAYCRAFT_ANON_KEYPublic JWT signed with PAYCRAFT_ANON_KEY = role:anon. Safe to embed in the dashboard + SDK — RLS enforces tenancy../generate-keys.sh (signs the JWT secret above)
PAYCRAFT_SERVICE_ROLE_KEYJWT signed with role:service_role — bypasses RLS. Server-side only. Never ship to a mobile app or the dashboard's NEXT_PUBLIC_* env../generate-keys.sh
PAYCRAFT_TOKEN_ENCRYPTION_KEYAES-256-GCM key used by stripe-connect to encrypt platform-account access tokens at rest. 32 raw bytes encoded as hex.openssl rand -hex 32
PAYCRAFT_OAUTH_STATE_SECRETHMAC secret for the Stripe Connect OAuth state round-trip parameter. Prevents CSRF on the connect callback.openssl rand -hex 32
PAYCRAFT_PLATFORM_STRIPE_SECRET_KEYYOUR Stripe platform secret key (sk_live_… or sk_test_…) — the account that bills your customers. Leave blank if you only use Razorpay or Custom.dashboard.stripe.com/apikeys
PAYCRAFT_PLATFORM_STRIPE_CLIENT_IDYour Stripe Connect platform OAuth client id (ca_…). Required for tenants to "Connect with Stripe" from your dashboard.dashboard.stripe.com/settings/applications
PAYCRAFT_API_URLPublic-facing URL for the Kong gateway. Used in webhook callbacks + dashboard redirects. Defaults to http://localhost:54321.Set to https://billing.yourdomain.com once you put a TLS-terminating proxy in front.
PAYCRAFT_DASHBOARD_URLPublic-facing URL for the Next.js dashboard. Used in GoTrue's SITE_URL and OAuth callbacks. Defaults to http://localhost:3000.Set to https://dashboard.yourdomain.com for prod.

Where each variable shows up

  • db, auth, edge-runtime all read PAYCRAFT_JWT_SECRET. Rotating it invalidates every outstanding session and SDK token — only do this with a scheduled maintenance window.
  • PAYCRAFT_ANON_KEY is mirrored into NEXT_PUBLIC_SUPABASE_ANON_KEY and NEXT_PUBLIC_PAYCRAFT_SUPABASE_ANON_KEY for the dashboard. It is also the value you pass to the SDK's SelfHosted(supabaseAnonKey = …) constructor.
  • PAYCRAFT_SERVICE_ROLE_KEY is consumed only by the edge-runtime and the dashboard's server-side routes. It MUST NOT leak into NEXT_PUBLIC_*.

4. Migration bootstrap

infra/selfhost/migrations-bootstrap.sh is the canonical applier. It:

  1. Connects to Postgres using PAYCRAFT_DB_PASSWORD (via local psql if present, else falls back to docker exec -i paycraft_db psql -U postgres).
  2. CREATE TABLE IF NOT EXISTS _paycraft_migrations(version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()) — the ledger that decides what has already been applied.
  3. Lists every file in server/migrations/*.sql in lexical order.
  4. For each file, checks _paycraft_migrations.version; if absent, runs the file inside a single transaction (-1 -v ON_ERROR_STOP=1) and records the version on success.
  5. Prints a summary: ✅ migrations: N applied, M skipped.

Adding a migration from upstream PayCraft

When MobileByteLabs publishes a new PayCraft release, you typically need to apply 1-N new SQL files.

cd /path/to/your/PayCraft/checkout
git pull origin main # or whatever release tag

# Re-run the bootstrap — only the new versions are applied; the ledger
# guarantees the old ones are skipped, even if they are still in the dir.
cd infra/selfhost
./migrations-bootstrap.sh

Expected output on a clean upgrade:

apply 042_add_coupons_v2
apply 043_widen_subscription_status
✅ migrations: 2 applied, 39 skipped

Inspecting applied state

docker exec paycraft_db psql -U postgres -d postgres \
-c "SELECT version, applied_at FROM _paycraft_migrations ORDER BY version;"

Recovery from a partially applied migration

Because every migration runs inside a single transaction, a failure leaves the ledger row absent — the next bootstrap retries from that file. No manual cleanup needed. If a migration fails for a non-transactional reason (e.g. CREATE INDEX CONCURRENTLY), fix the SQL upstream and re-run; the script will retry.

Never edit a migration that has already been applied in production. Add a follow-up file (NNN_fix_…sql) instead. See CLAUDE.md "Database Migrations — single source of truth".


5. SDK wiring

Point the Kotlin SDK at your self-hosted Kong gateway and pass the anon key (never the service role key):

import com.mobilebytelabs.paycraft.PayCraft
import com.mobilebytelabs.paycraft.PayCraftBackend

PayCraft.initialize(
apiKey = "pk_test_local", // generated in /settings/api-keys on your dashboard
backend = PayCraftBackend.SelfHosted(
supabaseUrl = "http://localhost:54321", // or https://billing.acme.com
supabaseAnonKey = "PASTE_YOUR_PAYCRAFT_ANON_KEY_HERE",
),
)

The SDK resolves ${supabaseUrl}/functions/v1/config?apiKey=… for its SuiteConfig fetch. Override the path via the third configPath argument if you've reverse-proxied the gateway under a sub-path.

Production hardening checklist

  • Terminate TLS in front of Kong (Caddy / nginx / Cloudflare) and switch PAYCRAFT_API_URL + PAYCRAFT_DASHBOARD_URL to https://.
  • Pin the Kotlin SDK call to your production URLs — pk_live_… API keys.
  • Configure each tenant's Stripe / Razorpay webhook endpoint to ${PAYCRAFT_API_URL}/functions/v1/{stripe-webhook|razorpay-webhook}.
  • Schedule daily off-site backups of the paycraft-db volume.

6. Smoke test

The fastest signal that the stack is alive:

curl -fsS "http://localhost:54321/functions/v1/config?apiKey=pk_test_local" | jq .

Expected: a JSON document with tenant_id, products, providers, and paywall keys — the same SuiteConfig the SDK will receive.

{
"tenant_id": "tenant-…",
"products": [],
"providers": [],
"paywall": {},
"cache_ttl_seconds": 3600
}

If you see this, the entire path (Kong → edge-runtime → Postgres → RLS-scoped RPC) is working. Wire your client app next.

Other quick probes

# Kong gateway is up
curl -fsS http://localhost:54321/ | head -c 200

# Postgres accepts connections
docker exec paycraft_db pg_isready -U postgres

# Dashboard is reachable
curl -fsSI http://localhost:3000 | head -n 1

7. Troubleshooting

Port already in use

Symptom: docker compose up -d exits with Bind for 0.0.0.0:54321 failed: port is already allocated.

# Identify the conflicting process
lsof -iTCP:54321 -sTCP:LISTEN -n -P
# Stop the conflicting service (often a stale Supabase CLI instance)
supabase stop
# Or rebind PayCraft's port in docker-compose.yaml (kong.ports[0])

The same diagnosis applies to 54322 (Postgres) and 3000 (dashboard).

Postgres container exits during boot

Symptom: paycraft_db keeps restarting; docker logs paycraft_db shows FATAL: password authentication failed or an empty data directory.

Most common causes:

  1. You changed PAYCRAFT_DB_PASSWORD after the volume was created. Postgres bakes the password into the initdb-time bytes — rotating it requires recreating the volume:
    docker compose down
    docker volume rm selfhost_paycraft-db
    docker compose up -d db
    This destroys all subscriptions data. Take a backup first.
  2. The volume was created by a prior supabase start run and is incompatible. Same fix as above.

Edge function returns 404 for /functions/v1/config

Symptom: curl …/functions/v1/config?apiKey=…{"message":"Function not found"}.

# The edge runtime mounts ../../supabase/functions read-only — confirm the
# config function is on disk:
ls ../../supabase/functions/config

# If absent, you're on an older release. Pull upstream and re-run migrations:
git pull && ./migrations-bootstrap.sh

# Otherwise the runtime probably failed to import — check the logs:
docker logs paycraft_edge_runtime --tail 100

migrations-bootstrap.sh aborts with "could not connect"

Symptom: psql: error: could not connect to server: Connection refused.

The script ran before Postgres finished its first-boot initdb. Wait 30 seconds and retry. If it persists:

docker exec paycraft_db pg_isready -U postgres # → "accepting connections"
# If "no response", inspect:
docker logs paycraft_db --tail 50

Dashboard build hangs at npm install

Symptom: paycraft_dashboard container sits at npm install for >5 min.

The container performs an npm install on every boot. On slow or rate-limited networks this can stall. Override with a host-side node_modules:

cd ../../dashboard
npm install --no-audit --no-fund
# Then re-up; the mounted node_modules is reused.
docker compose up -d dashboard

SDK throws "401 Unauthorized" from a self-hosted backend

Two common causes:

  1. The supabaseAnonKey you passed to the SDK does not match PAYCRAFT_ANON_KEY in .env. Verify they are byte-identical (including trailing newline trimming).
  2. The JWT secret changed between when the anon key was generated and now. Re-run ./generate-keys.sh and update .env (anon + service role both).

Tenant not auto-created on first signup

Confirm migration 030_create_tenants_trigger.sql (or whichever introduces handle_new_user_tenancy()) is in _paycraft_migrations. If not, re-run ./migrations-bootstrap.sh.


8. Updating

Upgrading to a newer PayCraft release on an existing self-hosted deployment:

# 1. Snapshot the database
docker exec paycraft_db pg_dump -U postgres -Fc postgres \
> backups/paycraft-$(date +%Y%m%d-%H%M).dump

# 2. Pull the new release
git fetch --tags
git checkout v<NEW_VERSION> # e.g. v2.1.0

# 3. Apply any new migrations (idempotent — only new files are applied)
cd infra/selfhost
./migrations-bootstrap.sh

# 4. Pull updated container images
docker compose pull

# 5. Recreate the dashboard with the new code (the volume mount is read-only,
# so a rebuild + restart picks up the new ../../dashboard sources)
docker compose up -d --no-deps --build dashboard edge-runtime

The whole sequence is online — Kong, GoTrue, and Postgres stay up for the duration. The dashboard is the only service that briefly restarts.

Rolling back

# 1. Stop the stack
docker compose down

# 2. Restore the dump captured in Step 1 of the upgrade
docker volume rm selfhost_paycraft-db
docker compose up -d db
sleep 10
cat backups/paycraft-YYYYMMDD-HHMM.dump | \
docker exec -i paycraft_db pg_restore -U postgres -d postgres --clean --if-exists

# 3. Check out the previous tag and re-up
git checkout v<PREVIOUS_VERSION>
docker compose up -d

Note: rolling back does not automatically reverse any migrations. If the new release added a column the old code does not understand, the column simply stays and is ignored. If it dropped or renamed a column, you need a hand-rolled down migration — open a support ticket before attempting this.


9. Enterprise license

The contents of infra/, server/, supabase/, and dashboard/ are licensed under the Business Source License v1.1 — free for development, evaluation, and non-production use; production use requires an Enterprise license from MobileByteLabs.

The SDK (cmp-paycraft/) is and will always be Apache 2.0 — you can ship it in your client apps with no commercial encumbrance.


See also