Proposed Single-Instance Multi-Tenant Architecture


This 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.

Architecture Diagram

flowchart TD
    D1@{ icon: "ngx:user", label: "group-a-ramblers.org.uk", pos: "b", h: 48 }
    D2@{ icon: "ngx:user", label: "group-b-ramblers.org.uk", pos: "b", h: 48 }
    DN@{ icon: "ngx:user", label: "...up to 500 domains", pos: "b", h: 48 }

    D1 --> CF
    D2 --> CF
    DN --> CF

    CF@{ icon: "ngx:cloudflare", label: "Cloudflare DNS", pos: "b", h: 48 }
    CF --> FLY

    subgraph FLY["Fly.io Application - 2 to 4 instances"]
        TM["Tenant Resolution Middleware<br/>Host header to groupCode"]
        CACHE["Domain-to-group cache<br/>refreshed periodically"]
        DBR["Database Router<br/>groupCode to mongoose.useDb"]
        TM --> CACHE
        TM --> DBR
    end

    DBR --> MDB1@{ icon: "ngx:mongodb", label: "Group A DB", pos: "b", h: 48 }
    DBR --> MDB2@{ icon: "ngx:mongodb", label: "Group B DB", pos: "b", h: 48 }
    DBR --> MDBN@{ icon: "ngx:mongodb", label: "Group N DB", pos: "b", h: 48 }

    MDB1 --> S3
    MDB2 --> S3
    MDBN --> S3

    S3@{ icon: "ngx:aws", label: "Single S3 Bucket<br/>/{groupCode}/images/", pos: "b", h: 48 }

    style FLY fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

Can a Single App Serve Multiple Domains?

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).

A 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.

At 500 groups: Fly.io supports hundreds of custom certificates per app. TLS termination is handled at their edge, so this scales.

Request Lifecycle

sequenceDiagram
    participant Browser
    participant Fly as Fly.io Edge
    participant MW as Tenant Middleware
    participant Route as Route Handler
    participant DB as MongoDB (Group A)
    participant SPA as Angular SPA

    Browser->>Fly: GET https://group-a-ramblers.org.uk/events
    Fly->>MW: TLS terminated, Host: group-a-ramblers.org.uk
    MW->>MW: lookup("group-a-ramblers.org.uk") to "group-a"
    MW->>MW: req.groupCode = "group-a"
    MW->>MW: req.db = mongoose.useDb("group-a")
    MW->>MW: req.s3Prefix = "group-a"
    MW->>Route: GET /api/database/events
    Route->>DB: Query events (Group A DB only)
    DB-->>Route: Group A events
    Route-->>Browser: JSON response
    Browser->>SPA: Render with Group A config
    SPA->>Route: GET /api/database/config
    Route->>DB: Query config (Group A DB)
    DB-->>Route: Group A branding, settings
    Route-->>SPA: Group A config
    SPA-->>Browser: Group A branded UI

Coexistence: Running Both Models on the Same Codebase

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.

flowchart TD
    subgraph existing["Existing Groups - per-app deployment"]
        E1@{ icon: "logos:fly-icon", label: "Group A app", pos: "b", h: 48 }
        E2@{ icon: "logos:fly-icon", label: "Group B app", pos: "b", h: 48 }
        E3@{ icon: "logos:fly-icon", label: "Group C app", pos: "b", h: 48 }
        E1 --> ED1@{ icon: "ngx:mongodb", label: "Group A DB", pos: "b", h: 48 }
        E2 --> ED2@{ icon: "ngx:mongodb", label: "Group B DB", pos: "b", h: 48 }
        E3 --> ED3@{ icon: "ngx:mongodb", label: "Group C DB", pos: "b", h: 48 }
    end

    subgraph multitenant["New Groups - multi-tenant instance"]
        MT@{ icon: "logos:fly-icon", label: "Multi-tenant app", pos: "b", h: 48 }
        MT --> MD1@{ icon: "ngx:mongodb", label: "Group D DB", pos: "b", h: 48 }
        MT --> MD2@{ icon: "ngx:mongodb", label: "Group E DB", pos: "b", h: 48 }
        MT --> MDN@{ icon: "ngx:mongodb", label: "Group N DB", pos: "b", h: 48 }
    end

    IMG@{ icon: "logos:docker-icon", label: "Same Docker Image", pos: "b", h: 48 }
    IMG --> existing
    IMG --> multitenant

    style existing fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style multitenant fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

How it works:

Migration path:

  1. Add tenant middleware behind the feature flag — off by default, zero impact on existing groups
  2. Deploy a new Fly.io app with MULTI_TENANT_ENABLED=true and a shared admin database containing the domain-to-group lookup table
  3. Onboard new groups onto the multi-tenant instance
  4. 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
  5. Decommission the old per-group app once migrated

This approach means there is no big-bang migration — existing groups continue running undisturbed, and the multi-tenant capability is introduced incrementally alongside them.

What Would Be Required

1. Request-Scoped Tenant Resolution (HIGH)

Every incoming HTTP request must be mapped to a group context based on the Host header:

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.

2. Database Architecture (HIGH)

Option A: Single database, collection-level isolation

Option B: Shared connection pool, database-per-group (recommended)

At 500 groups (Option B):

3. S3 Storage Consolidation (MEDIUM)

4. Authentication Changes (MEDIUM)

5. Background Jobs (MEDIUM)

Current: Each of the 13 Fly.io apps runs its own independent cron jobs.

Proposed: A single scheduler manages jobs for all 500 groups with controlled concurrency.

flowchart TD
    SCHED["Job Scheduler"]
    Q["Job Queue<br/>event-sync, email-send"]
    SCHED --> Q
    Q --> W1@{ icon: "logos:fly-icon", label: "Group A sync", pos: "b", h: 48 }
    Q --> W2@{ icon: "logos:fly-icon", label: "Group B sync", pos: "b", h: 48 }
    Q --> W3@{ icon: "logos:fly-icon", label: "Group C sync", pos: "b", h: 48 }
    Q --> WN["... x 500 groups<br/>concurrency: 3-5<br/>staggered schedule"]

    style SCHED fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143

6. Frontend Changes (LOW)

7. Admin and Onboarding (EXISTING — extend for multi-tenant)

An environment management admin UI already exists at /admin/environment-management/setup. It provides a multi-step wizard for provisioning new groups:

  1. Group Selection — create new, clone from existing, or modify an existing environment
  2. Environment and Services — configure app name, database, and service integrations
  3. Admin and Options — create the initial admin user
  4. Review and Validate — confirm all settings before deployment
  5. Deploy — provisions the Fly.io app, database, S3 bucket, DNS, and seeds initial data

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.