Skip to main content
Stripe SystemsStripe Systems
Cloud Computing📅 March 22, 2026· 19 min read

Multi-Cloud Architecture: Avoiding Vendor Lock-in Without Sacrificing Performance

✍️
Stripe Systems Engineering

Multi-cloud is one of the most oversold ideas in infrastructure. The pitch is simple: run workloads across AWS, GCP, and Azure to avoid vendor lock-in, improve resilience, and negotiate better pricing. The reality is that multi-cloud introduces operational complexity that most organizations underestimate — and the supposed benefits only materialize under specific conditions.

This post is a practical guide for engineering teams evaluating or implementing multi-cloud. We will cover when it actually makes sense, how to build portable infrastructure without sacrificing performance, and where the costs (both financial and operational) are real. Everything here is grounded in production experience.

When Multi-Cloud Makes Sense — and When It Doesn't

Before investing in multi-cloud, be honest about why you want it.

Legitimate reasons:

  • Regulatory data residency requirements. If you must store EU citizen data in EU-based infrastructure and Indian citizen data in India, and your primary cloud provider has limited regions in one jurisdiction, you may need a second provider.
  • Acquiring a company that runs on a different cloud. You inherit their infrastructure. Migration is expensive and risky. Running both for an extended period is a pragmatic choice.
  • Specific managed services with no equivalent. GCP's BigQuery for analytics, AWS's SageMaker for ML pipelines, Azure's Active Directory integration — sometimes one provider has a genuinely superior service for a specific workload.
  • Contractual or procurement constraints. Government contracts or enterprise clients may mandate specific providers.

Poor reasons:

  • "Avoiding vendor lock-in" as an abstract goal. If you are a 50-person startup, the cost of building and maintaining cloud-agnostic abstractions far exceeds the hypothetical cost of migrating later.
  • Negotiating better pricing. In practice, cloud pricing negotiations depend on committed spend. Splitting spend across providers weakens your negotiating position with each one.
  • Disaster recovery. A well-architected multi-region deployment within a single cloud provider gives you comparable resilience at a fraction of the operational cost.

The honest assessment: multi-cloud adds 30-60% operational overhead in terms of tooling, team skills, and debugging complexity. Adopt it only when the business case is unambiguous.

Abstraction Layers: Terraform, Pulumi, and Crossplane

If you commit to multi-cloud, infrastructure-as-code abstraction is non-negotiable. You need a single workflow to provision resources across providers. The three practical options are Terraform, Pulumi, and Crossplane.

Terraform Provider Abstraction

Terraform handles multi-cloud through its provider model. You declare providers for each cloud, and modules abstract the differences:

# providers.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

provider "google" {
  project = var.gcp_project
  region  = var.gcp_region
}

The key pattern is writing modules that accept a cloud_provider variable and dispatch to the correct resource type:

# modules/database/main.tf
variable "cloud_provider" {
  type        = string
  description = "Target cloud: aws or gcp"
  validation {
    condition     = contains(["aws", "gcp"], var.cloud_provider)
    error_message = "Supported providers: aws, gcp."
  }
}

variable "db_name" {
  type = string
}

variable "db_tier" {
  type    = string
  default = "small"
}

locals {
  aws_instance_class = {
    small  = "db.t3.medium"
    medium = "db.r6g.large"
    large  = "db.r6g.2xlarge"
  }
  gcp_tier = {
    small  = "db-custom-2-7680"
    medium = "db-custom-4-15360"
    large  = "db-custom-8-30720"
  }
}

resource "aws_db_instance" "postgres" {
  count                = var.cloud_provider == "aws" ? 1 : 0
  identifier           = var.db_name
  engine               = "postgres"
  engine_version       = "16.4"
  instance_class       = local.aws_instance_class[var.db_tier]
  allocated_storage    = 100
  storage_encrypted    = true
  publicly_accessible  = false
  skip_final_snapshot  = false
}

resource "google_sql_database_instance" "postgres" {
  count            = var.cloud_provider == "gcp" ? 1 : 0
  name             = var.db_name
  database_version = "POSTGRES_16"
  region           = var.gcp_region

  settings {
    tier              = local.gcp_tier[var.db_tier]
    availability_type = "REGIONAL"

    ip_configuration {
      ipv4_enabled    = false
      private_network = var.vpc_id
    }
  }
}

