Tenant Data Residency & Regional Isolation

Data residency means a tenant's records, files, and backups physically stay inside the jurisdiction the contract names, and no request path can move them out — a requirement that sits inside the broader Multi-Tenant Compliance & Data Governance program and turns "where does this row live" into a hard architectural constraint rather than a config flag.

The pattern is the same in every implementation: an authoritative region on the tenant record, an edge that routes each request to that tenant's regional stack before any business logic runs, regional data stores that never replicate tenant payloads across borders, and a small global control plane that holds only the routing metadata needed to find the region. Get the split between the metadata plane and the data plane wrong and a single cross-region join, a misrouted background job, or a global read replica quietly defeats the entire residency guarantee.

Prerequisites

Confirm the following before you pin a single tenant. Each missing piece is a path by which data crosses a border without anyone noticing.

Step-by-Step Implementation

The work breaks into five ordered steps: pin the region, publish it to the routing plane, route the request, scope every connection to the resolved region, and keep the metadata plane payload-free. Run them in this order — routing before the region field is authoritative sends tenants to the wrong stack on day one.

1. Pin the region on the tenant record

The region is decided once, at provisioning, from the customer's contractual jurisdiction. Store it as an enum so an unknown value fails loudly, and forbid silent updates — a region change is a data migration, not a column write.

CREATE TYPE tenant_region AS ENUM ('us-east', 'eu-west', 'ap-southeast');

ALTER TABLE tenants
  ADD COLUMN region tenant_region NOT NULL;

-- Region is immutable in the normal path; only a guarded migration may change it.
CREATE OR REPLACE FUNCTION forbid_region_change() RETURNS trigger AS $$
BEGIN
  IF NEW.region <> OLD.region AND current_setting('app.allow_region_migration', true) IS DISTINCT FROM 'on' THEN
    RAISE EXCEPTION 'tenant region is immutable; run a region migration instead';
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tenant_region_lock
  BEFORE UPDATE ON tenants
  FOR EACH ROW EXECUTE FUNCTION forbid_region_change();

2. Publish the mapping to a global routing plane

The edge cannot run a regional database query to learn a tenant's region — that would mean a cross-region round trip on every request. Replicate only the tenant_id -> region pair into a globally readable, low-latency store. This is the one piece of tenant metadata allowed to be global, because it is an identifier and a region label, not tenant data.

// On tenant provisioning, write the routing record to a global KV namespace.
// Cloudflare Workers KV reads at the edge in single-digit milliseconds worldwide.
export async function publishRouting(env: Env, tenantId: string, region: string) {
  await env.TENANT_ROUTING.put(`tenant:${tenantId}`, region, {
    metadata: { updatedAt: Date.now() },
  });
}

// Resolve a tenant's region at the edge with no cross-region call.
export async function regionFor(env: Env, tenantId: string): Promise<string | null> {
  return env.TENANT_ROUTING.get(`tenant:${tenantId}`);
}

3. Route the request to the regional stack at the edge

The edge resolves the tenant, looks up its region, and proxies to the matching regional origin. A request that reaches the wrong region must be rejected, not silently served — serving it would mean opening a regional connection in the wrong jurisdiction.

const ORIGINS: Record<string, string> = {
  "us-east": "https://us-east.origin.internal",
  "eu-west": "https://eu-west.origin.internal",
  "ap-southeast": "https://ap-southeast.origin.internal",
};

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const tenantId = resolveTenantId(req); // from host, path, or signed token
    const region = tenantId ? await regionFor(env, tenantId) : null;
    if (!region || !ORIGINS[region]) {
      return new Response("tenant region unresolved", { status: 421 });
    }
    const target = new URL(req.url);
    target.hostname = new URL(ORIGINS[region]).hostname;
    // Forward the resolved region so the origin can assert it, never re-derive it.
    const headers = new Headers(req.headers);
    headers.set("x-tenant-region", region);
    headers.set("x-tenant-id", tenantId!);
    return fetch(target.toString(), { method: req.method, headers, body: req.body });
  },
};

4. Scope every connection to the resolved region

Inside the regional stack, application code must only ever reach its own region's stores. Bind the database URL, object-storage bucket, and cache endpoint to the region the process runs in, and assert the incoming x-tenant-region matches — a tenant whose region differs from the stack it landed in is a routing bug and must fail closed.

const REGION = process.env.STACK_REGION!; // baked into each regional deployment

