Back to blog
architectureGDPRinfrastructurescalingcompliance

Multi-Region Deployment and Data Residency for Your SaaS: The Complete Guide

By SaaS Masters24 maart 20267 min read

More and more SaaS companies face the question: where does your data actually live? EU customers want guarantees about data residency, enterprise clients demand specific regions, and regulations like GDPR make it non-optional. In this article, we dive deep into multi-region deployment: why you need it, how to architect it, and which pitfalls to avoid.

Why multi-region deployment?

There are three main reasons SaaS companies go multi-region:

1. Compliance and data residency

The GDPR requires that personal data of EU citizens is adequately protected. While the GDPR technically doesn't require data to stay in the EU, many customers — especially in healthcare, finance, and government — expect their data to be stored in a specific region.

Customer: "Where is our data stored?"
You:      "Uh... somewhere in us-east-1?"
Customer: *cancels contract*

2. Latency and performance

A user in Frankfurt fetching data from a datacenter in Virginia experiences 80-120ms extra latency per request. With a typical page load of 5-10 API calls, you'll notice that immediately.

3. Availability and disaster recovery

One region is one single point of failure. AWS us-east-1 has had multiple major outages. If your SaaS runs there and it goes down, your business goes down too.

Architecture patterns for multi-region

There are three common patterns, each with their own trade-offs:

Pattern 1: Regional isolation (recommended for starters)

Each region runs as a completely independent stack. Customers are assigned to a region at creation time and their data never leaves that region.

// Example: region routing during onboarding
interface TenantConfig {
  id: string;
  region: 'eu-west-1' | 'us-east-1' | 'ap-southeast-1';
  databaseUrl: string;
  apiEndpoint: string;
}

async function createTenant(
  name: string,
  country: string
): Promise<TenantConfig> {
  // Determine region based on country
  const region = resolveRegion(country);

  // Create tenant in the correct regional database
  const regionalClient = getRegionalClient(region);
  const tenant = await regionalClient.tenant.create({
    data: { name, country, region }
  });

  return {
    id: tenant.id,
    region,
    databaseUrl: REGIONAL_DB_URLS[region],
    apiEndpoint: REGIONAL_API_URLS[region],
  };
}

function resolveRegion(country: string): string {
  const EU_COUNTRIES = ['NL', 'DE', 'FR', 'BE', 'ES', 'IT', /* ... */];
  const APAC_COUNTRIES = ['AU', 'JP', 'SG', 'KR', /* ... */];

  if (EU_COUNTRIES.includes(country)) return 'eu-west-1';
  if (APAC_COUNTRIES.includes(country)) return 'ap-southeast-1';
  return 'us-east-1'; // default
}

Advantages:

  • Simple to understand and implement
  • Complete data isolation per region
  • Compliance is trivial: data never leaves the region

Disadvantages:

  • Cross-region features (like global analytics) are complex
  • You manage multiple independent stacks

Pattern 2: Primary region with read replicas

One primary database handling write operations, with read replicas in other regions for fast read operations.

// Database routing based on operation type
class RegionalDatabaseRouter {
  private primaryClient: PrismaClient;
  private replicaClients: Map<string, PrismaClient>;

  async query<T>(
    operation: 'read' | 'write',
    region: string,
    queryFn: (client: PrismaClient) => Promise<T>
  ): Promise<T> {
    if (operation === 'write') {
      // Write operations always go to primary
      return queryFn(this.primaryClient);
    }

    // Read operations go to nearest replica
    const replica = this.replicaClients.get(region)
      || this.primaryClient;
    return queryFn(replica);
  }
}

Note: this pattern doesn't satisfy strict data residency requirements, as all data ultimately resides in the primary region.

Pattern 3: Distributed database (CockroachDB / YugabyteDB)

Databases like CockroachDB natively support geo-partitioning: you can specify per table or per row which region data should live in.

-- CockroachDB: pin data to a region
ALTER TABLE users
  CONFIGURE ZONE USING
    constraints = '[+region=eu-west-1]'
  WHERE country IN ('NL', 'DE', 'FR', 'BE');

ALTER TABLE users
  CONFIGURE ZONE USING
    constraints = '[+region=us-east-1]'
  WHERE country IN ('US', 'CA');

This is powerful but complex. Only use it if you truly need global consistency.

The routing layer: how do users reach the right region?

A crucial component is routing requests to the correct region. Here are three approaches:

DNS-based routing

# Region-specific subdomains
eu.app.saasmasters.nl   → EU-west load balancer
us.app.saasmasters.nl   → US-east load balancer
ap.app.saasmasters.nl   → AP-southeast load balancer

API Gateway with tenant lookup

// Central gateway that routes based on tenant
import { Router } from 'express';

const router = Router();