output "connection_string" {
  value = var.cloud_provider == "aws" ? aws_db_instance.postgres[0].endpoint : google_sql_database_instance.postgres[0].connection_name
}

This approach works but has a clear limitation: your modules grow linearly with each provider you support. For teams managing more than two providers, consider Crossplane.

Crossplane for Kubernetes-Native Abstraction

Crossplane runs as a Kubernetes operator and lets you define cloud resources as custom Kubernetes objects. The advantage is that teams already using Kubernetes get a consistent API surface:

apiVersion: database.crossplane.io/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: fintech-primary
spec:
  parameters:
    storageGB: 100
    version: "16"
  compositionSelector:
    matchLabels:
      provider: aws
      region: eu-west-1
  writeConnectionSecretToRef:
    name: db-credentials

Crossplane compositions map this to the correct provider-specific resource. The tradeoff is that you now depend on Kubernetes as your control plane — which is itself a significant operational commitment.

The Portable Services Stack

Not every managed service is portable. The pragmatic approach is to standardize on services that have near-equivalent managed offerings across clouds:

ServiceAWSGCPAzurePortable Alternative
Relational DBRDS PostgreSQLCloud SQL PostgreSQLAzure Database for PostgreSQLPostgreSQL (any provider)
CacheElastiCache RedisMemorystore RedisAzure Cache for RedisRedis (any provider)
Message QueueMSK (Kafka)Managed Kafka (via Confluent)Event Hubs (Kafka protocol)Apache Kafka
Container OrchestrationEKSGKEAKSKubernetes (any provider)
Object StorageS3Cloud StorageBlob StorageMinIO (self-managed) or S3-compatible API

PostgreSQL, Redis, Kafka, and Kubernetes form the common denominator. If your application code talks only to these interfaces, the infrastructure layer becomes swappable. Avoid cloud-specific features like Aurora Serverless v2 or Cloud Spanner unless you have a concrete reason — they become anchors.

Object storage deserves special mention. AWS S3's API has become a de facto standard. Both GCP Cloud Storage and MinIO support S3-compatible endpoints. Write your application against the S3 API and you preserve portability even if the underlying storage changes.

Data Residency and Compliance

Data residency is the most common legitimate driver of multi-cloud adoption. Three regulatory frameworks matter for most organizations:

  • GDPR (EU): Personal data of EU residents must be processed under GDPR protections. While GDPR does not strictly require data to stay within the EU, Schrems II rulings make cross-border transfers legally complex. Hosting in EU regions is the simplest compliance path.
  • India's DPDP Act (2023): The Digital Personal Data Protection Act allows the Indian government to restrict cross-border data transfers to specific countries via notification. Financial data in particular faces pressure to remain within Indian borders.
  • Sector-specific rules: PCI DSS for payment data, HIPAA for healthcare, RBI guidelines for financial data in India — each adds constraints on where data can reside and how it can be replicated.

When your users span jurisdictions with conflicting residency requirements, multi-cloud (or at minimum multi-region within a single cloud) becomes architecturally necessary. The challenge is that most managed database services do not support cross-cloud replication natively, which pushes you toward either self-managed databases or application-level replication patterns.

DNS-Based Traffic Routing

DNS is the simplest mechanism for directing traffic to the correct cloud based on user geography or failover conditions. All three major providers offer weighted, geolocation, and health-check-based DNS routing.

A typical pattern uses a top-level domain with geographic routing:

# AWS Route53 CLI — create geolocation routing policy
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1234567890 \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "api.example.com",
        "Type": "A",
        "SetIdentifier": "eu-traffic",
        "GeoLocation": { "ContinentCode": "EU" },
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "eu-alb-1234567.eu-west-1.elb.amazonaws.com",
          "EvaluateTargetHealth": true
        }
      }
    }, {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "api.example.com",
        "Type": "A",
        "SetIdentifier": "india-traffic",
        "GeoLocation": { "CountryCode": "IN" },
        "AliasTarget": {
          "HostedZoneId": "Z1BNKLHFG3H9OA",
          "DNSName": "gcp-proxy.asia-south1.example.com",
          "EvaluateTargetHealth": true
        }
      }
    }]
  }'

For cross-cloud setups, Route53 or Cloud DNS can point to load balancer endpoints in different providers. The critical detail is health checking — configure health checks against each cloud's endpoint so DNS automatically fails over if one becomes unavailable:

