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
- Global read replica in the nearest region. Symptom: residency audit finds EU tenant rows on a US replica. Root cause: a "read anywhere" replica was added for latency. Fix: scope replication to the home region only; never replicate tenant payload across borders.
- Background job runs in the scheduler's region. Symptom: a nightly export for an EU tenant opens a connection from a US worker. Root cause: jobs inherit the worker's region, not the tenant's. Fix: enqueue jobs onto a per-region queue keyed by the tenant's home region, and resolve the region at enqueue time.
- Region pin cached after a migration. Symptom: a migrated tenant's requests still go to the old region for minutes. Root cause: edge KV TTL outlives the cutover. Fix: write the directory with a short TTL and purge the tenant key as the final migration step before flipping
active. - Origin falls back instead of rejecting. Symptom: a misrouted request is served from the local store rather than failing. Root cause: a
catchdefaults to the local pool. Fix: make a wrong-region request a hard421; never substitute a local connection.
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.