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.

Back to Professional Services
4
VMs provisioned
4
Clouds, one config
4
Composed modules
8
Config files

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.

One Terraform root config composing four single-cloud child modules A single root configuration calls four child modules — aws-freetier-vm, azure-freetier-vm, gcp-freetier-vm and oci-freetier-vm — each by a relative source path. Running terraform apply with parallelism 1 creates one VM in each of AWS, Azure, GCP and OCI, one operation at a time. The AWS, GCP and OCI VMs are free-tier; the Azure VM is a billable Spot instance. root: terraform/multi-cloud apply -parallelism=1 one operation at a time module "aws" t3.micro · Amazon Linux 2023 · free module "azure" Spot B2ats_v2 · Ubuntu 24.04 · billable module "gcp" e2-micro · Ubuntu 24.04 · free module "oci" E2.1.Micro · Ubuntu 24.04 · free AWS VM + public IP Azure VM + public IP GCP VM + public IP OCI VM + public IP
One root config, four child modules, four VMs — processed one operation at a time so no two clouds' API calls ever run concurrently.

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 forbids depends_on / count / for_each on a module that contains its own provider block — 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=1 makes 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

FileWhat it's for
versions.tfTerraform + all four provider pins, so init installs them
variables.tfThe few inputs — the cloud identifiers plus a shared name and SSH key
main.tfThe four module blocks, each pointing at a sibling module
outputs.tfPer-cloud public IPs and SSH commands, as maps keyed by cloud
terraform.tfvars.exampleSample values — copy to terraform.tfvars, set the two required ids (no secrets)
.gitignoreKeeps state, secrets, plugins and terraform.tfvars out of git
list-all-vms.shHelper that lists running VMs across all four clouds via their CLIs
README.mdThe 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.

versions.tf
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.

variables.tf
# 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.

main.tf
# 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.

outputs.tf
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.

terraform.tfvars.example
# 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"
.gitignore
# 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.

verify auth in all four clouds
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
apply & tear down (always -parallelism=1)
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):

list-all-vms.sh
#!/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.