# Terraform: Route53 health check for GCP endpoint
resource "aws_route53_health_check" "gcp_api" {
  fqdn              = "gcp-proxy.asia-south1.example.com"
  port               = 443
  type               = "HTTPS"
  resource_path      = "/healthz"
  failure_threshold  = 3
  request_interval   = 10

  tags = {
    Name = "gcp-asia-south1-health"
  }
}

DNS TTLs matter here. Set TTLs to 60 seconds or less for failover records. Higher TTLs mean clients continue hitting a failed endpoint for the duration of the cached record.

Cross-Cloud Networking

Networking between clouds is where multi-cloud gets expensive and latency-sensitive. There are two approaches: VPN tunnels over the public internet and dedicated interconnects.

VPN Tunnels

A site-to-site VPN between AWS VPC and GCP VPC is the simplest option. Both providers support IPsec tunnels:

# Terraform: AWS VPN Gateway
resource "aws_vpn_gateway" "main" {
  vpc_id = aws_vpc.primary.id
  tags   = { Name = "cross-cloud-vpn" }
}

resource "aws_customer_gateway" "gcp" {
  bgp_asn    = 65000
  ip_address = google_compute_address.vpn_static_ip.address
  type       = "ipsec.1"
  tags       = { Name = "gcp-customer-gateway" }
}

resource "aws_vpn_connection" "to_gcp" {
  vpn_gateway_id      = aws_vpn_gateway.main.id
  customer_gateway_id = aws_customer_gateway.gcp.id
  type                = "ipsec.1"
  static_routes_only  = false
  tags                = { Name = "aws-to-gcp-vpn" }
}

Typical latency over VPN between AWS eu-west-1 and GCP asia-south1 (Mumbai) is 120-160ms. Between regions in the same geography (e.g., AWS eu-west-1 and GCP europe-west1), expect 5-15ms. VPN throughput caps at roughly 1.25 Gbps per tunnel on AWS; you can aggregate up to four tunnels per connection for higher throughput.

Dedicated Interconnects

For production workloads with high bandwidth or strict latency requirements, dedicated interconnects are justified:

  • AWS Direct Connect: 1 Gbps or 10 Gbps dedicated connections through colocation facilities.
  • GCP Partner Interconnect: 50 Mbps to 50 Gbps through service provider partners.

Using a colocation provider like Equinix, you can terminate both AWS Direct Connect and GCP Partner Interconnect in the same facility and cross-connect them. This reduces inter-cloud latency to 1-5ms for same-metro connections and provides consistent bandwidth. The cost is significant — a 1 Gbps Direct Connect port runs approximately $0.30/hour ($220/month) plus data transfer, and GCP Partner Interconnect pricing varies by partner and capacity.

Latency Reference

RouteVPN (typical)Dedicated Interconnect
eu-west-1 ↔ europe-west1 (same metro)5-15ms1-3ms
eu-west-1 ↔ asia-south1120-160ms100-130ms
us-east-1 ↔ us-central120-40ms10-20ms
Same cloud, same region<1msN/A

These numbers matter for database replication. Synchronous replication across 130ms of latency is impractical for transactional workloads.

Container Portability

Kubernetes provides the orchestration abstraction, but your container images and application code must cooperate. Three rules keep containers portable:

1. Build cloud-agnostic images. Do not bake cloud-specific SDKs or credentials into images. Use multi-stage Docker builds with a minimal runtime:

# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server

# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
ENTRYPOINT ["/server"]

2. Use environment variables for cloud-specific configuration. Storage bucket names, database connection strings, and queue URLs should come from environment variables or Kubernetes ConfigMaps/Secrets — never hardcoded:

# k8s deployment excerpt
env:
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: connection_string
  - name: OBJECT_STORE_BUCKET
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: storage_bucket
  - name: OBJECT_STORE_ENDPOINT
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: storage_endpoint

3. Use interface adapters for cloud services. Define interfaces in your application code and implement adapters for each provider. This keeps business logic clean:

// storage.go — interface definition
package storage

import (
    "context"
    "io"
)

type ObjectStore interface {
    Put(ctx context.Context, key string, reader io.Reader) error
    Get(ctx context.Context, key string) (io.ReadCloser, error)
    Delete(ctx context.Context, key string) error
}
// storage_s3.go — AWS/S3-compatible implementation
package storage

import (
    "context"
    "io"

    "github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Store struct {
    client *s3.Client
    bucket string
}

