Every organisation that ends up on two clouds meets the same proposal within a quarter: write one Terraform module that works on both. Give it a cloud variable, hide the differences, and teams never need to know which provider they are on. It is an attractive idea, and it produces modules that are harder to use than either cloud’s own resources.
The clouds are not the same thing with different names. A VPC and a virtual network differ in how subnets relate to zones, how outbound access works, what a security boundary is, how logs are captured and where they go. A module that hides those differences either exposes the lowest common denominator or grows a second, private configuration language for each cloud. This article takes a different line: share the contract, not the resources. One slice of a landing zone, a private network with logging, is built twice, once per cloud, behind the same inputs, outputs and naming rules, and the same contract tests run against both. The switch-module design is built as well, so its cost can be measured and not just asserted.
Requirements
- One interface for consumers. A platform pipeline or a calling module passes the same inputs and reads the same outputs whichever cloud it targets.
- Each cloud’s controls done properly. No default outbound access on Azure subnets. No public IP assignment and a deny-all default security group on AWS. Network logs kept, encrypted, private and expired on both.
- The interface cannot drift. If one implementation relaxes a validation rule or renames an output, a build fails.
- A team on one cloud pays nothing for the other. No second provider to download, configure, lock and upgrade.
- Testable with no cloud account, on Terraform and on OpenTofu.

