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:
| Service | AWS | GCP | Azure | Portable Alternative |
|---|---|---|---|---|
| Relational DB | RDS PostgreSQL | Cloud SQL PostgreSQL | Azure Database for PostgreSQL | PostgreSQL (any provider) |
| Cache | ElastiCache Redis | Memorystore Redis | Azure Cache for Redis | Redis (any provider) |
| Message Queue | MSK (Kafka) | Managed Kafka (via Confluent) | Event Hubs (Kafka protocol) | Apache Kafka |
| Container Orchestration | EKS | GKE | AKS | Kubernetes (any provider) |
| Object Storage | S3 | Cloud Storage | Blob Storage | MinIO (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
| Route | VPN (typical) | Dedicated Interconnect |
|---|---|---|
| eu-west-1 ↔ europe-west1 (same metro) | 5-15ms | 1-3ms |
| eu-west-1 ↔ asia-south1 | 120-160ms | 100-130ms |
| us-east-1 ↔ us-central1 | 20-40ms | 10-20ms |
| Same cloud, same region | <1ms | N/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)
| Provider | First 1 TB/month | 1-10 TB/month | 10-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 →