In this episode we'll design a complete multi-cloud stack with OpenTofu: an AWS VPC and EKS cluster, GCP Cloud Storage bucket and Service Account, Cloudflare DNS, and Helm releases and workloads on Kubernetes. We'll also see how dynamic provider iteration and reusable modules simplify cross-cloud configuration.

In the previous episode 16 we covered Policy as Code with OPA, where every OpenTofu plan is tested against Rego rules before being allowed to apply. Now the question moves from how to secure to how to build: how can one shared codebase manage services across several clouds at once?
In this episode 17, we'll design a complete multi-cloud infrastructure with OpenTofu. We'll build (1) an AWS VPC and EKS cluster, (2) a GCP Cloud Storage bucket and Service Account, (3) Cloudflare DNS records and SSL rules, and (4) Helm releases and workloads on Kubernetes — all declared in one shared HCL language.
Multi-cloud isn't just a trend. There are strong business reasons behind it: avoiding vendor lock-in, using the best service on each cloud, and meeting specific geographic and regulatory requirements. But multi-cloud also brings huge complexity — especially when managed manually through different consoles.
OpenTofu makes every cloud look like one uniform language. An AWS VPC, a GCP bucket, and Cloudflare DNS are just HCL blocks that can be declared side by side in one repository, reviewed through one process, and audited with one clear trail.
Each cloud requires a provider declared in required_providers:
terraform {
required_version = ">= 1.8"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
google = { source = "hashicorp/google", version = "~> 6.0" }
cloudflare = { source = "cloudflare/cloudflare", version = "~> 4.0" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.0" }
helm = { source = "hashicorp/helm", version = "~> 2.0" }
}
}Next we configure provider instances for each cloud. These three blocks can be combined in one providers.tf file:
provider "aws" {
region = var.aws_region
}Tip
For credentials, don't hardcode them in code. AWS uses the standard credential chain (environment variables or OIDC), GCP uses Application Default Credentials, and Cloudflare uses an API token stored in a secret store — as covered in episode 15.
The first part of our stack is networking and the Kubernetes control plane on AWS. We use community modules to avoid writing hundreds of lines of basic resources:
module "vpc" {
source = "registry.opentofu.org/terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "${var.env}-vpc"
cidr = "10.0.0.0/16"
}
module "eks" {
source = "registry.opentofu.org/terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "${var.env}-eks"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
cluster_version = "1.31"
}The vpc module creates a complete network with public and private subnets. The eks module builds the EKS control plane and connects it to the private subnets. When it finishes, we can take the kubeconfig from the module output and point the Kubernetes provider at this cluster.
On the GCP side, we set up a bucket for static assets with versioning and a Service Account for workloads:
resource "google_storage_bucket" "assets" {
name = "${var.env}-static-assets"
location = "ASIA-SOUTHEAST2"
force_destroy = false
versioning {
enabled = true
}
}
resource "google_service_account" "app" {
account_id = "app-svc"
display_name = "Application Service Account"
}The bucket is created in Southeast Asia with versioning enabled so deleted objects can still be recovered. The app-svc Service Account can be linked to Kubernetes workloads through Workload Identity Federation, so Pods on EKS can access the GCP bucket without storing JSON keys.
Next we route public traffic to the app. DNS records point the domain name at the cluster endpoint, and an SSL rule forces all traffic onto HTTPS:
resource "cloudflare_record" "app" {
zone_id = var.cloudflare_zone_id
name = "app"
content = module.eks.cluster_endpoint
type = "CNAME"
proxied = true
}
resource "cloudflare_ruleset" "https" {
zone_id = var.cloudflare_zone_id
name = "redirect-https"
kind = "zone"
phase = "http_request_dynamic_redirect"
rules {
action = "redirect"
description = "Redirect HTTP to HTTPS"
expression = "starts_with(http.request.uri, \"http:\")"
enabled = true
}
}With proxied = true, traffic flows through Cloudflare's network so it can be leveraged for caching, WAF, and SSL. The cloudflare_ruleset rule ensures no visitor accesses the service over plain HTTP.
Finally, we deploy the app to the EKS cluster. The kubernetes and helm providers read the kubeconfig from the EKS module output, then we install an ingress controller as a Helm release and the app as a workload:
resource "helm_release" "nginx_ingress" {
name = "ingress-nginx"
repository = "https://kubernetes.github.io/ingress-nginx"
chart = "ingress-nginx"
version = "4.10.0"
namespace = "ingress"
create_namespace = true
set {
name = "controller.publishService.enabled"
value = "true"
}
}resource "kubernetes_deployment" "app" {
metadata {
name = "myapp"
namespace = "default"
}
spec {
replicas = 3
selector {
match_labels = {
app = "myapp"
}
}
template {
metadata {
labels = { app = "myapp" }
}
spec {
container {
image = "ghcr.io/armandwipangestu/myapp:1.0.0"
name = "myapp"
}
}
}
}
}helm_release fully manages the chart lifecycle, while kubernetes_deployment defines the workload. Together they put the entire app — from control plane to deployment — under one tool.
OpenTofu's exclusive feature from episode 7 — dynamic provider iteration — is very useful in multi-cloud architectures. If we want EKS running in two regions at once, we don't need to copy provider blocks:
locals {
regions = {
singapore = { region = "ap-southeast-1" }
jakarta = { region = "ap-southeast-3" }
}
}
provider "aws" {
region = each.value.region
for_each = local.regions
}One provider block produces two instances at once, something impossible to do in Terraform. Combine it with for_each on resources and you can spread a stack across many regions with very concise code.
Warning
Putting all clouds in one root module is tempting, but its blast radius is large: a single tofu apply can touch many clouds at once. Common practice is separating each cloud into its own root module, e.g. infra/aws, infra/gcp, infra/cloudflare, and infra/k8s, with separate state connected via tofu output or remote state data sources.
In episode 17, we designed a complete multi-cloud infrastructure: VPC and EKS on AWS, a bucket and Service Account on GCP, DNS and SSL rules on Cloudflare, and Helm releases and workloads on Kubernetes. We also saw how dynamic provider iteration handles multi-region elegantly.
Key takeaways:
required_providers declares providers, and provider blocks configure them.google_storage_bucket and google_service_account build the foundation of GCP services.cloudflare_record routes DNS, cloudflare_ruleset enforces HTTPS.helm_release manages charts, kubernetes_deployment defines workloads.The ability to provision multi-cloud is a great strength, but it brings new responsibility: making sure cloud reality always matches what we declare. In the next episode, episode 18, we'll cover Infrastructure Drift Detection & Auto-Remediation — how to detect and handle deviations that happen outside code control. See you there!