The contract
The contract is three small files. The first is the inputs, with their validation rules:
# The contract. This file is identical in every implementation; CI fails if the copies drift.
variable "name" {
description = "Short workload name, used in resource names."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9]{2,11}$", var.name))
error_message = "name must be 3 to 12 lower-case letters and digits, starting with a letter."
}
}
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be dev, test or prod."
}
}
variable "region" {
description = "Region, in the cloud's own naming. This is deliberately not abstracted."
type = string
}
variable "address_space" {
description = "IPv4 CIDR block of the network, between /16 and /24."
type = string
validation {
condition = can(cidrhost(var.address_space, 0)) && try(tonumber(split("/", var.address_space)[1]) >= 16 && tonumber(split("/", var.address_space)[1]) <= 24, false)
error_message = "address_space must be an IPv4 CIDR block between /16 and /24."
}
}
variable "subnet_count" {
description = "Number of private subnets."
type = number
default = 2
validation {
condition = var.subnet_count >= 1 && var.subnet_count <= 4 && floor(var.subnet_count) == var.subnet_count
error_message = "subnet_count must be a whole number from 1 to 4."
}
}
variable "log_retention_days" {
description = "Days that network logs are kept."
type = number
default = 365
validation {
condition = var.log_retention_days >= 90
error_message = "log_retention_days must be at least 90."
}
}
variable "tags" {
description = "Extra tags for every resource."
type = map(string)
default = {}
}
The region variable has no validation, and that is a decision. eu-west-1 and northeurope are not two spellings of one thing; they are different places with different service availability and different data-residency meaning. Mapping them behind a neutral name such as “ireland” would hide a fact the caller has to own.
The second file is the outputs. Identifiers are returned in each cloud’s own format, a short VPC id on one side and a long resource id path on the other. The contract promises that there is a network id, not what it looks like.
# The contract. This file is identical in every implementation; CI fails if the copies drift.
output "network_id" {
description = "Identifier of the virtual network, in the cloud's own format."
value = local.network_id
}
output "subnet_ids" {
description = "Subnet identifiers by subnet name."
value = local.subnet_ids
}
output "subnet_cidrs" {
description = "Subnet CIDR blocks by subnet name."
value = local.subnets
}
output "log_store_id" {
description = "Identifier of the store that receives network logs."
value = local.log_store_id
}
output "tags" {
description = "The tags applied to every resource."
value = local.tags
}
The third is the derivations that must agree: names, tags and subnet arithmetic.
# Shared derivations. This file is identical in every implementation; CI fails if the copies drift.
locals {
prefix = "${var.name}-${var.environment}"
# Two bits of subnetting: up to four equal subnets, named the same way on every cloud.
subnets = {
for index in range(var.subnet_count) :
format("private-%d", index + 1) => cidrsubnet(var.address_space, 2, index)
}
tags = merge(var.tags, {
workload = var.name
environment = var.environment
managed-by = "terraform"
})
}
Two implementations
The AWS module:
# AWS implementation of the network baseline contract: a VPC with private subnets, a default
# security group that allows nothing, and VPC flow logs delivered to an encrypted S3 bucket.
resource "aws_vpc" "this" {
cidr_block = var.address_space
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "vpc-${local.prefix}" })
}
resource "aws_subnet" "private" {
for_each = local.subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value
map_public_ip_on_launch = false
tags = merge(local.tags, { Name = "snet-${local.prefix}-${each.key}" })
}
# Adopting the default security group with no rules removes its allow-all defaults.
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.this.id
tags = merge(local.tags, { Name = "default-deny-${local.prefix}" })
}
resource "aws_s3_bucket" "logs" {
bucket_prefix = "netlogs-${local.prefix}-"
tags = local.tags
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "expire-network-logs"
status = "Enabled"
filter {}
expiration {
days = var.log_retention_days
}
}
}
resource "aws_flow_log" "vpc" {
vpc_id = aws_vpc.this.id
traffic_type = "ALL"
log_destination_type = "s3"
log_destination = aws_s3_bucket.logs.arn
tags = local.tags
}
locals {
network_id = aws_vpc.this.id
subnet_ids = { for name, subnet in aws_subnet.private : name => subnet.id }
log_store_id = aws_s3_bucket.logs.arn
}
The Azure module:
# Azure implementation of the network baseline contract: a resource group, a virtual network with
# private subnets that have no default outbound access, and diagnostics delivered to a locked-down
# storage account.
resource "azurerm_resource_group" "this" {
name = "rg-${local.prefix}-network"
location = var.region
tags = local.tags
}
resource "azurerm_virtual_network" "this" {
name = "vnet-${local.prefix}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.address_space]
tags = local.tags
}
resource "azurerm_subnet" "private" {
for_each = local.subnets
name = "snet-${local.prefix}-${each.key}"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [each.value]
default_outbound_access_enabled = false
}
resource "azurerm_storage_account" "logs" {
# Storage account names: 3 to 24 lower-case letters and digits, globally unique.
name = substr("stnetlogs${var.name}${var.environment}", 0, 24)
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = "GRS"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
infrastructure_encryption_enabled = true
tags = local.tags
}
resource "azurerm_storage_management_policy" "logs" {
storage_account_id = azurerm_storage_account.logs.id
rule {
name = "expire-network-logs"
enabled = true
filters {
blob_types = ["blockBlob", "appendBlob"]
}
actions {
base_blob {
delete_after_days_since_modification_greater_than = var.log_retention_days
}
}
}
}
resource "azurerm_monitor_diagnostic_setting" "network" {
name = "diag-${local.prefix}-network"
target_resource_id = azurerm_virtual_network.this.id
storage_account_id = azurerm_storage_account.logs.id
enabled_log {
category_group = "allLogs"
}
}
locals {
network_id = azurerm_virtual_network.this.id
subnet_ids = { for name, subnet in azurerm_subnet.private : name => subnet.id }
log_store_id = azurerm_storage_account.logs.id
}
Set side by side, the two files make the argument. They have the same shape at the top, a network and a for_each over the same subnet map, and then they part company. On AWS the secure default is to adopt the default security group with no rules and to switch off public IP assignment. On Azure it is to turn off default outbound access per subnet. AWS captures traffic with a flow log resource; Azure attaches a diagnostic setting. The log store is a bucket with four companion resources on one side and a single storage account with a management policy on the other. Each ends with a small locals block that maps its own resources onto the names the contract’s outputs expect. That mapping is the whole of the abstraction.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
A[contract: variables, outputs, locals, contract tests] --> B[render.py copies the contract into each module]
B --> C[network-baseline-aws]
B --> D[network-baseline-azure]
C --> E[9 contract tests plus 4 AWS tests, mocked provider]
D --> F[9 contract tests plus 4 Azure tests, mocked provider]
E --> G{render.py --check and all tests pass?}
F --> G
G -->|No| H[Build fails: the contract drifted or a control broke]
G -->|Yes| I[Both implementations honour the same contract]
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
class A,B policy
class C,E,G compute
class D,F net
class H danger
class I storeKeeping two copies of one contract honest
Terraform has no way for two modules to import a shared variables file, so the contract is copied into each module, and a script owns the copies.
"""Copy the contract into every implementation, or check that the copies are current.
python contract/render.py write the copies
python contract/render.py --check exit 1 if any copy differs from the contract
"""
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
CONTRACT = ROOT / "contract"
IMPLEMENTATIONS = {
"network-baseline-aws": {"region": "eu-west-1", "mocks": "mock-aws.hcl"},
"network-baseline-azure": {"region": "northeurope", "mocks": "mock-azure.hcl"},
}
SPECIFIC = {"network-baseline-aws": "aws.tftest.hcl", "network-baseline-azure": "azure.tftest.hcl"}
MARKER = "# --- mock block, kept in step with the contract by contract/render.py ---"
def expected() -> dict[pathlib.Path, str]:
files = {}
template = (CONTRACT / "contract.tftest.hcl.tmpl").read_text(encoding="utf-8")
for name, settings in IMPLEMENTATIONS.items():
module = ROOT / "modules" / name
for shared in ("variables.tf", "outputs.tf", "locals.tf"):
files[module / f"contract_{shared}"] = (CONTRACT / shared).read_text(encoding="utf-8")
mocks = (CONTRACT / settings["mocks"]).read_text(encoding="utf-8").strip()
files[module / "tests" / "contract.tftest.hcl"] = template.replace("@@MOCKS@@", mocks).replace("@@REGION@@", settings["region"])
# The cloud-specific test file keeps its own tests and takes the same mock block.
specific = module / "tests" / SPECIFIC[name]
head, _, tail = specific.read_text(encoding="utf-8").partition(MARKER)
rest = tail.split(MARKER, 1)[1] if MARKER in tail else tail
files[specific] = f"{head}{MARKER}\n{mocks}\n{MARKER}{rest}"
return files
def main() -> int:
check = "--check" in sys.argv
stale = []
for path, content in expected().items():
if check:
if not path.exists() or path.read_text(encoding="utf-8") != content:
stale.append(str(path.relative_to(ROOT)))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if check:
print("contract copies are current" if not stale else "STALE: " + ", ".join(stale))
return 1 if stale else 0
print(f"wrote {len(expected())} files")
return 0
if __name__ == "__main__":
sys.exit(main())
The copies are committed. A consumer who fetches one module gets a complete module, with no build step. CI runs render.py --check and fails if any copy differs from the source. Generated, committed, verified is a dull pattern and it is the right one here.
Contract tests: the same assertions for both clouds
The contract test file is a template. Everything below the mock block is identical for both implementations and reads only outputs, so it cannot depend on how either cloud builds the network. It follows the approach from last week’s article: plan runs for configured values, an apply run against the mock for computed ones, and expect_failures for the validation rules.
# GENERATED from contract/contract.tftest.hcl.tmpl by contract/render.py. Do not edit this copy.
# The same assertions run against every implementation of the contract. They read outputs only.
@@MOCKS@@
variables {
name = "records"
environment = "dev"
region = "@@REGION@@"
address_space = "10.40.0.0/20"
}
run "subnets_are_named_and_sized_the_same_way" {
command = plan
assert {
condition = output.subnet_cidrs == { "private-1" = "10.40.0.0/22", "private-2" = "10.40.4.0/22" }
error_message = "Two /22 subnets named private-1 and private-2 are expected from a /20."
}
}
run "subnet_count_is_honoured" {
command = plan
variables {
subnet_count = 4
}
assert {
condition = keys(output.subnet_cidrs) == ["private-1", "private-2", "private-3", "private-4"]
error_message = "Four subnets are expected."
}
assert {
condition = output.subnet_cidrs["private-4"] == "10.40.12.0/22"
error_message = "The fourth subnet must be the last quarter of the address space."
}
}
run "tags_carry_workload_and_environment" {
command = plan
variables {
tags = { cost-centre = "cc-1042" }
}
assert {
condition = output.tags == { "cost-centre" = "cc-1042", "environment" = "dev", "managed-by" = "terraform", "workload" = "records" }
error_message = "Caller tags must be merged with the standard three."
}
}
run "identifiers_are_exposed_after_apply" {
command = apply
assert {
condition = length(output.network_id) > 0 && length(output.log_store_id) > 0
error_message = "network_id and log_store_id must be set."
}
assert {
condition = keys(output.subnet_ids) == keys(output.subnet_cidrs)
error_message = "There must be one subnet id for each subnet CIDR, under the same names."
}
}
run "rejects_an_address_space_that_is_too_small" {
command = plan
variables {
address_space = "10.40.0.0/26"
}
expect_failures = [var.address_space]
}
run "rejects_text_that_is_not_a_cidr" {
command = plan
variables {
address_space = "ten dot forty"
}
expect_failures = [var.address_space]
}
run "rejects_five_subnets" {
command = plan
variables {
subnet_count = 5
}
expect_failures = [var.subnet_count]
}
run "rejects_short_log_retention" {
command = plan
variables {
log_retention_days = 30
}
expect_failures = [var.log_retention_days]
}
run "rejects_a_name_with_a_hyphen" {
command = plan
variables {
name = "patient-records"
}
expect_failures = [var.name]
}
Each module also has four tests of its own for the controls that only exist on its cloud. The Azure ones:
# What only the Azure implementation can be asked: the cloud-specific controls.
# --- mock block, kept in step with the contract by contract/render.py ---
# The Azure provider parses resource ids even when it is mocked. Give the mock well-formed ones
# for the two resources whose ids are passed to other resources.
mock_provider "azurerm" {
mock_resource "azurerm_virtual_network" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Network/virtualNetworks/vnet-mock"
}
}
mock_resource "azurerm_storage_account" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Storage/storageAccounts/stmock"
}
}
}
# --- mock block, kept in step with the contract by contract/render.py ---
variables {
name = "records"
environment = "dev"
region = "northeurope"
address_space = "10.40.0.0/20"
}
run "subnets_have_no_default_outbound_access" {
command = plan
assert {
condition = alltrue([for subnet in azurerm_subnet.private : subnet.default_outbound_access_enabled == false])
error_message = "Private subnets must not have default outbound access."
}
}
run "log_storage_is_locked_down" {
command = plan
assert {
condition = azurerm_storage_account.logs.min_tls_version == "TLS1_2" && azurerm_storage_account.logs.https_traffic_only_enabled
error_message = "The log storage account must require HTTPS with TLS 1.2 or later."
}
assert {
condition = azurerm_storage_account.logs.allow_nested_items_to_be_public == false && azurerm_storage_account.logs.shared_access_key_enabled == false
error_message = "The log storage account must not allow public blobs or shared key access."
}
assert {
condition = azurerm_storage_account.logs.infrastructure_encryption_enabled
error_message = "The log storage account must use infrastructure encryption."
}
}
run "storage_account_name_fits_the_naming_rules" {
command = plan
variables {
name = "longworkload"
}
assert {
condition = can(regex("^[a-z0-9]{3,24}$", azurerm_storage_account.logs.name))
error_message = "Storage account names must be 3 to 24 lower-case letters and digits."
}
}
run "logs_expire_after_the_retention_period" {
command = plan
assert {
condition = azurerm_storage_management_policy.logs.rule[0].actions[0].base_blob[0].delete_after_days_since_modification_greater_than == 365
error_message = "Logs must be deleted after the retention period."
}
}
Thirteen tests per module passed on Terraform 1.16.3 and on OpenTofu 1.12.6. To confirm the arrangement catches drift, the pipeline edits one copy of the contract, lowering the minimum log retention in the Azure module from 90 days to 30. Two things fail: render.py --check reports the file as stale, and the contract test rejects_short_log_retention fails in that module.
What mocked providers still check
Getting the apply runs to pass exposed something worth knowing. A mocked provider does not call the cloud, and it does still validate the format of some values. Left to generate placeholders, both providers refused them:
Error: "log_destination" (61cr1qfv) is an invalid ARN: arn: invalid prefix
Error: parsing "52yadty8": parsing the StorageAccount ID: the number of segments didn't match
The fix is to give the mock well-formed defaults for the few identifiers that are passed from one resource to another:
# The Azure provider parses resource ids even when it is mocked. Give the mock well-formed ones
# for the two resources whose ids are passed to other resources.
mock_provider "azurerm" {
mock_resource "azurerm_virtual_network" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Network/virtualNetworks/vnet-mock"
}
}
mock_resource "azurerm_storage_account" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Storage/storageAccounts/stmock"
}
}
}
The two tools behave differently here, and it matters for a shared suite. On Terraform 1.16.3 those errors appeared only in apply runs; plan runs passed, because a mocked computed value is unknown while planning. On OpenTofu 1.12.6 the same errors appeared in plan runs too, and its placeholders were four characters long where Terraform’s were eight. OpenTofu generates mock values during the plan. A suite that passes on one tool can fail on the other until the mocks have well-formed defaults, after which both agreed on all thirteen tests.
The switch module, measured
Here is the design this article argues against, written the way it is usually proposed:
# The pattern this article argues against for anything but small estates: one module with a switch.
# It is here so its costs can be measured beside the two-module design.
variable "cloud" {
description = "Which cloud to build the baseline in."
type = string
validation {
condition = contains(["aws", "azure"], var.cloud)
error_message = "cloud must be aws or azure."
}
}
variable "name" { type = string }
variable "environment" { type = string }
variable "region" { type = string }
variable "address_space" { type = string }
module "aws" {
source = "../network-baseline-aws"
count = var.cloud == "aws" ? 1 : 0
name = var.name
environment = var.environment
region = var.region
address_space = var.address_space
}
module "azure" {
source = "../network-baseline-azure"
count = var.cloud == "azure" ? 1 : 0
name = var.name
environment = var.environment
region = var.region
address_space = var.address_space
}
output "network_id" {
value = one(concat(module.aws[*].network_id, module.azure[*].network_id))
}
output "subnet_cidrs" {
value = one(concat(module.aws[*].subnet_cidrs, module.azure[*].subnet_cidrs))
}
It works. Its two tests, one per cloud, pass on both tools. The costs are in what it requires of every caller.

