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.
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
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.
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
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:
MULTI_TENANT_ENABLED) controls whether the tenant resolution middleware is activeMONGODB_URI, serves one groupHost header, resolves the group, and switches database context per requestMigration path:
MULTI_TENANT_ENABLED=true and a shared admin database containing the domain-to-group lookup tableThis approach means there is no big-bang migration — existing groups continue running undisturbed, and the multi-tenant capability is introduced incrementally alongside them.
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.
Option A: Single database, collection-level isolation
groupCode fieldgroupCode filterOption B: Shared connection pool, database-per-group (recommended)
mongoose.connection.useDb(groupDbName) to route queries per requestAt 500 groups (Option B):
useDb() reuses the existing connection pool — it does not create new connections{groupCode}/images/... — simpler at scale, recommended for 500 groupsAUTH_SECRET but include groupCode in the JWT payloadgroupCode matches the request's resolved groupCodeCurrent: 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
/api/database/config call would return different data based on which domain was accessedAn environment management admin UI already exists at /admin/environment-management/setup. It provides a multi-step wizard for provisioning new groups:
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.