const stores = {
  db: new Pool({ connectionString: process.env[`DB_URL_${REGION}`] }),
  bucket: `tenant-data-${REGION}`,
};

export function assertRegion(req: Request): void {
  if (req.headers.get("x-tenant-region") !== REGION) {
    // A mismatch means the edge mis-routed; never open a connection on a guess.
    throw new RegionMismatchError(req.headers.get("x-tenant-region"), REGION);
  }
}

5. Keep the metadata plane free of tenant payload

Draw and enforce the line between what may live globally and what may not. The control plane may hold tenant IDs, region labels, plan tiers, and billing account references. It must never hold the customer's records, files, or anything derived from them. A periodic scan that fails the build if a payload column appears in a global table is cheap insurance.

-- Global control-plane tables must contain only identifiers and routing metadata.
-- This guard query should return zero rows in CI.
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'control_plane'
  AND column_name NOT IN (
    'tenant_id', 'region', 'plan_tier', 'billing_account_id',
    'created_at', 'updated_at'
  );

The control plane stays small and globally replicated; the data plane stays regional and never crosses a border. To extend this routing into the data tier — picking the right regional database and connection pool per request — work through routing tenants to regional data stores, which covers the connection-selection and failover details this page only sketches.

Metadata Plane vs. Data Plane

The single decision that makes or breaks residency is what is allowed to be global. Anything in the metadata plane is replicated worldwide for routing speed; anything in the data plane is locked to one region. Put a payload field on the wrong side and it is silently copied across borders by your own replication.

Concern Metadata plane (global) Data plane (regional)
What it holds tenant_id, region, plan tier, billing ref records, files, search index, backups
Replication replicated to every edge location none cross-region; in-region only
Read latency target single-digit ms at the edge regional, within the stack
Write frequency rare (provisioning, region migration) every business write
Residency exposure none (identifiers only) total (the protected asset)
Store example Workers KV, global config table regional Postgres, regional bucket

If you cannot describe a field as "an identifier or a routing label," it belongs in the data plane. The test is blunt and deliberate: would a regulator consider the value to be the customer's data? If yes, it never goes global.

The Region-Pinned Request Path

The hardest thing to reason about is the full chain from an inbound request to an in-region result, and the two points where data can leak across a border: the edge resolving the wrong region, and a regional store replicating payload outward. The figure traces that path and marks both.

Dynamic Query Scoping & Connection Handling

The region is fixed per tenant; the connection target is chosen per request. Every regional process binds at boot to its own region's stores, so there is no runtime choice of which database to hit — the choice was made by the edge when it picked the origin. This is deliberate: a process that can reach two regions' databases is one bug away from a cross-border read.

Background work is where residency most often slips. A job that iterates "all tenants" with a single global connection reads every region at once. Fan the scheduler out per region instead: one worker deployment per region, each querying only its own tenants, each writing only its own stores.

// One scheduled worker per region. It only ever sees its own region's tenants.
export async function runRegionalJob(env: Env) {
  const region = env.STACK_REGION;
  const tenants = await env.db.query(
    "SELECT id FROM tenants WHERE region = $1", [region],
  );
  for (const t of tenants.rows) {
    await processTenant(t.id, env); // all reads/writes stay in this region's stores
  }
}

This mirrors the request-scoping discipline from the tenant-aware data routing and query scoping pillar: context is resolved once, asserted everywhere, and never re-derived deep in the call stack. The difference is the unit — there the context is the tenant, here it is the region, and the region is the harder boundary because crossing it is a compliance event, not just a correctness bug.

Search indexes and caches need the same treatment. Run a regional cluster per region; do not point a global search service at every regional database, because the index then becomes a cross-region copy of tenant data and inherits the residency obligation it was meant to avoid.

Security Enforcement & Access Control

Residency holds only when each layer can be reached by code in exactly one region. The edge decides the region; every layer below it must be incapable of crossing it, enforced by credentials, not by convention.

Layer Mechanism Enforced by Failure if absent
Edge tenant_id -> region lookup Routing plane Requests reach the wrong region
Stack admission assert x-tenant-region == stack region Regional app Mis-routed requests served silently
Database region-scoped role + URL Regional credential One stack reaches another's data
Object storage per-region bucket + key Regional key Files written cross-border
Replication in-region replica targets only Backup policy Backups leave the jurisdiction

