Derives from: ADR-0020, ADR-0019, ADR-0035, ADR-0054.
LearnStack targets three deployment models from one codebase, one Helm chart, one set
of container images. The differentiator across modes is configuration + component
wiring + IEntitlementProvider implementation choice — never application code.
Support state (2026-09-17). The
DeploymentModeenum has five values and the composition root branches on all five.DevelopmentandSaaShave wired and tested foundation paths; this is not a production-readiness claim. Entitlements still useNullEntitlementProviderin every mode.Dedicated,SelfHostedOnlineandSelfHostedAirGappedare prepared seams, not supported deployments, until Phase 11 builds their adapters and integration suites, per ADR-0035. See § 5 for what "prepared seam" means concretely. The rest of this document describes target topologies. The production Helm chart, image release pipeline, licence adapters and migration runbooks below are not shipped artifacts; their examples must not be treated as operational instructions.
The diagram and comparison table describe target topologies and provider choices. The current support matrix distinguishes them from shipped foundation wiring.
flowchart TB
subgraph SaaS["SaaS — LearnStack hosted, many tenants"]
SaaSLS["LearnStack.Host pods<br/>(shared, multi-tenant)"]
SaaSHub["LearnStack Hub<br/>(LearnStack hosted)"]
SaaSDB[("Shared Postgres,<br/>RLS-isolated rows<br/>per tenant")]
end
subgraph Dedicated["Dedicated — LearnStack hosted, single tenant"]
DedLS["LearnStack.Host pods<br/>(dedicated to customer)"]
DedHub["LearnStack Hub<br/>(LearnStack hosted)"]
DedDB[("Dedicated Postgres<br/>per customer")]
end
subgraph Self["Self-Hosted — Customer hosted"]
SelfLS["LearnStack.Host pods<br/>(customer infra)"]
SelfHub["LearnStack Hub<br/>(optional: LearnStack hosted OR customer)"]
SelfDB[("Customer Postgres")]
end
SaaSLS --> SaaSDB
DedLS --> DedDB
SelfLS --> SelfDB
SaaSLS -. phone-home .-> SaaSHub
DedLS -. phone-home .-> DedHub
SelfLS -. phone-home or signed key .-> SelfHub
| Aspect | SaaS | Dedicated | Self-Hosted |
|---|---|---|---|
| Operator | LearnStack | LearnStack | Customer |
| Infrastructure | LearnStack cloud | LearnStack cloud, customer-isolated | Customer's K8s / VMs |
| Tenants per instance | Many | One | One (or many, customer's choice) |
| Database | Shared Postgres, RLS-isolated | Dedicated Postgres per customer | Customer-owned Postgres |
| Hub | LearnStack-hosted central Hub | LearnStack-hosted central Hub | Customer-hosted OR LearnStack-hosted (with phone-home) OR signed license key (air-gapped) |
| Entitlement | HubEntitlementProvider (online) |
HubEntitlementProvider (online) |
HubEntitlementProvider (online) OR SignedLicenseKeyEntitlementProvider (air-gapped) |
| Connectivity | Always online | Always online | Online preferred; air-gapped supported |
| Plan | Starter / Growth / Scale | Enterprise (custom) | Enterprise (custom) |
| Upgrade cadence | LearnStack-controlled rolling | LearnStack-coordinated per customer | Customer-controlled (Helm upgrade window) |
| Data residency | Region per LearnStack offering | Customer-selected region | Customer-owned data sovereignty |
| Compliance posture | LearnStack-defined caps | Customer-negotiated caps | Customer-defined caps |
Topology:
Internet
│
▼
[ APISIX gateway pool (2+ replicas) ]
│
▼ host: app.learnstack.dev | {tenant-custom-domain}
[ LearnStack.Host pods (auto-scaled 2-8) ]
│
├──► PostgreSQL (managed, RLS policies enforced)
├──► Valkey (managed, multi-tenant via key prefix)
├──► SeaweedFS (multi-tenant via key prefix)
├──► Meilisearch (per-tenant filter + per-locale index)
├──► Keycloak (single realm with multi-tenant claims)
├──► LiveKit (shared SFU pool)
└──► Dapr sidecars
▼ (separate gateway path) host: hub.learnstack.dev
[ LearnStack Hub pods ]
│
└──► Hub Postgres (separate from tenant DB)
-
Tenant onboarding: Customer signs up at
hub.learnstack.dev, picks plan, Stripe checkout, Hub provisions tenant in LearnStack viaPOST /api/internal/tenants. Total time: < 1 minute. -
Tenant URL: Starts on
{slug}.learnstack.app; upgrades to custom domain on Growth+ tier. -
Failure isolation: two different problems, and only one of them is solved today. Correctness isolation — no tenant can read or write another tenant's rows — is covered by tenant context + EF query filters + Row Level Security + architecture tests (ADR-0003 Amendment 3). Resource isolation — no tenant can starve another of connections, CPU, or job workers — is not covered by any of those. Row Level Security is a visibility predicate: it filters rows, and a filtered query consumes exactly as much of the database as an unfiltered one. A single tenant running an unbounded report can hold a connection, saturate the pool, and degrade every other tenant on the instance while every isolation test stays green.
What exists today: the API's own anonymous limiter runs before host classification. P02d-5 uses a shared 60/min canonical-IP quota for direct peers and authenticated-hop visitors, plus a 600/min physical-peer ceiling (API Standards § Request and Response Limits). APISIX's
limit-req, keyed onremote_addr, fronts the API only once the gateway lands, which ADR-0035 gates to Phase 11. Neither policy is per-tenant fairness: many client IPs can serve one tenant, while NAT users share an IP quota. ADR-0054 accounting is implemented — 2026-10-09; both review rounds passed. Remediation Step 1 owns visitor limiters under one process-local owner lock. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. The 600/min peer budget bounds successful peer acquisitions and new visitor allocations shared by visitors/tenants, not all attempts or refusal-response work. It is an allocation-rate bound, not a fixed global cardinality cap. This accounting does not bound total incoming network/refusal cost. Security Standards own lifecycle, replay and Retry-After obligations; the remediation plan owns implementation evidence.What is required, and where it lives: resource fairness is Phase 11 —
statement_timeoutper connection class, per-tenant connection-pool partitioning, query cost ceilings on report and export paths, Hangfire queue fairness so one tenant's bulk import cannot monopolise the workers, and per-tenant rate limiting keyed on the resolved tenant rather than on the client address. No earlier phase owns this work, and until Phase 11 ships it, SaaS multi-tenancy is protected against leakage but not against contention.
Topology — same as SaaS but isolated per customer:
[ LearnStack-managed Kubernetes namespace per customer ]
│
├──► Dedicated PostgreSQL instance (no shared rows; still RLS-protected for org scope)
├──► Dedicated Valkey instance
├──► Dedicated SeaweedFS instance
├──► Dedicated Meilisearch
├──► Dedicated Keycloak realm (could be in shared Keycloak cluster with realm-per-tenant)
├──► Shared LiveKit pool (with per-tenant resource caps) OR dedicated SFU
└──► Dapr sidecars
- Tenant: Exactly one. Domain model still includes
Tenantaggregate with a default organization; tenant_id is fixed. - Use case: Mid-to-large customers with data residency requirements, regulatory isolation, or volume that justifies dedicated infra.
- Hub: Same central LearnStack Hub manages the dedicated tenant; phone-home as normal.
Topology:
[ Customer-owned Kubernetes cluster, customer admin ]
│
├──► PostgreSQL (customer-managed)
├──► Valkey (customer-managed)
├──► SeaweedFS or S3-compatible (customer-managed)
├──► Meilisearch (customer-managed)
├──► Keycloak (customer-managed; LearnStack ships pre-configured realm export)
├──► LiveKit (customer-managed OR fall back to LiveKit Cloud)
├──► Dapr sidecars (deployed by LearnStack Helm chart)
└──► Optional: customer-hosted LearnStack Hub (rare; usually they consume LearnStack-
hosted Hub via phone-home, or use signed license key air-gapped)
- Customer's deployment phones home to LearnStack's Hub daily.
HubEntitlementProviderregistered;Entitlementprojection refreshed viaPOST /api/v1/internal/license/refresh.- 30-day grace period on phone-home failure.
- Customer's deployment has no outbound network access.
SignedLicenseKeyEntitlementProviderregistered; reads RSA-signed license key from a filesystem location (/var/learnstack/license/current.lic).- License key embeds the entitlement projection; the key itself is the entitlement source.
- License updates via signed-file delivery (USB stick, secure email, customer's SFTP).
- SIGHUP to the LearnStack process triggers immediate re-read of the license file.
- Revocation list also delivered out-of-band when revocations occur.
Tenant provisioning without Hub. Hub is the canonical issuer of tenant_id
(28-platform-tenant-organization.md § Hub ↔ LearnStack ownership matrix).
In air-gapped Self-Hosted mode there is no Hub at the customer's site, so the
bootstrap rule is:
- The signed
.licfile carriestenant_id(and a defaultorganization_id) as claims. The LearnStack-side CLI (learnstack tenant init) reads the.lic, verifies the signature, and creates thetenants+organizationsrows with the IDs from the license claims. - Subsequent tenants (rare in air-gapped enterprise) require a new
.licissued by the LearnStack-hosted Hub and delivered out-of-band — the customer never mintstenant_idthemselves. - In
SelfHostedOnlinemode the CLI exists too, but defers to Hub viaPOST /api/internal/tenantsso Hub remains authoritative.
Customer responsibilities (air-gapped):
- Customer manages their own TLS certs (cert-manager + local CA OR customer-provided certs).
- Customer manages their own Vault (or alternative secret store).
- Customer manages their own backups, DR, monitoring.
Target LearnStack deliverables for the supported Phase 11 air-gapped release:
- Helm chart with air-gapped mode pre-configured.
- Pre-signed license keys per agreement.
- Documentation runbook (
docs/operations/hub-on-prem-setup.md). - Optional: bundled container registry mirror script for air-gapped image pulls.
The LearnStack process reads Deployment:Mode from configuration at startup. Five
values exist; the provider comments below describe the target, not today's registration:
public enum DeploymentMode
{
Development, // Local dev; NullEntitlementProvider
SaaS, // LearnStack-hosted multi-tenant; HubEntitlementProvider
Dedicated, // LearnStack-hosted single-tenant; HubEntitlementProvider
SelfHostedOnline, // Customer-hosted, phone-home enabled; HubEntitlementProvider
SelfHostedAirGapped // Customer-hosted, no phone-home; SignedLicenseKeyEntitlementProvider
}The composition root uses this value for mode-specific wiring. Entitlements currently
use NullEntitlementProvider in every mode: it enables every feature and leaves
every limit unlimited. Supported foundation paths therefore do not enforce Hub plan
restrictions; the provider choices shown above are ADR-0020's target. Modules never read
the enum — Modules_Do_Not_Reference_DeploymentMode enforces that the composition root
owns the selection.
| Value | State | What that means concretely |
|---|---|---|
Development |
Supported foundation | Foundation host-boot path covered by the integration suite |
SaaS |
Supported foundation | Foundation host-boot path covered by the integration suite; real Hub entitlement integration belongs to Phase 02c and has not shipped |
Dedicated |
Prepared seam | Shares SaaS's Sentry error tracker and refuses startup without a Sentry DSN; uses the default entitlement provider; no dedicated-topology integration suite or operational runbook |
SelfHostedOnline |
Prepared seam | Uses Sentry when a DSN is supplied and NoOp error tracking otherwise; uses the default entitlement provider; real phone-home and its failure-mode tests are absent |
SelfHostedAirGapped |
Prepared seam | Local exception-file capture and suppression of network OTLP exporters exist; the signed-licence adapter, full telemetry file exporters and no-egress operational suite are not shipped |
A prepared seam accepts the value and supplies foundation wiring, including some
mode-specific behavior, but lacks the adapters and operational proofs needed for a
supported deployment. The entitlement provider remains the same default used in
Development. ADR-0035's "wired end to end" statement is read together with its
explicit default-provider and adapter-trigger table: it does not claim Hub entitlement
integration or full production readiness. It is honest to design for a seam; it is not
honest to sell it. Each seam becomes a supported mode in
Phase 11 when
its trigger fires — for SelfHostedAirGapped, that trigger is a signed Self-Hosted
contract (ADR-0035).
The same ADR adds a rule that this document must obey: a deployment mode without a signed contract cannot be the deciding factor in a technical choice. It may break a tie between otherwise-equal options. It may not, on its own, reject a dependency or justify an abstraction.
-
BFF session store — ADR-0070 selects app-local
BffSessionStore/InMemoryBffSessionStorefor P02b-7's one native web process. Restart signs sessions out in every mode. The SaaS auto-scaled topology above is a target, not multi-process session support. Phase 11 supplies a distributed adapter before multiple web processes serve one session, with atomic refresh/revocation/interruption qualification. -
Hub URL — target: LearnStack-hosted for SaaS / Dedicated / SelfHostedOnline, customer-hosted only where separately arranged, or absent for SelfHostedAirGapped. The current default entitlement provider makes no Hub call.
-
Secret store —
ConfigurationSecretProvidertoday in every mode; the Vault-backed provider is demand-gated to Phase 11 behindISecretProvider, triggered when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets. -
Telemetry sink — non-air-gapped modes currently use the configured OTLP endpoint;
SelfHostedAirGappedwires no network exporter. The target operator is LearnStack for SaaS / Dedicated and the customer for Self-Hosted. Full telemetry file exporters for the air-gapped mode belong to Phase 11; local exception-file capture exists today. -
Event transport —
InProcessEventBustoday in every mode; the Dapr pub/sub component and its Kafka backend land in Phase 11, triggered by a second process needing to consume an integration event. See 15-event-and-outbox.md.
The target LearnStack Helm chart (deploy/helm/) configures all modes through
values.yaml.
The chart is not implemented yet; these are configuration sketches for Phase 11:
# values-saas.yaml
deployment:
mode: SaaS
hub:
url: https://hub.learnstack.dev
# mTLS client cert + HMAC secret + JWT signing key; no API key (ADR-0034)
credentialsRef: { name: hub-internal-api, key: bundle }
postgres:
managed: true
connectionRef: { name: postgres-creds, key: connection-string }
dapr:
kafka:
brokers: kafka-managed.example.com:9092
auth: scram-sha-512# values-dedicated-acme-corp.yaml
deployment:
mode: Dedicated
hub:
url: https://hub.learnstack.dev
postgres:
managed: true
connectionRef: { name: postgres-acme, key: connection-string }
customDomain:
primary: learn.acmecorp.com
dataResidency: eu-west# values-self-hosted-airgapped.yaml
deployment:
mode: SelfHostedAirGapped
hub: {} # no Hub
licenseKey:
path: /var/learnstack/license/current.lic
revocationListPath: /var/learnstack/license/revocations.json
postgres:
managed: false
customerProvidedSecret: postgres-creds
imageRegistry: registry.customer.internal/learnstack
telemetry:
otlpEndpoint: otel-collector.customer.internal:4317Same chart. Same templates. Different values.
A small list of behaviours change across modes. This is the target table.
NullEntitlementProvider remains registered in every mode today; Hub and signed-licence
adapters follow the owners and triggers in ADR-0035. The table does not claim those
adapters or the production deployment automation are already running.
| Behaviour | SaaS | Dedicated | Self-Hosted Online | Self-Hosted Air-Gapped |
|---|---|---|---|---|
IEntitlementProvider |
Hub | Hub | Hub | SignedLicenseKey |
ISecretProvider (target) |
Vault | Vault | Vault | Vault OR file |
ISecretProvider (today) |
ConfigurationSecretProvider in every mode — the Vault adapter is demand-gated to Phase 11 |
← | ← | ← |
| Phone-home enabled | Yes | Yes | Yes | No |
| Outbound HTTP to Hub | Yes | Yes | Yes | No |
| OTel collector endpoint | LearnStack-hosted | LearnStack-hosted | Customer-hosted | Customer-hosted |
| Cert provisioning | LearnStack-managed | LearnStack-managed | Hub-driven (online) | Customer-managed |
| Helm chart values file | values-saas.yaml |
values-dedicated-{customer}.yaml |
values-self-hosted.yaml |
values-self-hosted-airgapped.yaml |
Planned customer-driven paths require Phase 11's migration runbooks and restore proofs:
- SaaS → Dedicated: Hub-orchestrated. New dedicated cluster provisioned; customer's tenant data exported through a tenant-scoped export procedure, restored into dedicated Postgres; DNS cutover; tenant_id remains the same.
- Dedicated → Self-Hosted: Customer takes over operation. LearnStack provides customer with Helm chart values + Postgres dump + Keycloak realm export.
- Self-Hosted → SaaS: Reverse import. Less common; one-time engagement.
Cross-mode migration is designed as engineering-assisted, not automated. A consistent
data model is a prerequisite, not a proven export procedure. The runbook must account
for tenant-scoped rows, shared identity references, media and outstanding operations.
PostgreSQL's pg_dump does not apply row security by default; its
--enable-row-security option
also has role and restore-format constraints. A shared Keycloak realm export is not a
tenant-scoped identity export or a
complete backup.
The target release model runs all LearnStack environments on the same container image
(learnstack/learnstack-api:<git-sha>). Dapr sidecar image
(daprio/daprd:<version>) is also identical across modes.
Phase 11 owns the production image pipeline; today's CI does not publish these images.
The target pipeline builds one image per merge to main, tags it with the git sha and
a SemVer release tag when a release is cut, and references it from Helm by tag.
These image names are illustrative:
ghcr.io/learnstack/learnstack-api:v1.0.0
ghcr.io/learnstack/learnstack-api:v1.0.1
ghcr.io/learnstack/learnstack-hub-api:v1.0.0
ghcr.io/learnstack/learnstack-hub-api:v1.0.1
ghcr.io/learnstack/learnstack-web:v1.0.0
ghcr.io/learnstack/learnstack-hub-operator-portal:v1.0.0
For Self-Hosted Air-Gapped, the planned bundle process loads images into the customer's
own registry. tools/airgapped-image-bundle.sh names an intended Phase 11 artifact;
the script does not exist yet.
Target operating policy, not an implemented deployment pipeline:
| Mode | Cadence | Trigger |
|---|---|---|
| SaaS | Continuous (every merge to main becomes a deployable build) | Automated GitOps; staged rollout: dev → staging → 10% prod → 100% prod |
| Dedicated | Coordinated per customer; typically weekly | LearnStack-led upgrade window |
| Self-Hosted Online | Customer-controlled | Customer schedules Helm upgrade; can lag SaaS by months |
| Self-Hosted Air-Gapped | Customer-controlled, signed-bundle delivery | LearnStack signs and delivers bundle; customer applies |
To be authored:
docs/operations/tenant-operations.md— Tenant provisioning (per mode), suspend, archive, terminate.docs/operations/helm-installation.md— Self-Hosted Helm chart installation guide.docs/operations/hub-on-prem-setup.md— Optional customer-hosted Hub OR signed license key air-gapped setup.docs/operations/license-key-management.md— RSA key generation, distribution, revocation.docs/operations/migration-orchestration.md— Schema migration coordination.docs/operations/security-incident.md— Security incident response.docs/operations/data-export-and-portability.md— GDPR data export per tenant.docs/operations/resource-fairness.md— the SaaS contention controls named in § 2:statement_timeoutclasses, pool partitioning, query cost ceilings, Hangfire queue fairness, per-tenant rate limits.
- Multi-cloud abstraction. LearnStack runs on Kubernetes; cloud-provider choice is customer's. We don't abstract over cloud APIs.
- Sovereign-cloud parity. LearnStack ships in the SaaS configuration aimed at major cloud regions; sovereign-cloud (Gaia-X, …) is in scope only via Self-Hosted.
- Tenant migration tooling beyond mode-change paths. Tenant CRUD belongs to Hub; cross- cluster tenant migration is a one-time engineering engagement, not a product feature.
- ADR-0020 — Triple Deployment + Hybrid License.
- ADR-0019 — LearnStack Hub.
- ADR-0003 Amendment 3 — what Row Level Security does and does not guarantee.
- ADR-0035 — which modes are supported, which are seams, and what promotes a seam.
- 26-hybrid-license-model.md — license payload + lifecycle.
- 24-learnstack-hub.md — Hub architecture.
- 04-technical-architecture.md — overall stack.
- Phase 11: Production Hardening, Operations, and Scale — owns resource fairness and the three prepared seams.
- ADR-0049 — Institution Sites and an Optional Course Marketplace — Proposed product direction; does not change this support matrix or authorize cross-installation marketplace participation.