Hosted SaaS (auto-provisioned tenants)
Optional, off by default. Lets you sell HeadlessCore as a subscription: a customer pays a flat recurring price and gets their own live WordPress + Next.js site, provisioned automatically on infrastructure you control (a Coolify-managed server).
Status: Phase 1 (manual-trigger provisioning) only. The Stripe subscription webhook, suspension/cancellation lifecycle, and customer-facing signup page described in the plan are not built yet — this doc covers what exists today: provisioning a tenant by hand, for testing the pipeline.
Architecture
scripts/provision-tenant.ts (manual, for now)
→ src/lib/provisioning.ts: provisionTenant()
→ mints a pro license key (src/lib/license.ts)
→ src/lib/coolify.ts: creates a WordPress Docker Compose app
→ src/lib/coolify.ts: creates a Next.js Nixpacks app
→ polls both until healthy
→ src/lib/platformDb.ts: records the tenant row (Postgres)
This is separate infrastructure from the multi-tenant feature (Multi-Tenant) — that feature routes one Next.js deployment to multiple WordPress backends by domain. This feature provisions an entirely separate WordPress + Next.js pair per customer.
Why a real database
Every other feature in this project deliberately avoids a database (WordPress's own MySQL, or Stripe object metadata, is always the datastore instead). This is the first feature that needs genuine mutable state — tracking tenant status, Coolify resource IDs, and subscription IDs — independent of any single tenant's own WordPress being reachable. It's a dedicated Postgres instance, not shared with any tenant's content database.
Resource limits (noisy-neighbor guard)
Every per-tenant container is capped so one tenant's traffic spike can't
starve every other tenant on the same server: docker-compose.saas-tenant.yml
sets deploy.resources.limits (512MB / 0.5 vCPU) on both db and
wordpress; provisioning.ts calls coolify.setResourceLimits() with the
same cap on the Next.js app. That's ~1.5GB RAM / 1.5 vCPU reserved per
tenant — use this figure for capacity planning (a server's usable RAM,
minus ~2GB for Coolify + the platform Postgres, divided by 1.5GB, is
roughly how many tenants it supports before you need to scale up).
Setup (Phase 0 — manual, one-time)
Before provisioning a real tenant:
- Server capacity. Each tenant needs its own WordPress + MySQL +
Next.js containers. A small (2GB) VPS supports maybe one or two
tenants at that density — either upgrade the server, or point each
tenant's WordPress at a remote/managed MySQL instead (adapt
docker-compose.saas-tenant.ymlthe same waydocker-compose.prod.remote-db.ymldoes for the single-tenant case) to cut each tenant's footprint. - Create a Coolify API token with resource-creation scope.
- Note your Coolify server UUID and project UUID.
- Point
*.{your-platform-domain}DNS at your server, and configure a DNS-01-capable provider in Coolify's Traefik settings so a wildcard TLS cert can be issued (HTTP-01 can't issue wildcards — this is what makes instant per-tenant subdomains work without a per-tenant ACME round trip). - Provision a Postgres resource in Coolify for the platform database.
- If your repo is private, connect Coolify's GitHub App integration.
Env vars
See .env.example's "Hosted SaaS provisioning" section:
PLATFORM_DATABASE_URL, COOLIFY_BASE_URL, COOLIFY_API_TOKEN,
COOLIFY_SERVER_UUID, COOLIFY_PROJECT_UUID, PLATFORM_DOMAIN,
SAAS_GIT_REPOSITORY. Also requires LICENSE_SIGNING_PRIVATE_KEY_PEM
(same one used by Stripe Licensing).
Migrate the platform database
PLATFORM_DATABASE_URL="postgres://..." node scripts/migrate-platform-db.js
Creates the tenants table. Safe to re-run.
Provision a tenant by hand
cd nextjs-starter-kit
npm run provision-tenant -- --email="jane@example.com"
This bypasses Stripe entirely (fake subscription IDs) — it's purely for exercising the Coolify provisioning path end to end. On success it prints the tenant's WordPress URL, Next.js URL, and license key.
Domain scheme
{slug}.{PLATFORM_DOMAIN}— the tenant's Next.js frontendcms-{slug}.{PLATFORM_DOMAIN}— the tenant's WordPress admin/GraphQL
Both are one level under the wildcard domain, so both are covered by the same TLS cert.
What's NOT built yet (see the project plan for the full design)
- Stripe subscription checkout + webhook wiring — provisioning only triggers manually today, not automatically on payment.
- Suspension on
past_due/cancellation (headlesscore_maintenance_modeis the intended mechanism — see Licensing — but nothing calls it yet for SaaS tenants). - Customer-facing
/hostedsignup page, welcome email, and Stripe Billing Portal self-serve link. saas_tenantWordPress-side audit CPT.- Reconciliation for tenants stuck mid-
provisioningafter a process restart.
Known unverified assumptions (confirm before relying on them)
- Exact Coolify API field names for setting env vars in bulk and for the
Nixpacks app's monorepo base directory —
src/lib/coolify.tsis written against Coolify's public API docs, not yet exercised against a live instance. - Whether Coolify's per-resource Docker Compose project scoping actually
namespaces
docker-compose.saas-tenant.yml's bare volume names (db_data,wp_data,wp_uploads) across tenants — inspectdocker volume lson the server after a second tenant provisions, before trusting it for a third.