Multi-Cloud Architecture

Design and operate workloads across multiple cloud providers. Covers abstraction layers, data replication, identity federation, cost management, disaster recovery, and the patterns that deliver multi-cloud value without drowning in complexity.

Multi-cloud architecture is a deployment strategy where an organisation uses two or more cloud platforms—such as AWS, Azure, GCP, and DigitalOcean—simultaneously to run its applications, services, and data. It matters when no single cloud provider offers the best combination of cost, performance, availability, or specialized services for all workloads, and when the organisation needs to avoid vendor lock-in, improve resilience, or leverage domain-specific capabilities across clouds.

Designing for Inter-Cloud Workload Distribution

Distribute workloads across multiple clouds to optimise performance, cost, and availability. The core decision is which services run on which platforms, and how they communicate.

A typical pattern involves placing stateful services on a primary cloud—usually AWS—while offloading specific workloads to other clouds based on their strengths. For example, use AWS EC2 for the core application tier, Azure Blob Storage for cold data, GCP BigQuery for analytics, and DigitalOcean for development and staging environments.

To orchestrate this, define deployment manifests that specify the target cloud for each service. Use Kubernetes manifests with nodeSelector and tolerations to place pods on clusters hosted on different clouds. A service running on AWS EKS might have:

apiVersion: v1
kind: Pod
metadata:
  name: analytics-worker
  labels:
    app: analytics
spec:
  nodeSelector:
    cloud: azure
    region: "eastus"
  containers:
  - name: worker
    image: registry.azure.com/analytics/processor:v1.3
    resources:
      requests:
        memory: "2Gi"
        cpu: "1"
      limits:
        memory: "4Gi"
        cpu: "2"

The cloud-specific node labels are set during cluster provisioning using kubectl:

kubectl label nodes ip-10-0-1-100 cloud=aws region=us-west-2
kubectl label nodes ip-10-0-2-200 cloud=azure region=eastus
kubectl label nodes ip-10-0-3-300 cloud=gcp region=europe-west1

Each cloud’s compute platform must expose its nodes as Kubernetes nodes. AWS EKS, Azure AKS, and GCP GKE all support this pattern natively.

When a workload fails to start, check kubectl describe pod <name> and look for No nodes available or NodeSelector terms didn't match, which often indicate that the node labels are missing or misnamed. Common failure: a node labeled cloud: azure but the pod expects cloud: "azure", where the value is a string literal and not a simple key.

Managing Cross-Cloud Data Flow and Replication

Move data between clouds efficiently. Data flows are typically asynchronous and event-driven. Use message queues, file transfer protocols, and database replication.

For data replication from AWS RDS to Azure SQL, use a change data capture (CDC) pipeline. Stream changes from AWS RDS PostgreSQL using logical decoding and pgoutput format, then publish changes to AWS Kinesis.


ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM SET max_wal_senders = 10;
ALTER SYSTEM SET max_replication_slots = 10;
SELECT pg_reload_conf();

Create a replication slot and set up a publication:

CREATE PUBLICATION customer_changes FOR TABLE customers;
SELECT pg_create_logical_replication_slot('customer_slot', 'pgoutput');

On the Azure side, consume the stream using a service written in Python with kinesis-connector and azure-cosmosdb:

import boto3
from azure.cosmos import CosmosClient

kinesis = boto3.client('kinesis', region_name='us-west-2')
client = CosmosClient(url="https://customerdb.documents.azure.com:443/", key="YOUR_KEY")
database = client.get_database_client("CustomerDB")
container = database.get_container_client("Customers")

response = kinesis.get_shard_iterator(
    StreamName="customer-changes",
    ShardIteratorType="LATEST",
    StartingSequenceNumber="123456789"
)

shard_iterator = response['ShardIterator']

while True:
    records = kinesis.get_records(ShardIterator=shard_iterator, Limit=100)
    for record in records['Records']:
        payload = record['Data']
        # Parse JSON from Kinesis payload
        event = json.loads(payload)
        # Upsert to Azure Cosmos DB
        container.upsert_item(body=event['data'])

    shard_iterator = records['NextShardIterator']
    if len(records['Records']) == 0:
        break

Failures here are subtle: a missing pgoutput plugin on AWS RDS, a mismatch between the publication name and the CREATE PUBLICATION statement, or a ShardIterator that becomes stale after 10 minutes, leading to missed records.

Replication can also use file-based pipelines. Use aws s3 sync to copy data from AWS S3 to Azure Blob Storage:

aws s3 sync s3://production-data/ s3://customer-data-backup/ \
  --region us-east-1 \
  --exclude "*.tmp" \
  --include "*.parquet"

Then trigger Azure Data Factory to process the incoming files via a Copy Activity that uses the Azure Blob Storage connector with SAS URL authentication. The SAS URL must include signedStart, signedExpiry, and signedPermissions, all of which are required for the Copy Activity to succeed.

A common silent failure: the Copy Activity runs but logs Status: Succeeded while no files are written. Investigation reveals that the SAS URL is missing ?sv=2020-08-04, and without the version parameter, Azure Data Factory does not recognize the URL as valid.

Establishing Identity and Access Across Clouds

Centralise identity and access management so users and services can authenticate across cloud platforms.

Use OpenID Connect (OIDC) with a single identity provider (IdP) such as Keycloak or Auth0. Define a client in the IdP for each cloud service.

For example, a service running on Google Cloud Platform (GCP) uses a JWT from the IdP to authenticate to AWS Lambda functions:

{
  "iss": "https://auth.example.com",
  "aud": "aws-lambda",
  "sub": "user-12345",
  "exp": 1625097600,
  "iat": 1625094000,
  "azp": "gcp-frontend-app",
  "scope": "read:users write:orders"
}

The AWS Lambda function validates the JWT using the IdP’s jwks_uri and checks the aud claim:

import jwt
import requests

def handler(event, context):
    auth_header = event['headers']['Authorization']
    token = auth_header.split(' ')[1]  # Bearer <token>

    jwks_uri = "https://auth.example.com/.well-known/jwks.json"
    jwks = requests.get(jwks_uri).json()
    public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks['keys'][0])

    try:
        payload = jwt.decode(
            token,
            public_key,
            algorithms=['RS256'],
            audience='aws-lambda'
        )
    except jwt.InvalidAudienceError:
        return {
            'statusCode': 403,
            'body': json.dumps({'error': 'Invalid audience'})
        }

    return {
        'statusCode': 200,
        'body': json.dumps({'message': 'Authenticated', 'user': payload['sub']})
    }

Common failure: audience claim is a string, but the IdP emits a JSON array. The Lambda function accepts aud as a string but expects aud to be a list. The error appears only when a service from Azure is added, which uses aud: ["aws-lambda", "gcp-api"], and the AWS Lambda function rejects it.

To handle cross-cloud service-to-service authentication, use service accounts with short-lived JWTs. For instance, a GCP Cloud Run service calls an Azure Function using a service principal:

# Create service principal in Azure
az ad sp create-for-rbac --name "cloud-run-connector" --role "Contributor" --scopes "/subscriptions/xyz/resourceGroups/myapp"

# Store the credentials as a JSON file
az ad sp create-for-rbac --name "cloud-run-connector" --output json > sp.json

Use az account get-access-token to fetch an access token and call the Azure Function:

token=$(az account get-access-token --query accessToken --output tsv)
curl -X POST \
  -H "Authorization: Bearer $token" \
  -H "Content-Type: application/json" \
  -d '{"data": "processed"}' \
  https://myapp.azurewebsites.net/api/process-order

Failure occurs when the tenantId is missing from the sp.json file, or when the applicationId does not match the appId in the Azure AD app registration. The Azure Function logs 401 Unauthorized, but the response body shows {"error":"invalid_token","error_description":"The access token is expired"}—a sign that the expires_in claim was not checked.

Orchestration and Configuration Across Clouds

Manage deployment and configuration consistently across different clouds.

Use git-based configuration with gitops patterns. Store infrastructure-as-code (IaC) manifests in a Git repository, with a main branch for production and staging for preview.

Define a CI/CD pipeline using GitHub Actions to deploy changes:

name: Deploy to Multi-Cloud
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Deploy to AWS
        run: |
          cd aws/
          terraform init
          terraform plan -out=tfplan
          terraform apply -input=false -auto-approve tfplan

      - name: Deploy to Azure
        run: |
          cd azure/
          az login --service-principal -u $AZURE_CLIENT_ID -p $AZURE_CLIENT_SECRET --tenant $AZURE_TENANT_ID
          az deployment group create \
            --resource-group production \
            --template-file main.bicep \
            --parameters @parameters.json

      - name: Deploy to GCP
        run: |
          cd gcp/
          gcloud auth activate-service-account --key-file=service-account.json
          gcloud deployment-manager deployments create production \
            --config=deployment.yaml

