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.
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.
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.
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.
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
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.
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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy