Skip to content

Domain resolution and multi-tenancy

One Next.js deployment and one Laravel API serve every customer event, each on its own domain, each with entirely different settings, branding and content.

There is no per-event build, no per-event deployment and no EVENT_ID baked into a bundle. A request's Host header decides which event it belongs to, and everything downstream follows from that one lookup.

The whole idea in one diagram

Three domains, three completely different-looking events, one running process. The difference between them is a row in event_domains and the configuration hanging off the event id it points to.

A request, end to end

The important step is 8: the middleware forwards the resolved id on the request headers so server components reuse it rather than resolving again. One lookup per request, not one per component.

The three ways an event is resolved

getEventId() in lib/event.ts tries these in order:

OrderSourceUsed for
1EVENT_ID environment variableLocal development, single-event deploys
2x-resolved-event-id request headerEvery normal request — set by the middleware
3Host header → /resolve-eventFallback when the header is absent

When none resolve, DomainNotConnectedError is thrown and the layout renders the "event not connected with this domain" page instead of a 500.

That page is a diagnosis, not a crash

Seeing it means the host reached the app but no active row matched. Check the domain's status in the admin, and check for a www. or port mismatch — see host normalisation below.

Host normalisation

The frontend and the API normalise identically, and they must stay in step. middleware.ts and ResolveEventController::normalizeHost() both:

  • Lowercase and trim.
  • Strip a leading www. — so www.run.example.com and run.example.com are one event.
  • Strip default ports :443 and :80. Browsers omit them, but some proxies attach them, and a Host: togosg61.togoparts.com:443 that failed to match its stored row once took a live event to the not-connected page.
  • Keep non-default ports. A dev site stored as 128.199.72.46:3000 must match host and port exactly.

Caching, and why it is layered

Resolution runs on every request, so it is cached in three places:

LayerTTLPurpose
WL API response header60sCache-Control: public, max-age=60
Middleware in-memory map60sPer host, on the Edge runtime
Server-side cache (lib/serverCache)60sConfig, pages and meta, keyed by event

The practical consequence: a newly activated domain goes live within about a minute, and so does a version switch. Neither is instant, and neither needs a deploy.

A hard browser reload (Cache-Control: no-cache) bypasses the server-side TTL cache, which is the fastest way to confirm a change without waiting.

Failing softly

The resolver is on the critical path for every page, so it is deliberately forgiving:

  • 404 from /resolve-event is terminal — genuinely no active domain. The stale mapping is dropped.
  • 429 or 5xx is transient — retried once, then it falls back to the last known good mapping rather than dropping the header.

Losing the header would render the not-connected page on a live event, which is far worse than serving a slightly stale mapping.

/resolve-event is also exempted from the default rate limiter and uses the generous public-read limiter. The default 60/minute was being exhausted by a single active visitor, and the 429s surfaced as the not-connected page.

The domain lifecycle

A domain moves through four states, driven from Setup → Domain in the admin panel.

What each step does:

  1. POST /events/{id}/domain creates the row with status='pending', a TXT verification token, and a_record_ip from the admin backend's SERVER_IP config.
  2. The customer adds two DNS records at their provider — an A record pointing at that IP, and the TXT record proving ownership.
  3. verify-txt looks for the TXT record → txt_verified.
  4. verify-dns confirms the A record resolves to one of the accepted SERVER_IPSactive.

Only active resolves. This is why a domain that "looks connected" in DNS still shows the not-connected page: DNS is only half of it.

The table

event_domains
  id, event_id, domain,
  status ENUM(pending, txt_verified, active, failed),
  version_key,
  txt_record_name, txt_record_value, a_record_ip,
  txt_verified_at, dns_verified_at, last_checked_at,
  created_at, updated_at

  UNIQUE(domain)    -- one host maps to exactly one event
  INDEX(event_id)   -- UNIQUE(event_id) was dropped: an event may have MANY domains

The asymmetry is the point. A host resolves to exactly one event, or the whole model collapses. An event may own several hosts — a .com and a .sg, or a short campaign domain alongside a descriptive one — each verified independently.

Telling the server about a new domain

Resolution is dynamic, but Apache still needs a vhost and a certificate for a new hostname. That is the one place the admin side calls the WL side directly.

WlDeployService::syncDomains() is deliberately best-effort: 5s connect, 20s total, and a failure never blocks the admin action. The domain row is already correct in the database; the vhost can be synced again.

Two different failure modes, often confused

Resolution is dynamic and needs no deploy — an activated domain resolves within a minute.

Serving needs a vhost and a certificate, which the deploy agent creates.

So a domain can be active and resolving correctly while still failing in a browser because Apache has no vhost for it. If the domain works over the resolver but not in a browser, retry the sync.

Versions per domain

event_domains.version_key (default v3) decides which WL version serves that domain. The deploy agent reads it when regenerating the vhost, and points the proxy at the instance for that version.

This is why version is a per-domain property rather than a per-event or global one: it is the vhost's upstream, and the vhost is per domain.

Full background, including the open questions with the WL host team, is in the multi-version hosting brief.

How one codebase produces different sites

Once event_id is known, everything else is data. Nothing about a customer's event lives in the code.

Two events on the same deployment can differ in mode, theme, page structure, registration flow, payment account and email identity — while running identical code.

The corollary matters more. Because there is one deployment, a change to tga-v3-wl-web reaches every live event at once. There is no gradual rollout. Anything conditional on an event's configuration must default safely for events that have not configured it.

Two other things the middleware does

Event resolution is its first job, not its only one.

Admin gate. /admin/* on an event site is the challenge-management dashboard. It is password protected only when that event has actually set credentials. The check fails closed — if the API cannot be reached and there is no cached answer, it assumes protected. The dashboard exposes donor names and amounts, so a blip must never open the door.

Custom URL patterns. An admin can define a pattern such as /fundraiser/[id] that renders the user_detail page. The middleware rewrites /fundraiser/123 to /user/123, or issues a 301 where the pattern has moved. It only runs for two-segment numeric paths Next.js would otherwise 404, so the normal request path stays cheap.

Debugging a domain

Work down this list in order:

  1. Is there a row, and is it active? Setup → Domain in the admin.
  2. Does the resolver agree? curl "https://wl-api.togoparts.com/api/v1/resolve-event?host=<host>" — a 404 means no active row for that exact host.
  3. Is it a normalisation mismatch? Check www., and check for a stored port.
  4. Has DNS actually propagated? The A record must point at an accepted server IP.
  5. Does Apache have a vhost? If the resolver returns the right event but the browser does not load, re-run the domain sync.
  6. Is it just cache? Wait 60 seconds, or hard-reload.

Organiser guide and developer documentation for the TogoActive platform.