| AWS module | Azure module | Facade | |
|---|---|---|---|
Providers installed by init | aws | azurerm | aws and azurerm |
| Providers on disk | 839 MB | 214 MB | 1,053 MB |
init on a hosted runner | 5 s | 2 s | 6 s |
| Lock file entries to maintain | 1 | 1 | 2 |
| A provider major-version upgrade affects | AWS consumers | Azure consumers | Every consumer |
The disk and time figures are from one run on one hosted runner, and the seconds are small. The structural points do not depend on the numbers. A team that only uses Azure installs a provider nearly four times the size of its own for a cloud it never touches. Both providers appear in its lock file and its upgrade backlog. And the switch buys the caller very little: the region value, the identifier formats and every operational limit are still cloud-specific, so the caller knows which cloud it is on anyway.
What to abstract and what to leave alone
| Share across clouds | Keep cloud-specific |
|---|---|
| Input names, types and validation rules | Resource types and their arguments |
| Output names and their meaning | Identifier formats |
| Naming conventions and mandatory tags | Region names and data-residency decisions |
| Subnet arithmetic and address planning | How isolation, routing and outbound access work |
| Retention periods and classification rules | How logs are captured and where they are stored |
| Contract tests | Tests of each cloud’s controls |
| The pipeline and the policy gate | Provider versions and upgrade timing |
The left column is policy and interface: things the organisation decides. The right column is mechanism: things the cloud decides. Abstractions over the left column last. Abstractions over the right column leak on first contact with a real requirement.
Alternatives considered
| Design | Advantages | Disadvantages | Fits |
|---|---|---|---|
| One module with a cloud switch | One name to learn; one call site | Both providers for every caller; lowest-common-denominator interface; coupled upgrades | Very small estates, demos |
| One contract, a module per cloud (this article) | Each cloud done properly; independent upgrades; drift is a build failure | Two implementations to maintain; the contract is copied, so it needs a check | Platform teams serving several product teams |
| Separate modules, no shared contract | Least ceremony | Interfaces diverge quietly; consumers special-case each cloud | A single team that owns both |
| A higher-level tool that generates Terraform | Real shared code and types | Another language and toolchain to own | Organisations already invested in one |
A baseline like this is where an organisation’s network and logging controls are decided once. The contract makes two of them explicit and testable on both clouds: network logs are retained for at least 90 days, and every resource carries workload and environment tags. Region is left to the caller on purpose, because where personal or clinical data is processed is a data protection decision that should be visible in the calling configuration and not buried in a mapping table. The tests here show the modules configure the intended controls. They do not show that a deployed network is isolated; that needs checks against the running environment.
Cost
Two implementations cost more to write than one, and that cost is paid once. What recurs is upgrade work, and here the two-module design is cheaper: each provider moves on its own schedule and only its consumers are affected. The contract adds one script and one CI step. Nothing in the test pipeline creates a resource, so there is no cloud spend in getting to a green build.
Decision framework
| Situation | Recommendation |
|---|---|
| One cloud today, a second is talked about | Write for the cloud you have. Keep inputs and outputs free of cloud-specific names where that costs nothing |
| Two clouds, one platform team, several consuming teams | One contract, a module per cloud, contract tests on both |
| Two clouds, each owned by a different team | Agree the contract and the tests; each team owns its implementation |
| Pressure to “just add a cloud variable” | Build the facade once in a branch and measure what callers inherit, as here |
| Modules must run on Terraform and OpenTofu | Run the same tests on both in CI and give mocks well-formed identifiers |
Key takeaways
- Share the contract, not the resources: input names, validation, outputs, naming, tags and subnet arithmetic are identical in both modules, and nothing else is.
- The same nine contract tests pass against the AWS and the Azure implementation, on Terraform 1.16.3 and OpenTofu 1.12.6, with no cloud account.
- A copied contract needs a check. Weakening one rule in one copy failed the build twice: once as a stale file, once as a failed contract test.
- Mocked providers still validate identifier formats, and the two tools generate mock values at different stages. Give mocks well-formed ARNs and resource ids.
- A switch module made every caller install 1,053 MB of providers where the Azure module alone needs 214 MB, and coupled both clouds’ upgrades.
If you run one interface over two clouds and have drawn the line somewhere else, leave a comment and say where.
All results are from GitHub Actions on ubuntu-latest with Terraform 1.16.3, OpenTofu 1.12.6, AWS provider 6.63.0 and AzureRM provider 5.2.0 (run 43297687751), with no cloud credentials. terraform validate accepted both modules against the real provider schemas. The listings are the files in the samples repository. Nothing was applied to an AWS account or an Azure subscription, so no claim is made that either cloud accepts these configurations at apply time. Disk sizes and init times are single measurements.
The complete sample for this article is on GitHub: nithinmohantk/blog-samples-working-repo/terraform-two-clouds. Clone it with git clone https://github.com/nithinmohantk/blog-samples-working-repo.git and open the terraform-two-clouds folder. Its README says how to run it and what has been verified.
Glossary
- Landing zone— The baseline of accounts or subscriptions, networking, identity and logging that workloads are deployed into.
- Contract— The inputs, outputs and behaviour a module promises to its callers, independent of how it is implemented.
- Contract test— A test that checks an implementation against the contract using only what a caller can see.
- Facade module— A module that wraps several implementations behind one interface with a switch.
- Default outbound access— On Azure, the implicit internet egress a subnet has unless it is turned off.
- Flow log— A record of network traffic accepted and rejected in a virtual network.
- Diagnostic setting— The Azure resource that routes a resource’s logs and metrics to a destination.
- Drift— A difference between copies, or between configuration and reality, that nobody intended.
References & Further Reading
- → Terraform: module composition— HashiCorp’s guidance on small modules and dependency inversion, including multi-cloud abstractions
- → Terraform: mocks— Mock providers and default values
- → AWS provider: aws_flow_log— Flow log destinations and formats
- → AzureRM provider: azurerm_subnet— Including default outbound access
- → Azure: default outbound access— What it is and why to turn it off
- → Testing Infrastructure Code— Last week’s article on mocked tests, mutation checks and a policy gate
- → Sample code for this article— The terraform-two-clouds folder on GitHub
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.