Data residency shows up the same way every time: a large prospect in a regulated sector, a security questionnaire, and one line stating that their data must remain within a specific jurisdiction. The engineering answer is not difficult so much as pervasive, because the requirement is rarely about the primary database — it is about everything you forgot is also storage.
I have been through this on two products. Both times the database was the easy part, and both times the surprises came from the same places: logs, search indexes, backups, queue payloads, analytics events, exported files, and third-party processors nobody had listed.
Separate the control plane from the data plane
The architecture that works keeps a global control plane — accounts, entitlements, billing, feature flags, routing — and regional data planes holding all customer content. The control plane knows a tenant exists and which region it lives in; it does not hold what the tenant does. That distinction is what lets you stay one product rather than forking into regional deployments that drift apart over eighteen months.
The routing decision belongs at the edge and must be unambiguous: a tenant's region is resolved from a globally replicated mapping, and every request for that tenant is served in that region. No fallback to another region on failure — a failover that relocates data is a compliance incident, not a resilience feature, and it is the single most common design mistake in this area.
# The audit question is always 'prove it stayed in region'. Make that answerable
# in code rather than in a diagram.
REGIONAL_STORES = {'eu', 'us', 'apac'}
def resolve(tenant_id: str) -> Region:
region = control_plane.tenant_region(tenant_id) # globally replicated, tiny
if region != CURRENT_REGION:
raise WrongRegion(tenant_id, expected=region, got=CURRENT_REGION)
return region
# Every storage client is region-scoped at construction; there is no global
# client available to accidentally use.
class RegionalContext:
def __init__(self, region: Region):
self.db = db_for(region)
self.objects = bucket_for(region)
self.search = index_for(region)
self.queue = queue_for(region)
self.logs = log_sink_for(region) # the one people forgetMaking the regional context the only way to reach storage is the structural control. If a global client exists anywhere in the codebase, someone will use it in a background job at some point, and you will find out during an audit rather than in review.
The things that leak
- Logs and traces. Error messages carry customer data, and most observability backends are a single global region by default.
- Backups and disaster recovery copies, which frequently default to a different region precisely because that is good engineering practice everywhere else.
- Queue payloads and caches — ephemeral is not a residency exemption, and a message sitting in a dead-letter queue for a week is storage.
- Third-party processors: your email provider, error tracker, analytics, support tooling and any AI model provider all need a documented regional posture.
- Support access. An engineer in another jurisdiction viewing customer data through an admin tool is a transfer, whatever the data's physical location.
Residency is not a database setting. It is a property of every system that has ever touched the data, including the ones you use for debugging.
One piece of commercial advice worth more than most of the technical detail: get precise about what the requirement actually is before designing for the strictest possible reading. 'Data must stay in the EU' and 'no non-EU person may access the data' and 'the operator must not be subject to foreign jurisdiction' are three very different requirements with wildly different costs. Teams routinely build for the third when the contract asks for the first.