Routing Tenants to Regional Data Stores

A residency guarantee only holds if every request reaches the data store in the tenant's contracted region and nothing else can, so routing — not storage — is where most residency designs leak. This page sits under tenant data residency, and it covers the mechanics of getting a request from any edge location to the one regional stack that is allowed to serve it.

Problem Framing

The promise to a customer is simple: their rows, files, and backups live in eu-west-1 (or us-east-1, or ap-southeast-2) and never leave. The hard part is that requests do not arrive in that region. A user in Frankfurt may hit a US edge node; a webhook from a payment processor lands wherever DNS sends it; a background job is scheduled by a worker in a third region. Each of those entry points must resolve the tenant's home region and forward the request there before any code touches a database, because once a connection to the wrong regional store is opened, the residency boundary is already crossed.

This requires two things that are easy to get wrong. First, there must be one authoritative answer to "which region owns this tenant," and it must be readable cheaply at the edge — a global directory that holds routing metadata only, never tenant payload data. Second, a request that arrives in the wrong region must be redirected or rejected, never silently served against a local replica or a cross-region connection. A global read replica in the nearest region is the classic failure: it makes the app fast and quietly defeats residency, because tenant data is now physically present outside the contracted jurisdiction.

The routing decision also has to compose with how the rest of the stack already finds the tenant. The region is resolved from the same identity the app uses everywhere else, so it belongs alongside your tenant context injection strategies rather than as a separate, parallel lookup that can drift out of sync.

The flow below shows a request entering at any edge, resolving the tenant's region from the global directory, and either being forwarded to the home-region stack or rejected when it is already in the wrong place.

Step-by-Step Guide

1. Pin the region in the tenant directory

Store an authoritative region on the tenant record in a global directory that holds only routing metadata — tenant ID, region, status — and never any tenant payload. The region is immutable for the life of the tenant unless you run an explicit migration; treat it as a closed enum so a typo cannot create an unroutable tenant.

# tenant-directory schema (global, replicated to every edge; metadata only)
tenant:
  id: t_8f3a21
  region: eu-west-1        # one of: us-east-1 | eu-west-1 | ap-southeast-2
  status: active
  # NO payload fields here — name, users, billing all live in the regional store

2. Replicate the directory to the edge as a key-value lookup

The edge needs the region in single-digit milliseconds, so push the directory into a globally replicated KV store rather than calling back to a home region (which would itself cross borders). The lookup is keyed by tenant ID and returns only the region string.

// Cloudflare Worker (or any edge runtime): resolve home region from KV
interface Env { TENANT_REGIONS: KVNamespace; }

const REGIONS = new Set(["us-east-1", "eu-west-1", "ap-southeast-2"]);

export async function resolveRegion(env: Env, tenantId: string): Promise<string> {
  const region = await env.TENANT_REGIONS.get(`tenant:${tenantId}`);
  if (!region || !REGIONS.has(region)) {
    throw new Error(`no home region for tenant ${tenantId}`);
  }
  return region;
}

3. Resolve the tenant ID before the region lookup

Derive the tenant ID from the request the same way the rest of the system does — a signed subdomain, a verified JWT claim, or an API key prefix. Do this at the edge so the region is known before any connection opens.

// Extract a tenant ID from the request host or a verified JWT claim
export function tenantIdFromRequest(req: Request, claims?: { tid?: string }): string {
  if (claims?.tid) return claims.tid;
  const host = new URL(req.url).hostname;          // acme.app.example.com
  const sub = host.split(".")[0];
  if (!sub || sub === "app") throw new Error("missing tenant subdomain");
  return sub;
}

4. Forward to the home-region stack from the edge

If the request is not already at the home region, forward it. Use an internal redirect to the region's stable hostname, or a Worker fetch to the regional origin. Carry the resolved region forward in a header so the origin does not have to look it up again.

// Route the request to the home-region origin, or pass through if already home
const LOCAL_REGION = "eu-west-1"; // injected per deployment