func NewS3Store(client *s3.Client, bucket string) *S3Store {
    return &S3Store{client: client, bucket: bucket}
}

func (s *S3Store) Put(ctx context.Context, key string, reader io.Reader) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
        Body:   reader,
    })
    return err
}

func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, error) {
    out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
    })
    if err != nil {
        return nil, err
    }
    return out.Body, nil
}

func (s *S3Store) Delete(ctx context.Context, key string) error {
    _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
    })
    return err
}
// storage_gcs.go — GCP implementation
package storage

import (
    "context"
    "io"

    gcs "cloud.google.com/go/storage"
)

type GCSStore struct {
    client *gcs.Client
    bucket string
}

func NewGCSStore(client *gcs.Client, bucket string) *GCSStore {
    return &GCSStore{client: client, bucket: bucket}
}

func (g *GCSStore) Put(ctx context.Context, key string, reader io.Reader) error {
    w := g.client.Bucket(g.bucket).Object(key).NewWriter(ctx)
    if _, err := io.Copy(w, reader); err != nil {
        w.Close()
        return err
    }
    return w.Close()
}

func (g *GCSStore) Get(ctx context.Context, key string) (io.ReadCloser, error) {
    return g.client.Bucket(g.bucket).Object(key).NewReader(ctx)
}

func (g *GCSStore) Delete(ctx context.Context, key string) error {
    return g.client.Bucket(g.bucket).Object(key).Delete(ctx)
}

At application startup, read an environment variable to decide which adapter to instantiate. The rest of your codebase only ever interacts with the ObjectStore interface.

Database Replication Across Clouds

Cross-cloud database replication is the hardest problem in multi-cloud. Your options:

Option 1: PostgreSQL Logical Replication

PostgreSQL's built-in logical replication works across any two PostgreSQL instances, regardless of where they are hosted. You configure the primary (on AWS RDS, for example) as a publisher and the secondary (on GCP Cloud SQL) as a subscriber:

-- On the AWS RDS primary: create a publication
CREATE PUBLICATION fintech_pub FOR TABLE
    accounts, transactions, user_profiles;

-- On the GCP Cloud SQL replica: create a subscription
CREATE SUBSCRIPTION fintech_sub
    CONNECTION 'host=rds-primary.eu-west-1.rds.amazonaws.com
                port=5432
                dbname=fintech
                user=replication_user
                password=REDACTED
                sslmode=require'
    PUBLICATION fintech_pub;

Pros: Works with managed PostgreSQL on any cloud. No additional software. Selective table replication. Cons: Logical replication is asynchronous — the replica lags behind the primary. At 130ms inter-cloud latency, expect 200-500ms of replication lag under normal load, potentially seconds under heavy write throughput. DDL changes (schema migrations) are not replicated automatically.

Option 2: CockroachDB

CockroachDB is a distributed SQL database that natively supports multi-region and multi-cloud deployments. You define locality for each node, and CockroachDB handles replication and consensus:

-- Configure locality-aware replication
ALTER DATABASE fintech SET PRIMARY REGION "eu-west-1";
ALTER DATABASE fintech ADD REGION "asia-south1";

-- Pin specific tables to regions for compliance
ALTER TABLE user_profiles_eu SET LOCALITY REGIONAL BY ROW;
ALTER TABLE user_profiles_in SET LOCALITY REGIONAL IN "asia-south1";

Pros: Strong consistency across regions. Automatic failover. SQL-compatible. Cons: Self-managed (no equivalent managed service across both AWS and GCP). Write latency is bounded by inter-region consensus — a write touching asia-south1 data from eu-west-1 incurs at minimum one round trip (~260ms). Operational complexity is high.

Option 3: YugabyteDB

Similar to CockroachDB in design — a distributed PostgreSQL-compatible database. YugabyteDB offers a managed service (Yugabyte Aeon) that supports multi-cloud deployments:

Pros: PostgreSQL wire protocol compatibility (easier migration from standard PostgreSQL). Managed offering reduces operational burden. Cons: Same fundamental latency constraints as CockroachDB for cross-region writes. PostgreSQL compatibility is not 100% — some extensions and behaviors differ.

Recommendation

For read-heavy workloads where eventual consistency on the replica is acceptable, PostgreSQL logical replication is the simplest and most proven approach. For workloads requiring strong consistency across regions, CockroachDB or YugabyteDB are appropriate — but expect significantly higher operational investment and write latency.