The keys are the real control. A regional deployment is issued only its own region's database role and storage key, so even a compromised or buggy eu-west process cannot authenticate to the us-east store. Tie those region-scoped credentials into the broader per-tenant encryption and key management model so each region's keys live in that region's KMS and never travel — a key that crosses a border is itself a residency breach. The principle: tenants are isolated by routing, regions are isolated by credentials.

Operational Overhead & Scaling Metrics

Regional isolation multiplies your deployment footprint and your migration surface. Watch the following and act at the thresholds before a routing slip becomes a reportable incident.

Metric Healthy Warning threshold Mitigation
Cross-region request rate 0 (rejected at edge) any sustained 421s Audit routing plane staleness
Routing-plane read latency <10 ms at edge >50 ms p99 Cache region in signed token
Routing-plane staleness seconds after provisioning minutes of disagreement Write region before first login
Region-migration duration hours, fully drained partial / dual-region tenant Block writes during cutover
Backup destination drift 100% in-region any out-of-region snapshot Enforce bucket region policy in IaC

The highest-leverage control is making the routing plane authoritative and fast. If the edge cannot trust the tenant_id -> region map, every request risks the wrong jurisdiction. Write the region into the routing plane before a tenant can authenticate, and embed it in a signed session token so steady-state requests skip the lookup entirely.

// Embed the resolved region in the session token so the edge trusts it without a KV read.
const token = await signSession({
  sub: tenantId,
  region,                       // signed, so the edge cannot be fooled into mis-routing
  exp: Math.floor(Date.now() / 1000) + 3600,
});

Region migrations are the genuinely hard operation: moving a tenant from eu-west to ap-southeast means draining writes, copying the data tier, repointing the routing plane, and verifying nothing remains in the source region. Treat it as a scheduled, audited, write-blocked cutover, never an online column update.

Pitfalls & Anti-Patterns

Global read replica of a regional store. Standing up a single global analytics replica that reads every regional database copies tenant payload out of its jurisdiction the moment replication runs. The replica inherits the residency obligation. Keep analytics regional and aggregate only non-payload metrics globally.

Region derived per request instead of pinned. Inferring a tenant's region from request IP, locale, or which edge POP answered means the same tenant can land in different regions on different requests. Region is an immutable property of the tenant record, resolved from the routing plane — never guessed from request characteristics.

Background jobs that ignore region. A cron that selects "all tenants" through one connection reads and writes across every region in a single transaction. Fan schedulers out per region so each job is structurally incapable of touching another region's data.

Payload creeping into the metadata plane. A "small" denormalized field — a customer name on the billing record, a document title in a global search index — quietly turns the global plane into a cross-border copy of tenant data. Guard the control-plane schema in CI so any payload column fails the build.

Backups and exports that leak the boundary. The live path can be perfectly regional while nightly snapshots, log shipping, or a data-export feature write to a default global bucket. Constrain every backup, DR replica, and export destination to in-region targets in infrastructure-as-code, and verify the destination, not the intent.

Frequently Asked Questions

Why not just store everything in one region and call it residency? Single-region works only if every tenant shares one jurisdiction. The moment you sign a customer who contractually requires EU residency and another who requires US, you need per-tenant region pinning and regional stacks — one region cannot satisfy both, and a global database silently violates whichever contract it is not hosted under.

What is allowed to be global, and what must stay regional? Identifiers and routing labels — tenant_id, region, plan tier, billing account reference — may be globally replicated because a regulator does not consider them the customer's data. Records, files, search indexes, caches of payload, and backups must stay in one region. If a value is the protected asset or is derived from it, it never goes global.

How does the edge know a tenant's region without a cross-region call? A small global routing plane (Workers KV, or a globally replicated config table) holds the tenant_id -> region map and reads in single-digit milliseconds at the edge. The map is written at provisioning and rarely changes, so it stays cheap to replicate everywhere, and the resolved region can be embedded in a signed session token to skip even that lookup.

Can a tenant move between regions? Yes, but it is a migration, not a setting. Block writes, copy the full data tier to the new region, repoint the routing plane, verify the source region is empty, then resume. Treat it as an audited, scheduled cutover; an online region change risks a window where the tenant exists in two jurisdictions at once.

Does region pinning replace per-tenant encryption? No — they are complementary. Residency controls where data lives; encryption controls who can read it and proves the boundary to an auditor. Run a regional KMS per region so keys never cross a border, and pair residency with per-tenant encryption and key management for defence in depth.