export async function route(req: Request, env: Env): Promise<Response> {
  const tenantId = tenantIdFromRequest(req);
  const home = await resolveRegion(env, tenantId);
  if (home === LOCAL_REGION) {
    return fetch(req, { headers: { ...req.headers, "x-tenant-region": home } });
  }
  const url = new URL(req.url);
  url.hostname = `${home}.origin.example.com`;
  return fetch(new Request(url, req), { headers: { ...req.headers, "x-tenant-region": home } });
}

5. Select the regional connection at the origin

Inside the home-region stack, pick the database connection for that region. The connection map is built from the region the deployment runs in, so a process can only ever open a connection to its own regional store — there is no code path that dials another region's database.

// Per-region connection registry; the process only holds its own region's pool
import { Pool } from "pg";

const LOCAL_REGION = process.env.REGION!;          // eu-west-1
const pools: Record<string, Pool> = {
  "eu-west-1": new Pool({ host: process.env.DB_HOST_EU }),
};

export function poolForRequest(req: Request): Pool {
  const region = req.headers.get("x-tenant-region");
  if (region !== LOCAL_REGION) {
    throw new Error(`request for ${region} reached ${LOCAL_REGION}`);
  }
  return pools[LOCAL_REGION];
}

6. Reject cross-region access at the origin as a backstop

Edge routing should make a misrouted request impossible, but the origin still verifies it. If a request for another region somehow arrives — a cached DNS record, a replayed internal call — refuse it with 421 Misdirected Request instead of serving it from a local replica. Pair this with the regional key boundary in per-tenant encryption and key management, so even a leaked connection cannot decrypt data outside its region.

// Origin guard: a request for a non-local region is a routing bug, not a fallback
export function assertLocalRegion(req: Request): void {
  const region = req.headers.get("x-tenant-region");
  if (region !== process.env.REGION) {
    throw Object.assign(new Error("misdirected: wrong region"), { status: 421 });
  }
}

Verification

Prove that the edge forwards correctly and the origin refuses anything misrouted. The first test asserts a tenant pinned to eu-west-1 is sent to the EU origin from a US edge; the second asserts the origin rejects a request carrying the wrong region header.

import { describe, it, expect } from "vitest";

describe("regional routing", () => {
  it("forwards an EU tenant from a US edge to the EU origin", async () => {
    const env = { TENANT_REGIONS: kvWith({ "tenant:t_8f3a21": "eu-west-1" }) };
    const res = await route(new Request("https://t_8f3a21.app.example.com/x"), env);
    expect(res.url).toContain("eu-west-1.origin.example.com");
  });

  it("rejects a request whose region header is not the local region", () => {
    process.env.REGION = "eu-west-1";
    const req = new Request("https://x/", { headers: { "x-tenant-region": "us-east-1" } });
    expect(() => assertLocalRegion(req)).toThrowError(/misdirected/);
  });
});

A correctly routed request leaves one log line at the edge and one at the origin, and the two regions agree:

edge   tenant=t_8f3a21 home=eu-west-1 action=forward origin=eu-west-1.origin.example.com
origin region=eu-west-1 tenant=t_8f3a21 pool=DB_HOST_EU status=200

Failure Modes & Gotchas

FAQ

Where should the region pin live so the edge can read it without crossing borders? In a globally replicated metadata directory — an edge KV store or a global control-plane table — that holds only the tenant ID, region, and status, never payload. Replicating routing metadata everywhere is fine for residency because it is not the regulated data; replicating the rows or files is what you must prevent.

Should a cross-region request be redirected or rejected? At the edge, redirect or forward it to the home region so the user still gets served. At the origin, reject it with 421 Misdirected Request — once a request reaches the wrong regional stack, serving it would mean touching a non-home store, which is exactly the boundary you are protecting.

How do background jobs honor the region pin? Resolve the tenant's home region when the job is enqueued and place it on a per-region queue, so the worker that runs it is already in the correct region. Never let a job inherit the region of whatever scheduler happened to create it.