Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Provider-Neutral Patterns

Intermediate⏱ ~14 minbash

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

Not yet marked complete on this device.

“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:

  1. 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 the external provider are all provider-neutral by construction.
  2. Wrap a provider behind an abstraction — your module accepts inputs and returns outputs and hides whether the underlying resource is an aws_instance, an azurerm_linux_virtual_machine, or a proxmox_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:

  1. The module has one implementation — the aws version exists and is in production; the azure and proxmox versions are stubs from the original PR and have not been touched in two years.
  2. The interface leaks the provider — the caller passes subnet_id (AWS-shaped) and the Azure implementation translates it to a subnet_id that happens to be the same shape but semantically different. The abstraction is hiding a translation that is happening, not a translation that is missing.
  3. 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:

  1. One repository, one provider per root module. The infra/aws/, infra/azure/, infra/proxmox/ directories each contain provider-specific configuration. No provider-neutral wrapping.
  2. A shared module for the glue that is genuinely neutral. A modules/inventory module that produces an Ansible inventory from any list of host records. A modules/bastion module 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.
  3. No abstract compute or network modules. A VM is an aws_instance, an azurerm_linux_virtual_machine, or a proxmox_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

  1. random_password in 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.
  2. local_file written to a path the operator does not control. A path under ${path.module}/inventory/hosts.yml is fine. A path under /etc/ansible/hosts is a privilege mistake waiting to happen. The local provider runs as the Terraform user.
  3. http data 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.
  4. external provider 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. Add triggers_replace on a hash of the inputs to make it stable.
  5. tls_self_signed_cert used 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.
  6. Abstraction that hides a security-relevant difference. AWS aws_instance IMDSv2 is opt-in; Proxmox has no IMDS at all. A wrapper that sets metadata_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 random for globally-unique names. Use local_file for artefacts other tools will read. Use tls for ephemeral certs and internal CAs. Use http for metadata lookups that have no provider data source. Use external only 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_password outputs 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

  1. Q1. Which of the following providers is genuinely provider-neutral?

  2. Q2. Which providers belong to the genuinely provider-neutral group? (Select all that apply.)

  3. Q3. A generic wrapper module is only as portable as the CI matrix that exercises it against every target.

  4. Q4. What is the production-grade use of the random_id resource?

  5. 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?

  6. Q6. Why is random_password a security concern when used with an unencrypted state backend?

  7. Q7. Which is the right pattern for a 'provider-neutral' Ansible inventory module?

Passing score: 75%. Answers are checked in this browser.