Each cloud’s IaC tooling—Terraform, Bicep, Deployment Manager—runs independently, but the pipeline ensures consistency.

A failure mode: a Terraform module in AWS expects region to be a string, but the parameters.json file passes region as a number (42 instead of "us-west-2"). Terraform logs Error: Invalid type for input variable, but the error message is buried in CI logs.

Another issue: the Azure Bicep file uses module syntax to call a shared module, but the module path is relative. When the CI job runs in a clean environment, the module is not found because bicep cannot resolve the path. The solution: explicitly cd into the module directory and use --module flag:

az deployment group create \
  --resource-group production \
  --template-file main.bicep \
  --parameters @parameters.json \
  --module ./modules/vpc/main.bicep

Handling Latency, Failures, and Cross-Cloud Topology

Optimise for latency and resilience in a multi-cloud setup.

Use a CDN to serve assets from the cloud closest to the user. Deploy a Cloudflare or Fastly edge network that caches static assets from AWS S3, Azure Blob Storage, and GCP Cloud Storage.

Configure cache rules based on content type and origin:

[
  {
    "target": "cdn.example.com/images/*",
    "origin": "s3://assets-prod",
    "cacheTtl": 86400,
    "cacheKey": ["Host", "Accept-Encoding", "User-Agent"]
  },
  {
    "target": "cdn.example.com/api/*",
    "origin": "https://api.azurewebsites.net",
    "cacheTtl": 300,
    "cacheKey": ["Host", "Path", "Query"]
  }
]

When a client requests a resource, the CDN returns X-Cache: HIT if the asset is served from an edge node, and X-Cache: MISS if it was fetched from the origin.

Failures: a client receives a 504 Gateway Timeout when the CDN fetches from an origin that is down. The CDN logs Origin Unreachable but does not retry. The solution: configure retry policies with exponential backoff and fallback origins.

Use curl to test the full path:

curl -I https://cdn.example.com/images/logo.png
# Response:
# HTTP/2 200
# X-Cache: HIT
# Age: 120
# Content-Type: image/png
# Date: Wed, 15 Mar 2023 12:00:00 GMT

When Age: 120, the client knows the asset is 2 minutes old. If X-Cache: MISS, the client can fetch from the origin directly.

Monitoring and Observability Across Clouds

Aggregate logs, metrics, and traces across clouds into a central observability platform.

Use Fluentd to collect logs from AWS EC2, Azure VMs, and GCP Compute Engine. Forward logs to a central Elasticsearch cluster:

<system>
  <source>
    @type forward
    bind 0.0.0.0
    port 24224
  </source>

  <match **>
    @type elasticsearch
    host elasticsearch.example.com
    port 9200
    logstash_format true
    logstash_prefix fluentd
    flush_interval 10s
  </match>
</system>

On each cloud, run Fluentd as a daemon with systemd:

[Unit]
Description=Fluentd
After=network.target

[Service]
ExecStart=/usr/bin/fluentd -c /etc/fluentd/fluent.conf
Restart=always
User=fluentd

[Install]
WantedBy=multi-user.target

Then, use kubectl logs to inspect logs from a Kubernetes pod in GCP:

kubectl logs -n analytics analytics-worker-7f9b8c4d2e --since=1h

When a service fails, check logs across all clouds. A user reports a slow API response. The logs show a 500 error in the AWS Lambda function, but the GCP Cloud Run service logs show Connection timeout to the Azure SQL database.

The root cause: the Azure SQL database has a firewall rule that allows only 10.0.0.0/24, but the GCP Cloud Run service runs on a different IP range. The solution: add the GCP IP ranges to the Azure SQL firewall using az sql server firewall-rule create:

az sql server firewall-rule create \
  --resource-group production \
  --server mydb \
  --name gcp-cloud-run \
  --start-ip-address 35.223.128.0 \
  --end-ip-address 35.223.255.255

Without this, the Cloud Run service fails silently with Connection timeout and no error message in the application logs.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.