Observability Across Clouds

Centralized observability is mandatory in multi-cloud. If your logs, metrics, and traces are split across CloudWatch and Google Cloud Logging, debugging cross-cloud issues becomes a nightmare.

The standard approach: OpenTelemetry collectors in each cloud, exporting to a centralized Grafana stack.

OpenTelemetry Collector Configuration

Deploy an OTel collector as a DaemonSet in each Kubernetes cluster:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

  # Scrape Kubernetes metrics
  prometheus:
    config:
      scrape_configs:
        - job_name: 'k8s-pods'
          kubernetes_sd_configs:
            - role: pod
          relabel_configs:
            - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
              action: keep
              regex: true

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024

  # Tag all telemetry with cloud provider and region
  resource:
    attributes:
      - key: cloud.provider
        value: "${CLOUD_PROVIDER}"
        action: upsert
      - key: cloud.region
        value: "${CLOUD_REGION}"
        action: upsert
      - key: deployment.environment
        value: "${ENVIRONMENT}"
        action: upsert

  # Filter noisy health check spans
  filter:
    spans:
      exclude:
        match_type: strict
        attributes:
          - key: http.target
            value: /healthz
          - key: http.target
            value: /readyz

exporters:
  otlphttp/traces:
    endpoint: https://tempo.observability.internal:4318
    tls:
      cert_file: /etc/otel/tls/client.crt
      key_file: /etc/otel/tls/client.key

  prometheusremotewrite:
    endpoint: https://mimir.observability.internal/api/v1/push
    tls:
      cert_file: /etc/otel/tls/client.crt
      key_file: /etc/otel/tls/client.key

  loki:
    endpoint: https://loki.observability.internal/loki/api/v1/push
    tls:
      cert_file: /etc/otel/tls/client.crt
      key_file: /etc/otel/tls/client.key

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [filter, resource, batch]
      exporters: [otlphttp/traces]
    metrics:
      receivers: [otlp, prometheus]
      processors: [resource, batch]
      exporters: [prometheusremotewrite]
    logs:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [loki]

The resource processor is critical — it stamps every piece of telemetry with cloud.provider and cloud.region, enabling you to filter and correlate across clouds in Grafana dashboards.

The backend stack — Grafana Tempo for traces, Mimir for metrics, Loki for logs — can run in either cloud or on dedicated infrastructure. For most teams, running it in the primary cloud and accepting the cross-cloud data transfer cost is simpler than self-hosting in a colocation facility.

The Cost of Multi-Cloud

Egress charges are the hidden tax of multi-cloud. Every byte that crosses from one cloud to another incurs data transfer fees from both sides.

Egress Pricing (as of 2026)

ProviderFirst 1 TB/month1-10 TB/month10-50 TB/month
AWS (to internet)$0.09/GB$0.085/GB$0.07/GB
GCP (to internet)$0.12/GB$0.11/GB$0.08/GB
Azure (to internet)$0.087/GB$0.083/GB$0.07/GB

Inter-cloud traffic is billed as internet egress by both providers. If you replicate 500 GB of database WAL per month from AWS to GCP, you pay approximately $45 on the AWS side and $0 on the GCP ingress side (ingress is typically free). That sounds manageable, but add observability data (metrics, logs, traces), container image pulls, API traffic between services, and it accumulates quickly. A moderately active multi-cloud deployment can easily incur $2,000-5,000/month in cross-cloud egress alone.

Operational Cost

Beyond egress, account for:

  • Team skills. Your engineers need to be proficient in at least two cloud platforms. Training, certifications, and context-switching overhead are real.
  • Tooling divergence. CLI tools, IAM models, networking abstractions, and monitoring differ across providers. Even with Terraform, debugging provider-specific issues requires provider-specific knowledge.
  • Incident response. When a cross-cloud issue occurs, you need to correlate logs and metrics from two different platforms simultaneously. Mean time to resolution increases.
  • Security surface area. Two sets of IAM policies, two sets of network security rules, two sets of audit trails. Every additional cloud doubles your security review scope.

Decision Framework

Rather than treating multi-cloud as a binary choice, use a three-tier framework:

Tier 1: Single-Cloud-First

When: You have no regulatory requirement for multiple providers. Your team is small. You are optimizing for velocity.

