Curate is a multi-tenant digital-memorial platform. Customers are cemetery and columbarium operators — one operator is one tenant — whose staff administer niches, memorials, and the families attached to them. Families build online tributes for the deceased: profiles, photo timelines, galleries, AI-generated oil-paintings, and encrypted “time capsule” letters delivered after passing. The primary locale is zh-TW.
Two sentences carry most of the architecture:
- Every browser-facing hostname is a Cloudflare Worker.
- The tenant is the hostname —
taipei.curate.memorialis tenanttaipei, and nothing else decides it.
Runtime topology
There is exactly one backend deployment. It is never reached at its own
address by a browser: each Worker proxies /api/* under its own hostname,
so every request a browser makes is same-origin.
flowchart TB
subgraph edge["Cloudflare"]
tenant["curate-tenant Worker<br/>one Custom Domain per tenant hostname<br/>/ = consumer SPA · /admin = tenant admin"]
platform["curate-platform Worker<br/>admin.curate.memorial<br/>super-admin SPA"]
external["curate-external Worker<br/>curate.memorial<br/>marketing + public guides"]
internal["curate-internal Worker<br/>internal.curate.memorial<br/>this handbook · Access-gated"]
end
subgraph aws["AWS · ap-east-2"]
alb["ALB · api.curate.memorial<br/>ingress locked to Cloudflare CIDRs"]
api["ECS: API server"]
bg["ECS: background worker<br/>polls the DB · same image"]
store[("Postgres · S3 · KMS · Resend")]
end
tenant -->|"/api/* · sets X-Forwarded-Host"| alb
platform -->|"/api/*"| alb
alb --> api
api --> store
bg --> storeTenant origins are all served by one Worker, with a Cloudflare Custom
Domain per tenant hostname — no wildcard. We sell B2B and onboard tenants
deliberately (roughly one a quarter), so binding a hostname is a single
dashboard click on the onboarding checklist, and only real tenants resolve at
all. The Worker serves the consumer SPA at /, the tenant-admin SPA at
/admin, and proxies /api/* onward.
The super-admin origin is separate on purpose. Tenant staff and content
contributors should never share an origin with the cross-tenant admin surface,
and a separate origin can be put behind IP allowlisting or SSO on its own.
Tenant origins go further and refuse /api/super* at the edge with a bare
404.
The two content Workers serve static output and make no API calls at all —
which is why nothing leaves them in the diagram. internal.curate.memorial —
this site — sits behind Cloudflare Access, so offboarding a maintenance vendor
is a deletion from one policy.
The backend is two binaries built from one image: the API server and a background worker. Nothing reaches the background worker — it has no inbound arrow because it polls the database for jobs rather than serving requests. No Redis, no queue. Migrations run inline on boot under an advisory lock.
The tenant is the hostname
The backend reads the tenant slug from the first DNS label of the hostname the
browser used, which reaches it as X-Forwarded-Host (the Worker always
overwrites that header, so a client cannot supply its own).
This happens only when a session is established — login, signup, password
reset. From then on the session carries the tenant_id and every later
request trusts the session, never the header. Tenant isolation itself is
enforced deeper in the backend, at the SQL layer; the hostname decides only
which tenant a new session belongs to.
sequenceDiagram participant B as Browser participant W as curate-tenant Worker participant A as API participant D as Postgres B->>W: POST taipei.curate.memorial/api/consumer/auth/login Note over W: overwrites X-Forwarded-Host<br/>a client-supplied value cannot survive W->>A: proxy · X-Forwarded-Host: taipei.curate.memorial A->>A: first DNS label = slug "taipei" A->>D: resolve tenant by slug D-->>A: tenant_id A-->>B: session cookie carrying tenant_id Note over B,A: every later request trusts the session, never the host
Consequences you will meet in practice:
- A host with no tenant gets
400 invalid_tenantfrom the auth endpoints — a bare hostname, an IP, or a first label that matches no tenant row (which is what you get hitting the API origin directly). - Cookies are host-only and
SameSite=Lax, one per surface:consumer_jwt,admin_jwt,super_jwt, each scoped to its own API path. This is why every origin proxies the API under its own hostname — the backend sets no CORS headers, deliberately. Fix a cross-origin failure by moving the API behind the calling origin, never by adding CORS. - The host also gates who may log in where. A tenant hostname admits only that tenant’s staff; the platform origin admits only super admins. Mismatches return the endpoint’s ordinary response (401, or 202 for a reset request) so nothing leaks about which accounts exist.
- Email links are per-tenant. Staff invites, memorial invites, and
time-capsule letters are minted on
<slug>.<base domain>. There is no environment-wide “client URL” any more. - Some slugs are reserved —
admin,api,www,curate,staging,internal,platform— and rejected at both tenant create and rename, so a customer can never claim a hostname the platform depends on.
In local development this means browsing http://<slug>.localhost:5173.
Browsers resolve *.localhost to loopback on their own, so no /etc/hosts
editing is involved; bare localhost gets the same 400 invalid_tenant.
Where to go next
- Repo tour — which of the four repos holds what
- Environments — hostnames, deploy paths, and the staging tenant
curate-serverdocs/ARCHITECTURE.md— backend internals: layering, RBAC, isolation, domain model