{"id":"69bb38f53f1c7d8bc1820a0d","title":"Proposed Architecture","path":"how-to/technical-articles/2026-03-18-single-instance-multi-tenant-exploration/proposed-architecture","contentMarkdown":"# Proposed Single-Instance Multi-Tenant Architecture\n\n_____\n\nThis article describes the proposed architecture where a single Fly.io application serves all groups, with tenant resolution middleware determining which group's database and storage to use per request.\n\n## Architecture Diagram\n\n```mermaid\nflowchart TD\n    D1@{ icon: \"ngx:user\", label: \"group-a-ramblers.org.uk\", pos: \"b\", h: 48 }\n    D2@{ icon: \"ngx:user\", label: \"group-b-ramblers.org.uk\", pos: \"b\", h: 48 }\n    DN@{ icon: \"ngx:user\", label: \"...up to 500 domains\", pos: \"b\", h: 48 }\n\n    D1 --> CF\n    D2 --> CF\n    DN --> CF\n\n    CF@{ icon: \"ngx:cloudflare\", label: \"Cloudflare DNS\", pos: \"b\", h: 48 }\n    CF --> FLY\n\n    subgraph FLY[\"Fly.io Application - 2 to 4 instances\"]\n        TM[\"Tenant Resolution Middleware<br/>Host header to groupCode\"]\n        CACHE[\"Domain-to-group cache<br/>refreshed periodically\"]\n        DBR[\"Database Router<br/>groupCode to mongoose.useDb\"]\n        TM --> CACHE\n        TM --> DBR\n    end\n\n    DBR --> MDB1@{ icon: \"ngx:mongodb\", label: \"Group A DB\", pos: \"b\", h: 48 }\n    DBR --> MDB2@{ icon: \"ngx:mongodb\", label: \"Group B DB\", pos: \"b\", h: 48 }\n    DBR --> MDBN@{ icon: \"ngx:mongodb\", label: \"Group N DB\", pos: \"b\", h: 48 }\n\n    MDB1 --> S3\n    MDB2 --> S3\n    MDBN --> S3\n\n    S3@{ icon: \"ngx:aws\", label: \"Single S3 Bucket<br/>/{groupCode}/images/\", pos: \"b\", h: 48 }\n\n    style FLY fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n## Can a Single App Serve Multiple Domains?\n\n**Yes, this is technically feasible.** Fly.io (and most reverse proxies) support binding multiple custom domains and TLS certificates to a single application. The server would inspect the `Host` header on each request to determine which group context to use. This is a well-established pattern (multi-tenant SaaS).\n\nA user navigating to `group-a-ramblers.org.uk` vs `group-b-ramblers.org.uk` would see completely different branding, events, members, and content — unaware they hit the same server.\n\n**At 500 groups:** Fly.io supports hundreds of custom certificates per app. TLS termination is handled at their edge, so this scales.\n\n## Request Lifecycle\n\n```mermaid\nsequenceDiagram\n    participant Browser\n    participant Fly as Fly.io Edge\n    participant MW as Tenant Middleware\n    participant Route as Route Handler\n    participant DB as MongoDB (Group A)\n    participant SPA as Angular SPA\n\n    Browser->>Fly: GET https://group-a-ramblers.org.uk/events\n    Fly->>MW: TLS terminated, Host: group-a-ramblers.org.uk\n    MW->>MW: lookup(\"group-a-ramblers.org.uk\") to \"group-a\"\n    MW->>MW: req.groupCode = \"group-a\"\n    MW->>MW: req.db = mongoose.useDb(\"group-a\")\n    MW->>MW: req.s3Prefix = \"group-a\"\n    MW->>Route: GET /api/database/events\n    Route->>DB: Query events (Group A DB only)\n    DB-->>Route: Group A events\n    Route-->>Browser: JSON response\n    Browser->>SPA: Render with Group A config\n    SPA->>Route: GET /api/database/config\n    Route->>DB: Query config (Group A DB)\n    DB-->>Route: Group A branding, settings\n    Route-->>SPA: Group A config\n    SPA-->>Browser: Group A branded UI\n```\n\n## Coexistence: Running Both Models on the Same Codebase\n\nA critical requirement is that the multi-tenant architecture must coexist with the current per-group deployment model — both running from the same codebase simultaneously. This is achievable using a feature-flagged middleware approach.\n\n```mermaid\nflowchart TD\n    subgraph existing[\"Existing Groups - per-app deployment\"]\n        E1@{ icon: \"logos:fly-icon\", label: \"Group A app\", pos: \"b\", h: 48 }\n        E2@{ icon: \"logos:fly-icon\", label: \"Group B app\", pos: \"b\", h: 48 }\n        E3@{ icon: \"logos:fly-icon\", label: \"Group C app\", pos: \"b\", h: 48 }\n        E1 --> ED1@{ icon: \"ngx:mongodb\", label: \"Group A DB\", pos: \"b\", h: 48 }\n        E2 --> ED2@{ icon: \"ngx:mongodb\", label: \"Group B DB\", pos: \"b\", h: 48 }\n        E3 --> ED3@{ icon: \"ngx:mongodb\", label: \"Group C DB\", pos: \"b\", h: 48 }\n    end\n\n    subgraph multitenant[\"New Groups - multi-tenant instance\"]\n        MT@{ icon: \"logos:fly-icon\", label: \"Multi-tenant app\", pos: \"b\", h: 48 }\n        MT --> MD1@{ icon: \"ngx:mongodb\", label: \"Group D DB\", pos: \"b\", h: 48 }\n        MT --> MD2@{ icon: \"ngx:mongodb\", label: \"Group E DB\", pos: \"b\", h: 48 }\n        MT --> MDN@{ icon: \"ngx:mongodb\", label: \"Group N DB\", pos: \"b\", h: 48 }\n    end\n\n    IMG@{ icon: \"logos:docker-icon\", label: \"Same Docker Image\", pos: \"b\", h: 48 }\n    IMG --> existing\n    IMG --> multitenant\n\n    style existing fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style multitenant fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n**How it works:**\n\n- An environment variable (`MULTI_TENANT_ENABLED`) controls whether the tenant resolution middleware is active\n- **When off** (existing groups): the app behaves exactly as today — connects to a single database via `MONGODB_URI`, serves one group\n- **When on** (multi-tenant instance): the middleware inspects the `Host` header, resolves the group, and switches database context per request\n- Both modes run the **same codebase and the same Docker image** — the only difference is the environment variable and which secrets are configured\n\n**Migration path:**\n\n1. Add tenant middleware behind the feature flag — off by default, zero impact on existing groups\n2. Deploy a new Fly.io app with `MULTI_TENANT_ENABLED=true` and a shared admin database containing the domain-to-group lookup table\n3. Onboard new groups onto the multi-tenant instance\n4. Optionally migrate existing groups one at a time by pointing their DNS to the multi-tenant instance and adding their domain to the lookup table\n5. Decommission the old per-group app once migrated\n\nThis approach means there is **no big-bang migration** — existing groups continue running undisturbed, and the multi-tenant capability is introduced incrementally alongside them.\n\n## What Would Be Required\n\n### 1. Request-Scoped Tenant Resolution (HIGH)\n\nEvery incoming HTTP request must be mapped to a group context based on the `Host` header:\n\n- A **domain-to-group lookup table** (stored in a shared admin database or in-memory cache, refreshed periodically)\n- Express middleware that resolves the group before any route handler runs\n- The resolved group context must flow through the entire request lifecycle\n\n**At 500 groups:** The lookup table is small (500 domain to groupCode pairs). An in-memory Map refreshed every 60 seconds from a shared admin database is sufficient.\n\n### 2. Database Architecture (HIGH)\n\n**Option A: Single database, collection-level isolation**\n- All groups share one MongoDB database\n- Every document gains a `groupCode` field\n- Every query gains a `groupCode` filter\n- Risk: a missing filter leaks data across groups — this risk multiplies with 500 groups\n\n**Option B: Shared connection pool, database-per-group (recommended)**\n- Keep separate databases per group (as today)\n- Use `mongoose.connection.useDb(groupDbName)` to route queries per request\n- Models are compiled once and reused across databases\n- Benefit: maintains current data isolation model, zero risk of cross-group data leakage\n\n**At 500 groups (Option B):**\n- MongoDB Atlas supports thousands of databases per cluster\n- `useDb()` reuses the existing connection pool — it does not create new connections\n- Connection pool size: ~50–100 connections shared across all groups\n\n### 3. S3 Storage Consolidation (MEDIUM)\n\n- **Keep separate buckets**: proven model, but managing 500 IAM users/policies is operationally burdensome\n- **Single bucket with path prefixes**: `{groupCode}/images/...` — simpler at scale, recommended for 500 groups\n\n### 4. Authentication Changes (MEDIUM)\n\n- **Recommended:** Single shared `AUTH_SECRET` but include `groupCode` in the JWT payload\n- Token verification middleware checks that the token's `groupCode` matches the request's resolved `groupCode`\n- A member logged into one group's domain must NOT be authenticated on another group's domain\n\n### 5. Background Jobs (MEDIUM)\n\n**Current:** Each of the 13 Fly.io apps runs its own independent cron jobs.\n\n**Proposed:** A single scheduler manages jobs for all 500 groups with controlled concurrency.\n\n```mermaid\nflowchart TD\n    SCHED[\"Job Scheduler\"]\n    Q[\"Job Queue<br/>event-sync, email-send\"]\n    SCHED --> Q\n    Q --> W1@{ icon: \"logos:fly-icon\", label: \"Group A sync\", pos: \"b\", h: 48 }\n    Q --> W2@{ icon: \"logos:fly-icon\", label: \"Group B sync\", pos: \"b\", h: 48 }\n    Q --> W3@{ icon: \"logos:fly-icon\", label: \"Group C sync\", pos: \"b\", h: 48 }\n    Q --> WN[\"... x 500 groups<br/>concurrency: 3-5<br/>staggered schedule\"]\n\n    style SCHED fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n### 6. Frontend Changes (LOW)\n\n- No frontend code changes needed — the Angular app already loads all config at runtime from the API\n- The initial `/api/database/config` call would return different data based on which domain was accessed\n\n### 7. Admin and Onboarding (EXISTING — extend for multi-tenant)\n\nAn **environment management admin UI** already exists at `/admin/environment-management/setup`. It provides a multi-step wizard for provisioning new groups:\n\n1. **Group Selection** — create new, clone from existing, or modify an existing environment\n2. **Environment and Services** — configure app name, database, and service integrations\n3. **Admin and Options** — create the initial admin user\n4. **Review and Validate** — confirm all settings before deployment\n5. **Deploy** — provisions the Fly.io app, database, S3 bucket, DNS, and seeds initial data\n\nFor the multi-tenant model, this wizard would need to be extended to support provisioning groups within the shared instance (database creation + domain-to-group mapping) rather than creating a new Fly.io app. The core onboarding workflow already exists — it just needs a multi-tenant deployment target alongside the existing per-app target.\n","contentHtml":"<h1>Proposed Single-Instance Multi-Tenant Architecture</h1>\n<hr>\n<p>This article describes the proposed architecture where a single Fly.io application serves all groups, with tenant resolution middleware determining which group&#39;s database and storage to use per request.</p>\n<h2>Architecture Diagram</h2>\n<pre><code class=\"language-mermaid\">flowchart TD\n    D1@{ icon: &quot;ngx:user&quot;, label: &quot;group-a-ramblers.org.uk&quot;, pos: &quot;b&quot;, h: 48 }\n    D2@{ icon: &quot;ngx:user&quot;, label: &quot;group-b-ramblers.org.uk&quot;, pos: &quot;b&quot;, h: 48 }\n    DN@{ icon: &quot;ngx:user&quot;, label: &quot;...up to 500 domains&quot;, pos: &quot;b&quot;, h: 48 }\n\n    D1 --&gt; CF\n    D2 --&gt; CF\n    DN --&gt; CF\n\n    CF@{ icon: &quot;ngx:cloudflare&quot;, label: &quot;Cloudflare DNS&quot;, pos: &quot;b&quot;, h: 48 }\n    CF --&gt; FLY\n\n    subgraph FLY[&quot;Fly.io Application - 2 to 4 instances&quot;]\n        TM[&quot;Tenant Resolution Middleware&lt;br/&gt;Host header to groupCode&quot;]\n        CACHE[&quot;Domain-to-group cache&lt;br/&gt;refreshed periodically&quot;]\n        DBR[&quot;Database Router&lt;br/&gt;groupCode to mongoose.useDb&quot;]\n        TM --&gt; CACHE\n        TM --&gt; DBR\n    end\n\n    DBR --&gt; MDB1@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group A DB&quot;, pos: &quot;b&quot;, h: 48 }\n    DBR --&gt; MDB2@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group B DB&quot;, pos: &quot;b&quot;, h: 48 }\n    DBR --&gt; MDBN@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group N DB&quot;, pos: &quot;b&quot;, h: 48 }\n\n    MDB1 --&gt; S3\n    MDB2 --&gt; S3\n    MDBN --&gt; S3\n\n    S3@{ icon: &quot;ngx:aws&quot;, label: &quot;Single S3 Bucket&lt;br/&gt;/{groupCode}/images/&quot;, pos: &quot;b&quot;, h: 48 }\n\n    style FLY fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h2>Can a Single App Serve Multiple Domains?</h2>\n<p><strong>Yes, this is technically feasible.</strong> Fly.io (and most reverse proxies) support binding multiple custom domains and TLS certificates to a single application. The server would inspect the <code>Host</code> header on each request to determine which group context to use. This is a well-established pattern (multi-tenant SaaS).</p>\n<p>A user navigating to <code>group-a-ramblers.org.uk</code> vs <code>group-b-ramblers.org.uk</code> would see completely different branding, events, members, and content — unaware they hit the same server.</p>\n<p><strong>At 500 groups:</strong> Fly.io supports hundreds of custom certificates per app. TLS termination is handled at their edge, so this scales.</p>\n<h2>Request Lifecycle</h2>\n<pre><code class=\"language-mermaid\">sequenceDiagram\n    participant Browser\n    participant Fly as Fly.io Edge\n    participant MW as Tenant Middleware\n    participant Route as Route Handler\n    participant DB as MongoDB (Group A)\n    participant SPA as Angular SPA\n\n    Browser-&gt;&gt;Fly: GET https://group-a-ramblers.org.uk/events\n    Fly-&gt;&gt;MW: TLS terminated, Host: group-a-ramblers.org.uk\n    MW-&gt;&gt;MW: lookup(&quot;group-a-ramblers.org.uk&quot;) to &quot;group-a&quot;\n    MW-&gt;&gt;MW: req.groupCode = &quot;group-a&quot;\n    MW-&gt;&gt;MW: req.db = mongoose.useDb(&quot;group-a&quot;)\n    MW-&gt;&gt;MW: req.s3Prefix = &quot;group-a&quot;\n    MW-&gt;&gt;Route: GET /api/database/events\n    Route-&gt;&gt;DB: Query events (Group A DB only)\n    DB--&gt;&gt;Route: Group A events\n    Route--&gt;&gt;Browser: JSON response\n    Browser-&gt;&gt;SPA: Render with Group A config\n    SPA-&gt;&gt;Route: GET /api/database/config\n    Route-&gt;&gt;DB: Query config (Group A DB)\n    DB--&gt;&gt;Route: Group A branding, settings\n    Route--&gt;&gt;SPA: Group A config\n    SPA--&gt;&gt;Browser: Group A branded UI\n</code></pre>\n<h2>Coexistence: Running Both Models on the Same Codebase</h2>\n<p>A critical requirement is that the multi-tenant architecture must coexist with the current per-group deployment model — both running from the same codebase simultaneously. This is achievable using a feature-flagged middleware approach.</p>\n<pre><code class=\"language-mermaid\">flowchart TD\n    subgraph existing[&quot;Existing Groups - per-app deployment&quot;]\n        E1@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group A app&quot;, pos: &quot;b&quot;, h: 48 }\n        E2@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group B app&quot;, pos: &quot;b&quot;, h: 48 }\n        E3@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group C app&quot;, pos: &quot;b&quot;, h: 48 }\n        E1 --&gt; ED1@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group A DB&quot;, pos: &quot;b&quot;, h: 48 }\n        E2 --&gt; ED2@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group B DB&quot;, pos: &quot;b&quot;, h: 48 }\n        E3 --&gt; ED3@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group C DB&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    subgraph multitenant[&quot;New Groups - multi-tenant instance&quot;]\n        MT@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Multi-tenant app&quot;, pos: &quot;b&quot;, h: 48 }\n        MT --&gt; MD1@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group D DB&quot;, pos: &quot;b&quot;, h: 48 }\n        MT --&gt; MD2@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group E DB&quot;, pos: &quot;b&quot;, h: 48 }\n        MT --&gt; MDN@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Group N DB&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    IMG@{ icon: &quot;logos:docker-icon&quot;, label: &quot;Same Docker Image&quot;, pos: &quot;b&quot;, h: 48 }\n    IMG --&gt; existing\n    IMG --&gt; multitenant\n\n    style existing fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style multitenant fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p><strong>How it works:</strong></p>\n<ul>\n<li>An environment variable (<code>MULTI_TENANT_ENABLED</code>) controls whether the tenant resolution middleware is active</li>\n<li><strong>When off</strong> (existing groups): the app behaves exactly as today — connects to a single database via <code>MONGODB_URI</code>, serves one group</li>\n<li><strong>When on</strong> (multi-tenant instance): the middleware inspects the <code>Host</code> header, resolves the group, and switches database context per request</li>\n<li>Both modes run the <strong>same codebase and the same Docker image</strong> — the only difference is the environment variable and which secrets are configured</li>\n</ul>\n<p><strong>Migration path:</strong></p>\n<ol>\n<li>Add tenant middleware behind the feature flag — off by default, zero impact on existing groups</li>\n<li>Deploy a new Fly.io app with <code>MULTI_TENANT_ENABLED=true</code> and a shared admin database containing the domain-to-group lookup table</li>\n<li>Onboard new groups onto the multi-tenant instance</li>\n<li>Optionally migrate existing groups one at a time by pointing their DNS to the multi-tenant instance and adding their domain to the lookup table</li>\n<li>Decommission the old per-group app once migrated</li>\n</ol>\n<p>This approach means there is <strong>no big-bang migration</strong> — existing groups continue running undisturbed, and the multi-tenant capability is introduced incrementally alongside them.</p>\n<h2>What Would Be Required</h2>\n<h3>1. Request-Scoped Tenant Resolution (HIGH)</h3>\n<p>Every incoming HTTP request must be mapped to a group context based on the <code>Host</code> header:</p>\n<ul>\n<li>A <strong>domain-to-group lookup table</strong> (stored in a shared admin database or in-memory cache, refreshed periodically)</li>\n<li>Express middleware that resolves the group before any route handler runs</li>\n<li>The resolved group context must flow through the entire request lifecycle</li>\n</ul>\n<p><strong>At 500 groups:</strong> The lookup table is small (500 domain to groupCode pairs). An in-memory Map refreshed every 60 seconds from a shared admin database is sufficient.</p>\n<h3>2. Database Architecture (HIGH)</h3>\n<p><strong>Option A: Single database, collection-level isolation</strong></p>\n<ul>\n<li>All groups share one MongoDB database</li>\n<li>Every document gains a <code>groupCode</code> field</li>\n<li>Every query gains a <code>groupCode</code> filter</li>\n<li>Risk: a missing filter leaks data across groups — this risk multiplies with 500 groups</li>\n</ul>\n<p><strong>Option B: Shared connection pool, database-per-group (recommended)</strong></p>\n<ul>\n<li>Keep separate databases per group (as today)</li>\n<li>Use <code>mongoose.connection.useDb(groupDbName)</code> to route queries per request</li>\n<li>Models are compiled once and reused across databases</li>\n<li>Benefit: maintains current data isolation model, zero risk of cross-group data leakage</li>\n</ul>\n<p><strong>At 500 groups (Option B):</strong></p>\n<ul>\n<li>MongoDB Atlas supports thousands of databases per cluster</li>\n<li><code>useDb()</code> reuses the existing connection pool — it does not create new connections</li>\n<li>Connection pool size: ~50–100 connections shared across all groups</li>\n</ul>\n<h3>3. S3 Storage Consolidation (MEDIUM)</h3>\n<ul>\n<li><strong>Keep separate buckets</strong>: proven model, but managing 500 IAM users/policies is operationally burdensome</li>\n<li><strong>Single bucket with path prefixes</strong>: <code>{groupCode}/images/...</code> — simpler at scale, recommended for 500 groups</li>\n</ul>\n<h3>4. Authentication Changes (MEDIUM)</h3>\n<ul>\n<li><strong>Recommended:</strong> Single shared <code>AUTH_SECRET</code> but include <code>groupCode</code> in the JWT payload</li>\n<li>Token verification middleware checks that the token&#39;s <code>groupCode</code> matches the request&#39;s resolved <code>groupCode</code></li>\n<li>A member logged into one group&#39;s domain must NOT be authenticated on another group&#39;s domain</li>\n</ul>\n<h3>5. Background Jobs (MEDIUM)</h3>\n<p><strong>Current:</strong> Each of the 13 Fly.io apps runs its own independent cron jobs.</p>\n<p><strong>Proposed:</strong> A single scheduler manages jobs for all 500 groups with controlled concurrency.</p>\n<pre><code class=\"language-mermaid\">flowchart TD\n    SCHED[&quot;Job Scheduler&quot;]\n    Q[&quot;Job Queue&lt;br/&gt;event-sync, email-send&quot;]\n    SCHED --&gt; Q\n    Q --&gt; W1@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group A sync&quot;, pos: &quot;b&quot;, h: 48 }\n    Q --&gt; W2@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group B sync&quot;, pos: &quot;b&quot;, h: 48 }\n    Q --&gt; W3@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Group C sync&quot;, pos: &quot;b&quot;, h: 48 }\n    Q --&gt; WN[&quot;... x 500 groups&lt;br/&gt;concurrency: 3-5&lt;br/&gt;staggered schedule&quot;]\n\n    style SCHED fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h3>6. Frontend Changes (LOW)</h3>\n<ul>\n<li>No frontend code changes needed — the Angular app already loads all config at runtime from the API</li>\n<li>The initial <code>/api/database/config</code> call would return different data based on which domain was accessed</li>\n</ul>\n<h3>7. Admin and Onboarding (EXISTING — extend for multi-tenant)</h3>\n<p>An <strong>environment management admin UI</strong> already exists at <code>/admin/environment-management/setup</code>. It provides a multi-step wizard for provisioning new groups:</p>\n<ol>\n<li><strong>Group Selection</strong> — create new, clone from existing, or modify an existing environment</li>\n<li><strong>Environment and Services</strong> — configure app name, database, and service integrations</li>\n<li><strong>Admin and Options</strong> — create the initial admin user</li>\n<li><strong>Review and Validate</strong> — confirm all settings before deployment</li>\n<li><strong>Deploy</strong> — provisions the Fly.io app, database, S3 bucket, DNS, and seeds initial data</li>\n</ol>\n<p>For the multi-tenant model, this wizard would need to be extended to support provisioning groups within the shared instance (database creation + domain-to-group mapping) rather than creating a new Fly.io app. The core onboarding workflow already exists — it just needs a multi-tenant deployment target alongside the existing per-app target.</p>\n"}