Strategy: Pick one cloud. Use its managed services freely. Invest in multi-region within that cloud for resilience. Write clean interfaces in your application code (the adapter pattern described earlier) so that a future migration is possible but not premature.

Tier 2: Multi-Cloud-Ready

When: You anticipate regulatory requirements within 12-18 months. You are growing into new geographies. Your team is large enough to absorb some additional infrastructure complexity.

Strategy: Standardize on portable services (PostgreSQL, Redis, Kafka, Kubernetes). Use Terraform with provider abstraction from day one. Avoid cloud-specific managed services for core workloads. You are not actively running on multiple clouds, but your architecture could support it with 2-4 weeks of infrastructure work.

Tier 3: Active Multi-Cloud

When: You have a concrete, current business or regulatory requirement. You have a dedicated platform engineering team.

Strategy: Implement the full stack — cross-cloud networking, database replication, centralized observability, DNS-based traffic routing. Budget for the egress costs and operational overhead. Staff your platform team accordingly.

Most organizations should be at Tier 1 or Tier 2. Tier 3 is justified only with a clear and present need.

Case Study: Fintech Data Residency on AWS and GCP

A fintech company processing payments for merchants in both the EU and India faced a concrete data residency challenge. EU transaction data had to remain in EU-hosted infrastructure under GDPR. Indian transaction data needed to stay within India to satisfy RBI data localization guidelines. The primary workload — payment processing, merchant dashboards, reconciliation — ran on AWS in eu-west-1 (Ireland). However, AWS's Mumbai region (ap-south-1) alone did not satisfy the client's requirement for a geographically diverse Indian hosting option, and the Indian operations team preferred GCP's asia-south1 (Mumbai) for cost reasons and existing familiarity with BigQuery for analytics.

Stripe Systems designed and implemented the cross-cloud architecture. Here is how it was built.

Terraform Module Structure

infrastructure/
├── environments/
│   ├── eu-production/
│   │   ├── main.tf          # AWS eu-west-1 resources
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── india-production/
│       ├── main.tf          # GCP asia-south1 resources
│       ├── variables.tf
│       └── terraform.tfvars
├── modules/
│   ├── database/
│   │   ├── main.tf          # Provider-abstracted DB (shown earlier)
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── kubernetes/
│   │   ├── aws-eks/
│   │   │   └── main.tf
│   │   └── gcp-gke/
│   │       └── main.tf
│   ├── networking/
│   │   ├── aws-vpc/
│   │   │   └── main.tf
│   │   ├── gcp-vpc/
│   │   │   └── main.tf
│   │   └── cross-cloud-vpn/
│   │       └── main.tf      # VPN tunnel between AWS and GCP
│   └── observability/
│       └── otel-collector/
│           ├── daemonset.yaml
│           └── config.yaml
└── shared/
    ├── dns/
    │   └── main.tf           # Route53 geolocation routing
    └── tls/
        └── main.tf           # Shared TLS certificates

Network Architecture

The two environments connect via a pair of IPsec VPN tunnels for redundancy:

┌─────────────────────────────┐         ┌──────────────────────────────┐
│  AWS eu-west-1              │         │  GCP asia-south1             │
│                             │         │                              │
│  ┌───────────┐              │  VPN    │              ┌────────────┐  │
│  │  EKS      │──┐           │ Tunnel  │           ┌──│  GKE       │  │
│  │  Cluster  │  │           │◄──────►│           │  │  Cluster   │  │
│  └───────────┘  │           │ (IPsec) │           │  └────────────┘  │
│                 ▼           │         │           ▼                  │
│  ┌───────────────────────┐  │         │  ┌─────────────────────────┐ │
│  │  VPC: 10.0.0.0/16     │  │         │  │  VPC: 10.1.0.0/16      │ │
│  │  ┌─────────────────┐  │  │         │  │  ┌──────────────────┐  │ │
│  │  │ RDS PostgreSQL   │  │  │         │  │  │ Cloud SQL PG     │  │ │
│  │  │ (Primary)        │──┼──┼─────────┼──┼─►│ (Read Replica)   │  │ │
│  │  └─────────────────┘  │  │  Logical │  │  └──────────────────┘  │ │
│  └───────────────────────┘  │  Replic. │  └─────────────────────────┘ │
└─────────────────────────────┘         └──────────────────────────────┘
                │                                        │
                ▼                                        ▼
        Route53 (EU traffic)                  Cloud DNS (India traffic)
                └──────────────┬─────────────────────────┘
                               ▼
                        api.example.com
                   (Geolocation-based routing)