router.use(async (req, res, next) => {
  const tenantId = extractTenantId(req);

  // Lookup in global tenant registry (cached)
  const tenantRegion = await tenantRegistry.getRegion(tenantId);

  if (tenantRegion !== CURRENT_REGION) {
    // Proxy to the correct region
    const targetUrl = REGIONAL_URLS[tenantRegion] + req.originalUrl;
    return proxy(req, res, targetUrl);
  }

  next();
});

Edge-based routing (Cloudflare Workers / Vercel Edge)

// Cloudflare Worker for intelligent routing
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const tenantId = url.hostname.split('.')[0];

    // Tenant-region mapping from KV store
    const region = await TENANT_REGIONS.get(tenantId);

    const origins: Record<string, string> = {
      'eu': 'https://eu-origin.example.com',
      'us': 'https://us-origin.example.com',
      'ap': 'https://ap-origin.example.com',
    };

    // Forward to correct origin
    const originUrl = origins[region || 'eu'] + url.pathname;
    return fetch(originUrl, {
      method: request.method,
      headers: request.headers,
      body: request.body,
    });
  }
};

Database migrations across regions

One of the trickiest operational challenges: how do you keep database schemas in sync across multiple regions?

// Migration orchestrator for multi-region
class MultiRegionMigrator {
  private regions: RegionConfig[];

  async migrate(migrationFile: string): Promise<void> {
    console.log(\`Migrating \${migrationFile} across \${this.regions.length} regions\`);

    // Step 1: Validate migration in staging region
    await this.runMigration('staging', migrationFile);
    await this.validateSchema('staging');

    // Step 2: Run migration per region, sequentially
    for (const region of this.regions) {
      console.log(\`Migrating region: \${region.name}\`);

      try {
        await this.runMigration(region.name, migrationFile);
        await this.validateSchema(region.name);
        console.log(\`✓ \${region.name} migrated successfully\`);
      } catch (error) {
        console.error(\`✗ \${region.name} migration failed!\`);
        // Stop immediately — fix the issue before continuing
        await this.alertOps(region.name, error);
        throw error;
      }
    }
  }
}

Golden rules for multi-region migrations:

  • Always use backward-compatible migrations (don't drop columns in the same release)
  • Roll out sequentially, not in parallel
  • Automatic rollback strategy per region
  • Schema version tracking in every region

Monitoring and observability

With multiple regions, monitoring becomes significantly more complex. You need a centralized overview:

// Health check aggregator
interface RegionHealth {
  region: string;
  status: 'healthy' | 'degraded' | 'down';
  latencyP99: number;
  errorRate: number;
  activeConnections: number;
  lastCheck: Date;
}

class GlobalHealthMonitor {
  async getStatus(): Promise<RegionHealth[]> {
    const checks = this.regions.map(async (region) => ({
      region: region.name,
      status: await this.checkRegionHealth(region),
      latencyP99: await this.getLatencyP99(region),
      errorRate: await this.getErrorRate(region),
      activeConnections: await this.getConnectionCount(region),
      lastCheck: new Date(),
    }));

    return Promise.all(checks);
  }

  private async checkRegionHealth(region: RegionConfig): Promise<string> {
    const [db, api, cache] = await Promise.all([
      this.pingDatabase(region),
      this.pingApi(region),
      this.pingCache(region),
    ]);

    if (!db || !api) return 'down';
    if (!cache || this.getErrorRate(region) > 0.01) return 'degraded';
    return 'healthy';
  }
}

Costs: what to watch for

Multi-region deployment isn't free. These are the main cost drivers:

Cost itemEstimateTip
Extra database instances€200-800/month per regionStart with the smallest instance
Cross-region data transfer€0.02-0.09/GBMinimize replication data
Extra load balancers€20-50/month per regionUse managed services
CDN/Edge€50-200/monthOften already needed, no extra cost
Monitoring overhead€50-150/monthCentralize where possible

Rule of thumb: expect 1.5-2x your current infrastructure costs per additional region.

Step-by-step plan: from single-region to multi-region

  1. Inventory your data — Which data is region-sensitive? Not everything needs to be migrated.
  2. Choose your pattern — Start with regional isolation unless you have a good reason for something more complex.
  3. Build the routing layer — DNS or edge-based. Test extensively.
  4. Migrate your first region — Choose your second-most-important market. Keep the primary region intact.
  5. Automate deployments — You can't manually deploy to 3+ regions.
  6. Centralize monitoring — One dashboard, all regions.
  7. Test failover — Regularly. Not just when things go wrong.

Conclusion

Multi-region deployment is no longer a luxury for SaaS companies serving the European market. With increasing regulation and higher customer expectations around data residency, it's a matter of when, not if.

Start simple with regional isolation, invest in good routing and monitoring, and expand when your market demands it. The initial investment pays for itself in enterprise deals that would otherwise be impossible.

The most important advice: start designing for it before you need it. Retrofitting multi-region into an application that wasn't built for it is one of the most expensive refactors you can do.