Professional Services Infrastructure as Code
Multi-cloud provisioning with Terraform
One Terraform configuration that stands up a VM in all four clouds at once — AWS,
Azure, GCP and OCI — by composing the four single-cloud modules as child modules. A single
terraform apply brings up all four; a single terraform destroy tears them
all down. Run both with -parallelism=1 so the clouds run one after another, with no
cross-cloud race conditions.
What this is
This is the write-up that accompanies the studio's
multi-cloud Terraform guide — a walkthrough of a single configuration
(terraform/multi-cloud) that creates and destroys one free-tier VM in each
of AWS, Azure, GCP and OCI, serialized so there are no race conditions. It builds
directly on the four single-cloud configurations:
AWS, Azure,
GCP and OCI — if you
haven't built and authenticated each cloud individually yet, do that first; this config just
orchestrates the four.
The big idea: composition with modules
Terraform lets one configuration call others as child modules. Instead of
re-writing four VM stacks, this config reuses the four standalone modules via relative
source paths. Each child module already contains its own provider block
(configured from the values passed in), so the root needs no provider blocks of its own — it only
declares all four providers in versions.tf so terraform init installs
them.
Why and how it's serialized
The goal is minimum parallelism so the clouds run one after another. Two facts shape how that's done:
- You'd normally serialize Terraform with
depends_on(chain module B after module A). But Terraform forbidsdepends_on/count/for_eachon a module that contains its ownproviderblock — which all four of ours do. So we can't chain them that way. - The lever that always works is
-parallelism=N. Terraform defaults to 10 concurrent operations; setting-parallelism=1makes it perform exactly one operation at a time across the whole graph — so no two cloud API calls run concurrently, which is precisely what prevents race conditions.
So the rule for this config is simple: always run apply and
destroy with -parallelism=1.
The configuration, file by file
| File | What it's for |
|---|---|
versions.tf | Terraform + all four provider pins, so init installs them |
variables.tf | The few inputs — the cloud identifiers plus a shared name and SSH key |
main.tf | The four module blocks, each pointing at a sibling module |
outputs.tf | Per-cloud public IPs and SSH commands, as maps keyed by cloud |
terraform.tfvars.example | Sample values — copy to terraform.tfvars, set the two required ids (no secrets) |
.gitignore | Keeps state, secrets, plugins and terraform.tfvars out of git |
list-all-vms.sh | Helper that lists running VMs across all four clouds via their CLIs |
README.md | The module's own quick-start (this page is the deeper companion) |
versions.tf — all four providers
Pin Terraform to 1.5+ and declare all four providers so terraform init installs
them. Each child module configures its own provider from the variables passed in; the root only
needs to know they exist.
terraform {
required_version = ">= 1.5.0"
# All four providers are used (one per child module). terraform init installs
# them; each child module configures its own provider from the variables passed
# in below.
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
oci = {
source = "oracle/oci"
version = "~> 6.0"
}
}
}
variables.tf — just the identifiers
Only a handful of inputs: the two required cloud identifiers (GCP project id, OCI compartment OCID), the optional Azure subscription id, and a shared name + SSH key. Everything else falls back to each child module's defaults.
# Required cloud identifiers (the rest of each cloud's settings use the child
# module defaults). No secrets here — these are account-scoping identifiers.
variable "gcp_project_id" {
description = "GCP project id. Get it with: gcloud config get-value project"
type = string
}
variable "oci_compartment_id" {
description = "OCI compartment OCID (tenancy root is fine for Always Free). Get it from ~/.oci/config (tenancy=) or: oci iam compartment list"
type = string
}
variable "azure_subscription_id" {
description = "Azure subscription id. Leave null to use the ARM_SUBSCRIPTION_ID env var. Get it with: az account show --query id -o tsv"
type = string
default = null
}
variable "name" {
description = "Name / prefix for the VM and its resources in every cloud. Kept distinct from the standalone 'freetier-vm' deployments so they don't collide."
type = string
default = "multicloud-vm"
}
variable "public_key_path" {
description = "Path to the SSH PUBLIC key injected into every VM. The private key stays on your machine."
type = string
default = "~/.ssh/id_rsa.pub"
}
main.tf — the four module blocks
Each block points at a sibling module via a relative source and passes only the
inputs that cloud needs. Note the small naming difference: OCI's key variable is
ssh_public_key_path, while AWS/Azure/GCP use public_key_path; and Azure
gets the Spot recipe because its free B-series sizes are capacity-blocked on this subscription.
# Multi-cloud free-tier VM: one VM in each of AWS, Azure, GCP and OCI, by reusing
# the four standalone modules. Each child module carries its own provider block
# (configured from the variables passed here), so this root needs no provider
# blocks of its own.
#
# SERIALIZATION / NO RACE CONDITIONS:
# The child modules contain their own provider configurations, which means
# Terraform forbids depends_on/count/for_each on them — so we cannot chain the
# modules explicitly. Instead, run apply/destroy with -parallelism=1, which
# processes the graph one operation at a time. That guarantees no two cloud
# operations run concurrently (no races), effectively one-after-another. See
# terraform-multicloud.md and the README.
# --- AWS: t3.micro, Amazon Linux 2023 (free tier), default VPC ---
module "aws" {
source = "../aws-freetier-vm"
name = var.name
public_key_path = var.public_key_path
# region (us-west-2), profile (freetier), instance_type (t3.micro) use defaults.
}
# --- Azure: Spot Standard_B2ats_v2 in westcentralus (the free B-series sizes are
# capacity-restricted on this subscription, so the working recipe is Spot) ---
module "azure" {
source = "../azure-freetier-vm"
name = var.name
public_key_path = var.public_key_path
subscription_id = var.azure_subscription_id
location = "westcentralus"
vm_size = "Standard_B2ats_v2"
spot_enabled = true
spot_eviction_policy = "Deallocate"
spot_max_bid_price = -1
}
# --- GCP: e2-micro in us-west1, Ubuntu 24.04 (Always Free) ---
module "gcp" {
source = "../gcp-freetier-vm"
name = var.name
public_key_path = var.public_key_path
project_id = var.gcp_project_id
# region (us-west1), zone (us-west1-a), machine_type (e2-micro) use defaults.
}
# --- OCI: VM.Standard.E2.1.Micro, Ubuntu 24.04 (Always Free) ---
module "oci" {
source = "../oci-freetier-vm"
name = var.name
ssh_public_key_path = var.public_key_path
compartment_id = var.oci_compartment_id
# shape (VM.Standard.E2.1.Micro), region (us-phoenix-1) use defaults.
}
outputs.tf — one IP and SSH line per cloud
Two maps, keyed by cloud, so terraform output ssh_commands prints a ready-to-paste
SSH line for each VM.
output "public_ips" {
description = "Public IP of the VM in each cloud."
value = {
aws = module.aws.public_ip
azure = module.azure.public_ip
gcp = module.gcp.public_ip
oci = module.oci.public_ip
}
}
output "ssh_commands" {
description = "Ready-to-run SSH command for each cloud's VM."
value = {
aws = module.aws.ssh_command
azure = module.azure.ssh_command
gcp = module.gcp.ssh_command
oci = module.oci.ssh_command
}
}
terraform.tfvars.example & .gitignore — safe by default
Copy the example to terraform.tfvars and set the two required identifiers — no
secrets belong in it. State and your local terraform.tfvars are gitignored.
# Copy to terraform.tfvars and set the two required identifiers. No secrets here.
# terraform.tfvars is gitignored.
# REQUIRED
gcp_project_id = "my-project-123456" # gcloud config get-value project
oci_compartment_id = "ocid1.tenancy.oc1..xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # ~/.oci/config tenancy=, or oci iam compartment list
# OPTIONAL — leave unset to use the ARM_SUBSCRIPTION_ID env var instead
# azure_subscription_id = "00000000-0000-0000-0000-000000000000" # az account show --query id -o tsv
name = "multicloud-vm"
public_key_path = "~/.ssh/id_rsa.pub"
# Never commit Terraform state (can contain resource details / sensitive values),
# local provider plugins, crash logs, plan files, or your local variable choices
# (terraform.tfvars holds your project id / compartment OCID).
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
terraform.tfvars
*.auto.tfvars
No secrets in the repo. Each cloud authenticates the same way it does standalone — AWS CLI profile, az login, GCP Application Default Credentials, OCI config profile — so no credentials live in the code. The only values you supply are account-scoping identifiers, and even those stay in a gitignored terraform.tfvars.
The apply workflow
First confirm all four clouds are authenticated, then init once (it installs all four
providers and links the local modules) and apply with -parallelism=1.
aws sts get-caller-identity --profile freetier # AWS
az account show # Azure
gcloud auth application-default print-access-token >/dev/null && echo gcp-ok # GCP
oci os ns get # OCI
cd terraform/multi-cloud
cp terraform.tfvars.example terraform.tfvars # set gcp_project_id + oci_compartment_id
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
terraform init
terraform plan
terraform apply -parallelism=1 # create — one operation at a time
terraform output ssh_commands
terraform destroy -parallelism=1 # destroy all four
Always pass -parallelism=1. That's the serialization that avoids the cross-cloud races this config is built to prevent. Keep the same directory and its terraform.tfstate for the create/destroy cycle — don't apply from a fresh copy with empty state, or you'll create duplicates.
Listing every VM across the four clouds
A small helper wraps the four clouds' CLIs so you can see what's running at a glance (the OCI
compartment is read from ~/.oci/config):
#!/usr/bin/env bash
# List running VMs across all four cloud providers (AWS, Azure, GCP, OCI).
# Requires each cloud's CLI to be authenticated and on PATH. The OCI compartment
# is read from ~/.oci/config (tenancy root).
set -u
echo "=== AWS (EC2) ==="
aws ec2 describe-instances --profile "${AWS_PROFILE:-freetier}" \
--filters Name=instance-state-name,Values=running,pending \
--query 'Reservations[].Instances[].[InstanceId,InstanceType,State.Name,PublicIpAddress]' \
--output table 2>&1
echo "=== Azure ==="
az vm list -d \
--query "[?powerState=='VM running'].{name:name, size:hardwareProfile.vmSize, ip:publicIps, location:location}" \
--output table 2>&1
echo "=== GCP ==="
gcloud compute instances list \
--format='table(name, zone.basename(), machineType.basename(), status, EXTERNAL_IP)' 2>&1
echo "=== OCI ==="
TENANCY=$(awk -F= '/^tenancy=/{print $2}' ~/.oci/config)
oci compute instance list --compartment-id "$TENANCY" \
--query "data[?\"lifecycle-state\"=='RUNNING'].{name:\"display-name\", shape:shape, state:\"lifecycle-state\"}" \
--output table 2>&1
Three free, one billable. The AWS t3.micro, GCP e2-micro and OCI E2.1.Micro are ~$0 within their free tiers; the Azure VM is a billable Spot instance. Watch OCI's per-region VCN limit of 2 if the standalone OCI VM is also running — and terraform destroy -parallelism=1 when you're done.
The four single-cloud write-ups have the per-cloud detail:
AWS, Azure,
GCP and OCI. Once the
boxes are up, the same fleet is patched and hardened with the
Ansible playbooks, and a push to main becomes
a multi-OS build and release through the CI/CD pipeline.
Want your cloud infrastructure described in code, not clicked together in a console?
Reproducible, reviewable provisioning you can stand up and tear down on demand — let's talk.