The VPN tunnels use BGP for dynamic route advertisement. AWS VPC uses CIDR 10.0.0.0/16, GCP VPC uses 10.1.0.0/16 — non-overlapping ranges are essential.

Database Replication

The primary PostgreSQL on AWS RDS (eu-west-1) publishes three tables to GCP Cloud SQL (asia-south1) via logical replication. The Cloud SQL instance serves read-only queries for the Indian merchant dashboard:

-- AWS RDS: Enable logical replication (requires parameter group change)
-- rds.logical_replication = 1 (set in RDS parameter group, requires reboot)

-- Create replication user with limited privileges
CREATE ROLE repl_user WITH REPLICATION LOGIN PASSWORD '...';
GRANT SELECT ON accounts, transactions, user_profiles TO repl_user;

-- Create publication
CREATE PUBLICATION india_read_pub FOR TABLE
    accounts, transactions, user_profiles
    WHERE (region = 'IN');
-- GCP Cloud SQL: Subscribe
-- Requires cloudsql.logical_decoding = on (set via instance flags)

CREATE SUBSCRIPTION india_read_sub
    CONNECTION 'host=primary.eu-west-1.rds.amazonaws.com
                port=5432
                dbname=fintech_prod
                user=repl_user
                password=...
                sslmode=verify-full
                sslrootcert=/etc/ssl/aws-rds-ca.pem'
    PUBLICATION india_read_pub;

The WHERE (region = 'IN') filter on the publication ensures only Indian user data replicates to GCP, satisfying both GDPR (EU data stays in EU) and Indian data localization (Indian data is available in India).

Measured replication lag under production load averaged 350ms, with spikes to 1.2 seconds during batch reconciliation jobs. The Indian merchant dashboard displays a "data as of" timestamp, and the application code accounts for eventual consistency — read-after-write operations for Indian merchants route to the AWS primary via a dedicated API path when consistency is critical.

Application-Level Cloud Abstraction

The application uses the adapter pattern to remain cloud-agnostic. Here is the storage interface and provider selection from the actual implementation:

// cmd/server/main.go — provider selection at startup
func initObjectStore(cfg Config) (storage.ObjectStore, error) {
    switch cfg.CloudProvider {
    case "aws":
        awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
            awsconfig.WithRegion(cfg.CloudRegion),
        )
        if err != nil {
            return nil, fmt.Errorf("loading AWS config: %w", err)
        }
        client := s3.NewFromConfig(awsCfg)
        return storage.NewS3Store(client, cfg.StorageBucket), nil

    case "gcp":
        client, err := gcs.NewClient(context.Background())
        if err != nil {
            return nil, fmt.Errorf("creating GCS client: %w", err)
        }
        return storage.NewGCSStore(client, cfg.StorageBucket), nil

    default:
        return nil, fmt.Errorf("unsupported cloud provider: %s", cfg.CloudProvider)
    }
}

The Config struct is populated entirely from environment variables. The same container image deploys to both EKS and GKE — only the ConfigMap and Secrets differ between environments.

Results

The architecture has been running in production for eight months. Key operational metrics:

  • Cross-cloud VPN uptime: 99.97% (two brief outages caused by GCP maintenance windows, automatic failover to secondary tunnel).
  • Replication lag (p99): 1.8 seconds. P50 is 350ms.
  • Egress cost: Approximately $1,200/month for replication traffic and cross-cloud API calls.
  • Deployment cadence: Same CI/CD pipeline deploys to both EKS and GKE. Engineers do not interact with cloud-specific tooling during normal development.

The team at Stripe Systems continues to maintain the infrastructure, with ongoing work to evaluate replacing the VPN tunnels with a dedicated interconnect as Indian traffic grows.

Conclusion

Multi-cloud is a tool, not a goal. It solves specific problems — data residency, regulatory compliance, inherited infrastructure — and introduces specific costs. The engineering challenge is building abstractions that provide portability without burying your team in unnecessary complexity.

The practical path for most teams: start single-cloud, write clean interfaces, standardize on portable services, and adopt multi-cloud only when a concrete requirement demands it. When that requirement arrives, the patterns described here — Terraform provider abstraction, logical replication, interface adapters, centralized observability — give you a proven foundation to build on.

Ready to discuss your project?

Get in Touch →
← Back to Blog

More Articles