TerraformXXVI · Cloud and Platform OperationsProduction Terraform
Provider-Neutral Patterns
What you'll learn
- Distinguish genuinely provider-neutral modules from modules that merely hide provider-specific attributes
- Use the null, local, random, http, tls, and external providers for cross-platform glue
- Decide when a provider-neutral abstraction pays for itself and when it is theatre
- Avoid the failure mode of forcing every provider into one abstract interface
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
“Provider-neutral” is one of the most over-used phrases in Terraform discourse. People say it when they mean “portable”; they say “portable” when they mean “I copied the AWS example and changed the resource type.” Both are usually wrong. This lesson separates the genuinely provider-neutral patterns from the cosplay.
What “provider-neutral” actually means
A Terraform configuration is provider-neutral when its behaviour does not depend on which cloud or platform the user is targeting. There are two distinct ways to achieve this:
- Use a provider that has no platform —
null,local,random,tls,http,external,time. These providers exist to give Terraform capabilities that are independent of any cloud. Random IDs, local files, HTTP probes, TLS certificates generated inside the apply, and arbitrary scripts via theexternalprovider are all provider-neutral by construction. - Wrap a provider behind an abstraction — your module accepts
inputs and returns outputs and hides whether the underlying
resource is an
aws_instance, anazurerm_linux_virtual_machine, or aproxmox_vm_qemu. The module is “neutral” in the sense that the caller does not name a provider, but a different module is required for each provider.
The first kind is real provider-neutrality. The second kind is abstraction, and it has a cost.
The genuinely provider-neutral providers
The providers in this group do not provision infrastructure that lives on a cloud. They give Terraform capabilities that any other configuration can use, regardless of the platform.
null — the do-nothing resource
null_resource (and its modern replacement, terraform_data) is
the standard way to make Terraform do something that is not “manage
a resource.” A null_resource exists only in state. It has no
upstream object. Anything you put in its provisioner block runs
locally.
resource "terraform_data" "bootstrap" {
triggers_replace = {
config_sha = sha256(jsonencode(var.bootstrap_config))
}
provisioner "local-exec" {
command = "./scripts/render-config.sh"
environment = {
CONFIG_JSON = jsonencode(var.bootstrap_config)
}
}
}
terraform_data replaces null_resource for this use case; it is
the idiomatic 1.9.x way to attach triggers and provisioners to a
synthetic resource. Use it for hooks (run a script after a VM is
created), for tracking changes that are not expressed in any
resource attribute, and for writing glue between Terraform and
systems Terraform cannot model.
local — files and values inside the apply
The local provider lets Terraform write files locally and read
values from the filesystem. It is the right tool for materialising
configuration that another tool will consume — an Ansible inventory,
a kubeconfig, a ~/.aws/config, an SSH config fragment.
resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory/hosts.yml"
content = yamlencode({
all = {
children = {
web = {
hosts = {
for vm in module.compute.vms :
vm.name => { ansible_host = vm.public_ip }
}
}
}
}
})
file_permission = "0600"
}
The local_file resource lives on the machine running Terraform.
It is not pushed to a remote host. If you need files on a remote
host, that is a job for Ansible, remote-exec, or a configuration
management tool — not for the local provider.
random — idempotency primitives
The random provider generates strings, integers, passwords, and
shuffled lists that are stable across applies within a state but
unique across states. The classic production use is generating a
random suffix that makes bucket names, key names, and resource names
globally unique:
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "artefacts" {
bucket = "prod-artefacts-${random_id.suffix.hex}"
}
If the bucket is recreated (state lost, account change), the suffix changes, the bucket name changes, and a new bucket is created. That is the property you want from a globally-unique name resource.
tls — certificates and key material inside the apply
The tls provider generates private keys, self-signed certificates,
and certificate signing requests without calling out to a CA. It is
useful for service-mesh TLS, internal CAs, and ephemeral dev
environments. It is not a replacement for ACM, Let’s Encrypt, or
your internal CA in production for any certificate that needs to be
trusted by clients.
resource "tls_private_key" "ca" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "tls_self_signed_cert" "ca" {
private_key_pem = tls_private_key.ca.private_key_pem
subject {
common_name = "Internal CA"
organisation = "Example Ltd"
}
validity_period_hours = 87600
is_ca_certificate = true
allowed_uses = ["cert_signing", "crl_signing"]
}
http — fetch data during the apply
The http provider makes HTTP requests and exposes the body as a
data source. The production use is fetching a metadata endpoint,
querying a service registry, or pulling a public IP. It is not a
replacement for proper provider data sources; the response is opaque
to Terraform and not schema-validated.
data "http" "my_ip" {
url = "https://ifconfig.me"
request_headers = {
Accept = "text/plain"
}
}
resource "aws_security_group_rule" "ssh_admin" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["${data.http.my_ip.response_body}/32"]
security_group_id = aws_security_group.bastion.id
}
external — call any script and parse JSON
The external provider runs an external program and parses its
JSON output. It is the last-resort escape hatch when Terraform
cannot express what you need and the answer comes from running code
on the apply host. It is slow, fragile, and not testable without
mocking. Use it sparingly.
data "external" "ami_lookup" {
program = ["python3", "${path.module}/scripts/lookup_ami.py"]
query = {
region = var.region
pattern = var.ami_name_pattern
}
}
The script must print a single JSON object to stdout. Anything else breaks the apply.
The abstraction pattern — when it works
The other way to write “provider-neutral” Terraform is to wrap provider-specific resources in a module with a generic interface. This works when the differences between providers are small and mechanical.
┌─────────────────────────────────────┐
│ module "compute" │
│ input: name, image, subnet, size │
└────────────┬───────────────┬────────┘
│ │
aws_instance proxmox_vm_qemu
(module-aws) (module-proxmox)
The caller writes:
module "web" {
source = "./modules/compute"
name = "web-01"
image = "ubuntu-24.04"
subnet = module.network.subnet_id
size = "small"
}
The caller does not know which provider the module uses. The repository has two implementations and a CI matrix that runs both. This works.
It stops working the moment the providers differ in non-trivial ways. The pattern that comes up most often:
Caller asks: "give me a VM with a public IP and a tag"
AWS gives: public_dns, public_ip, ipv6_association
Azure gives: public_ip_id (separate resource), no native IPv6
Proxmox gives: no native public IP (it's a VM in a private VLAN)
A module that hides this either fails on Azure (no IPv6) or lies
to the caller (returns an empty string for public_ipv6 on AWS
when the subnet doesn’t have IPv6 enabled). Both are bad.
When the abstraction is theatre
The most common failure mode of “provider-neutral” code is that it is neither neutral nor portable. Three symptoms:
- The module has one implementation — the
awsversion exists and is in production; theazureandproxmoxversions are stubs from the original PR and have not been touched in two years. - The interface leaks the provider — the caller passes
subnet_id(AWS-shaped) and the Azure implementation translates it to asubnet_idthat happens to be the same shape but semantically different. The abstraction is hiding a translation that is happening, not a translation that is missing. - The CI does not run the matrix — the modules exist as files but no pipeline ever applies the Azure module against a real subscription. Drift between the implementations is invisible until production tries to use one of them.
A pragmatic pattern that works
The pattern that survives in production:
- One repository, one provider per root module. The
infra/aws/,infra/azure/,infra/proxmox/directories each contain provider-specific configuration. No provider-neutral wrapping. - A shared module for the glue that is genuinely neutral. A
modules/inventorymodule that produces an Ansible inventory from any list of host records. Amodules/bastionmodule that takes a list of hostnames and returns a working SSH config. These modules have no provider. They consume provider-specific data and produce provider-neutral artefacts. - No abstract
computeornetworkmodules. A VM is anaws_instance, anazurerm_linux_virtual_machine, or aproxmox_vm_qemu. They are different resources with different attributes. Hiding them in a wrapper makes the codebase harder to read without making it more portable.
How to validate
After writing a module that you intend to be provider-neutral:
# READ-ONLY: confirm the module has no provider blocks of its own
grep -nE '^\s*provider\s+' modules/compute/*.tf || echo "no provider blocks"
# READ-ONLY: confirm the module consumes only neutral data sources
grep -rn 'data "' modules/compute/*.tf
The first command confirms the module does not implicitly depend on
a provider. The second confirms the data sources it reads are
neutral (null, local, random, tls, http, external,
terraform_data).
For the abstraction pattern, the validation is harder: you must run the matrix.
# CONFIGURATION: run plan against each provider target
for target in aws azure proxmox; do
(cd "infra/$target" && terraform plan -input=false)
done
If any target drifts from the others, the abstraction is leaking.
Production failure modes
random_passwordin state with an unencrypted backend. The password is in plaintext in the state file. Anyone with read access to the bucket has root on the database.local_filewritten to a path the operator does not control. A path under${path.module}/inventory/hosts.ymlis fine. A path under/etc/ansible/hostsis a privilege mistake waiting to happen. Thelocalprovider runs as the Terraform user.httpdata source blocking on a slow endpoint. The apply hangs waiting for a third-party service. There is no timeout you can set. The CI times out, and the apply never completes.externalprovider script with non-deterministic output. The script prints different JSON on different runs (timestamps, random IDs, race conditions). Every apply produces a diff and nothing ever converges. Addtriggers_replaceon a hash of the inputs to make it stable.tls_self_signed_certused as a leaf certificate in production. Self-signed certs are for internal CAs and ephemeral dev. A leaf certificate trusted by browsers must come from a public CA or from an internal CA that the clients trust.- Abstraction that hides a security-relevant difference. AWS
aws_instanceIMDSv2 is opt-in; Proxmox has no IMDS at all. A wrapper that setsmetadata_options.http_tokens = "required"silently does nothing on Proxmox. The caller thinks they have a security control; they do not.
What to do in production
- Use
randomfor globally-unique names. Uselocal_filefor artefacts other tools will read. Usetlsfor ephemeral certs and internal CAs. Usehttpfor metadata lookups that have no provider data source. Useexternalonly when nothing else fits. - Do not write a “compute” module that wraps every provider. Write provider-specific compute modules and a shared glue module for the artefacts downstream tools consume.
- Treat state as the secrets store it actually is. If the backend
is not encrypted and access-controlled, do not put
random_passwordoutputs in it.
Verification
After working through this lesson, confirm the following:
- You can name the six genuinely provider-neutral providers and one production-grade use for each.
- You can describe one failure mode caused by an abstraction that hides a security-relevant difference between providers.
- You can decide, given a candidate module, whether the provider-neutral wrapper is real or theatre, based on whether the CI runs the matrix.
Knowledge check · 7 questions
Q1. Which of the following providers is genuinely provider-neutral?
Q2. Which providers belong to the genuinely provider-neutral group? (Select all that apply.)
Q3. A generic wrapper module is only as portable as the CI matrix that exercises it against every target.
Q4. What is the production-grade use of the random_id resource?
Q5. A team has written a module/compute that wraps aws_instance, azurerm_linux_virtual_machine, and proxmox_vm_qemu behind the same interface. The AWS version is in production; the Azure and Proxmox versions have not been updated in 18 months. What is the most likely production outcome?
Q6. Why is random_password a security concern when used with an unencrypted state backend?
Q7. Which is the right pattern for a 'provider-neutral' Ansible inventory module?
Passing score: 75%. Answers are checked in this browser.