The OpenTofu registry, providers & modules
Where OpenTofu resolves code from.
A parcel label that reads "ACME Widgets, Berlin" is not a widget. Something has to turn that name into a warehouse, a shelf, and a box on your doorstep. The line source = "hashicorp/aws" in an OpenTofu configuration is that kind of label, and the registry is what turns it into a real file on your disk. Here is the part that matters if you defend systems for a living: the registry never hands you a plugin (a separate program that tofu starts up and talks to). It hands you an address to fetch from, a list of expected fingerprints, and a signature over that list. Your tofu binary does every download and every check itself.
The registry lives at registry.opentofu.org, and its metadata comes from a public GitHub repository called opentofu/registry. Providers, modules, and the GPG (GNU Privacy Guard, the standard tool for signing a file so anyone can prove who produced it) public keys that sign them are submitted through issue forms and reviewed by the OpenTofu maintainers. You can go and read the issue that added a namespace. Compare that with an index you can only query: here a defender can answer "who is allowed to publish under this name" by reading a repository, with no support ticket involved.
What the registry actually hands you
Before tofu talks to a registry host, it asks that host where everything is, the way you read the directory board in a lobby before walking to an office. That step is called service discovery: one HTTPS (hypertext transfer protocol secure, the encrypted version of the web's transfer protocol) request to a fixed well-known path, answered with a small JSON (JavaScript Object Notation, a plain-text way of writing structured data) document mapping API (application programming interface, the agreed way one program asks another for something) names to URL prefixes. Every registry answers the same way, public or private, which is why pointing a configuration at an internal registry needs no special flag. Walk the discovery reply by hand and you follow the exact route tofu init follows.
# 1. Service discovery: where does this host keep its APIs?curl -s https://registry.opentofu.org/.well-known/terraform.json# 2. Which versions of a provider exist? The list is not sorted for you.curl -s https://registry.opentofu.org/v1/providers/hashicorp/random/versions \| jq '{count: (.versions | length),newest: ([.versions[].version]| sort_by(split(".") | map(tonumber? // 0)) | last)}'# 3. How do I get one, for a single operating system and CPU?curl -s https://registry.opentofu.org/v1/providers/hashicorp/aws/6.0.0/download/linux/amd64 \| jq '{filename, download_url, shasums_url, shasums_signature_url, shasum,key_id: .signing_keys.gpg_public_keys[0].key_id}'
{"modules.v1": "/v1/modules/","providers.v1": "/v1/providers/"}{"count": 41,"newest": "3.9.0"}{"filename": "terraform-provider-aws_6.0.0_linux_amd64.zip","download_url": "https://github.com/opentofu/terraform-provider-aws/releases/download/v6.0.0/terraform-provider-aws_6.0.0_linux_amd64.zip","shasums_url": "https://github.com/opentofu/terraform-provider-aws/releases/download/v6.0.0/terraform-provider-aws_6.0.0_SHA256SUMS","shasums_signature_url": "https://github.com/opentofu/terraform-provider-aws/releases/download/v6.0.0/terraform-provider-aws_6.0.0_SHA256SUMS.sig","shasum": "5e5e6b4cf921abf55c69b7ed33450a98de8bed611082c4272e9bba81a965d81c","key_id": "0C0AF313E5FD9F80"}
Read that third reply slowly, because it is the whole security story of provider installation. download_url does not point at the registry. It points at a release on GitHub owned by the publisher, so one tofu init touches two separate services on its way to one plugin. shasums_url is a text file listing the SHA256 (secure hash algorithm, 256-bit, a fingerprint of a file's contents where changing a single byte changes the whole fingerprint) of every archive in that release. Beside it sits shasums_signature_url, a detached signature: a small .sig file that signs the fingerprint list rather than any individual archive, like a wax seal on the packing slip instead of on each box. signing_keys is the public key the registry says owns this namespace. tofu fetches the list, verifies the signature against that key, checks the downloaded zip's fingerprint against the list, and only then unpacks it.
Two things follow for anyone running this in anger. First, your outbound firewall rules need the download host as well as the registry host. Allow only registry.opentofu.org and init dies halfway through with a confusing message, because the metadata resolved fine and the package fetch did not. Second, be honest about what the signature buys you. On a first install, whoever controls the registry response controls both the download URL and the key that download is checked against. That is trust on first use, the same bet you make the first time you accept an SSH (secure shell) host key. A compromised registry is not survivable by signature checking alone. What the design does give you is a public paper trail, since key changes land as reviewed submissions in opentofu/registry, plus a lock file that makes every install after the first refuse anything that does not match what you already recorded. The lock file is the control that holds.
Same name, different bytes
Here is the thing that catches people migrating off Terraform. The address hashicorp/aws is a name, not a destination. Ask two registries for the identical version and you get two different files.
for host in registry.opentofu.org registry.terraform.io; doprintf '%-22s ' "$host"curl -s "https://$host/v1/providers/hashicorp/aws/6.0.0/download/linux/amd64" \| jq -r '"\(.download_url | split("/")[2]) \(.shasum) key \(.signing_keys.gpg_public_keys[0].key_id)"'done
registry.opentofu.org github.com 5e5e6b4cf921abf55c69b7ed33450a98de8bed611082c4272e9bba81a965d81c key 0C0AF313E5FD9F80registry.terraform.io releases.hashicorp.com 4cd447b5c24f14553bd6e1a0e4fea3c7d7b218cbb2316a3d93f1c5cb562c181b key 34365D9472D7468F
Same provider name, same version number, different bytes, different signer. OpenTofu publishes the providers in the hashicorp namespace from its own GitHub organisation, github.com/opentofu, signed with key 0C0AF313E5FD9F80. Pull that key out of the registry reply and its user ID reads "OpenTofu (This key is used to sign opentofu providers) <[email protected]>". Terraform's registry serves HashiCorp's own builds from releases.hashicorp.com under key 34365D9472D7468F. Neither side is hiding any of this. What it means for you is concrete: the source string in your configuration is not the trust decision. The registry host it resolves to, plus the checksums you record, are the trust decision. It also explains something that alarms teams mid-migration. Lock file entries are keyed by the full address, so registry.terraform.io/hashicorp/aws and registry.opentofu.org/hashicorp/aws are separate entries with different hashes, and tofu init writes a fresh one rather than reusing yours. Third-party providers behave differently again: cloudflare/cloudflare resolves to Cloudflare's own repository, cloudflare/terraform-provider-cloudflare, under Cloudflare's key. The rebuild is specific to the hashicorp namespace.
Declaring which versions you will accept
A provider is a phrasebook for one language. Concretely, it is a separate binary that tofu launches and talks to, and it knows how to speak to exactly one API: AWS, Cloudflare, a Postgres database. You never install one by hand. You declare it in a required_providers block and tofu init fetches it. The full address has three parts, host/namespace/type, and writing the short two-part form means OpenTofu fills in its own default host for you. Version constraints are where the real care goes. The ~> operator sets a ceiling that depends on how many numbers you write: ~> 6.0 allows any 6.x and stops before 7.0, while ~> 6.0.1 allows only 6.0.x and stops before 6.1. Get that wrong and a team ends up either frozen on patch releases forever or accepting a minor version bump nobody reviewed. The enclosing block is still spelled terraform, which OpenTofu kept so one file works with either tool.
terraform {required_version = ">= 1.6.0"required_providers {aws = {source = "hashicorp/aws" # short for registry.opentofu.org/hashicorp/awsversion = "~> 6.0" # any 6.x, never 7.0}random = {source = "hashicorp/random"version = ">= 3.6.0, < 4.0.0"}cloudflare = {source = "cloudflare/cloudflare" # third party, same address shapeversion = "~> 5.22"}}}
tofu init
Initializing modules...Downloading registry.opentofu.org/terraform-aws-modules/vpc/aws 6.6.1 for vpc...- vpc in .terraform/modules/vpcDownloading git::https://github.com/acme/tofu-modules.git?ref=v1.4.0 for network...- network in .terraform/modules/network/network- labels in modules/labelsInitializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...- Finding hashicorp/random versions matching ">= 3.6.0, < 4.0.0"...- Finding cloudflare/cloudflare versions matching "~> 5.22"...- Installing hashicorp/aws v6.55.0...- Installed hashicorp/aws v6.55.0 (signed, key ID 0C0AF313E5FD9F80)- Installing hashicorp/random v3.9.0...- Installed hashicorp/random v3.9.0 (signed, key ID 0C0AF313E5FD9F80)- Installing cloudflare/cloudflare v5.22.0...- Installed cloudflare/cloudflare v5.22.0 (signed, key ID DE413CEC881C3283)Providers are signed by their developers.If you'd like to know more about provider signing, you can read about it here:https://opentofu.org/docs/cli/plugins/signing/OpenTofu has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that OpenTofu can guarantee to make the same selections by default whenyou run "tofu init" in the future.OpenTofu has been successfully initialized!
Two details in that output repay attention. The key ID on each Installed line is the thing to eyeball: a provider you expect from a vendor should carry the vendor's key, and cloudflare/cloudflare arriving under OpenTofu's key would be worth chasing down before you type apply. And notice the modules were fetched before any provider was, which matters because a module can drag in providers you never wrote down. tofu providers prints the whole dependency tree, constraints included, and it is the quickest way to see what your configuration really depends on.
tofu providers
Providers required by configuration:.├── provider[registry.opentofu.org/hashicorp/aws] ~> 6.0├── provider[registry.opentofu.org/hashicorp/random] >= 3.6.0, < 4.0.0├── provider[registry.opentofu.org/cloudflare/cloudflare] ~> 5.22├── module.vpc│ └── provider[registry.opentofu.org/hashicorp/aws] >= 6.0.0└── module.network└── provider[registry.opentofu.org/hashicorp/tls]Providers required by state:provider[registry.opentofu.org/hashicorp/aws]provider[registry.opentofu.org/hashicorp/random]
module.network brought hashicorp/tls along with it, unconstrained, and undeclared anywhere in your own files. That is normal. It is also exactly how an unexpected plugin ends up running on your build agent with your cloud credentials sitting in the environment. Reading this tree during code review, rather than during an incident, is the cheap version of that lesson.
One source field, several fetchers
A module is a directory of .tf files, nothing more, and source tells OpenTofu where that directory lives. One field, several meanings: OpenTofu reads the shape of the string and picks a fetcher, the way a post room reads an address and decides between the courier, the internal mail tray, and the drawer behind the desk. Three parts with no dot in the first one means the registry. A git:: prefix, or a github.com/ shorthand, means clone it. A leading ./ or ../ means a local directory that is never fetched from anywhere. Only registry sources accept a version argument, because the registry is the only source that knows which releases exist. Git sources pin inside the URL with ?ref=, //subdir picks a folder inside the repository, and adding &depth=1 gives you a shallow clone when the repository is large.
module "vpc" {source = "terraform-aws-modules/vpc/aws" # <NAMESPACE>/<NAME>/<TARGET_SYSTEM>version = "~> 6.6" # version = only works for registry sourcesname = "prod-vpc"cidr = "10.0.0.0/16" # CIDR: the address range for this network}module "network" {# git source: //network selects a subdirectory, ?ref pins a tag or commitsource = "git::https://github.com/acme/tofu-modules.git//network?ref=v1.4.0"}module "labels" {source = "./modules/labels" # local path, never fetched, no version argument}
Now the part that catches security teams out. .terraform.lock.hcl locks providers. It does not lock modules. No checksum is recorded for module source code anywhere, ever. What you get instead depends entirely on which fetcher the source string picked, and you can see the difference for yourself by asking the registry where a module version really comes from.
curl -s https://registry.opentofu.org/v1/modules/terraform-aws-modules/vpc/aws/6.6.1/download \| jq -r .location
git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=3ffbd46fb1c7733e1b34d8666893280454e27436
The registry recorded a full commit hash at submission time, not a tag, so a registry module version resolves to one exact commit and stays there. Your own git source is a different animal. A tag is a label someone can move: anyone who can push to that repository can point v1.4.0 at a new commit, and your next tofu init fetches the new code and says nothing at all. A branch name like ?ref=main is not a pin in any sense of the word. For a third-party module that matters, pin to a full 40-character commit hash yourself, or vendor a copy into a repository you control. To see what a run actually pulled, read the manifest that init writes.
# Everything this configuration really pulled in, remote and localjq -r '.Modules[] | select(.Key != "") | [.Key, .Source, (.Version // "-")] | @tsv' \.terraform/modules/modules.json | column -t -s$'\t'
vpc terraform-aws-modules/vpc/aws 6.6.1network git::https://github.com/acme/tofu-modules.git//network?ref=v1.4.0 -labels ./modules/labels -
Modules called by other modules show up here too, with dotted keys like vpc.subnets, so this file is the complete inventory of remote code your run will execute. It is small, it is machine-readable, and it belongs in whatever check runs before an apply reaches production.
Where trust gets frozen
The lock file is a receipt. tofu init writes it, and from that moment it, rather than the registry, decides what an install is allowed to be. Each provider gets one block keyed by its full address, holding the exact version chosen, the constraints that produced it, and a list of hashes. Two hash schemes share that list, and the gap between them causes most lock file pain. A zh: entry (zip hash) is the SHA256 of the official .zip exactly as the registry indexes it, so it can only ever vouch for that one archive. An h1: entry (hash scheme 1) is computed over the unpacked contents of the package, so it vouches for the provider no matter how it arrived: registry, mirror, or local cache. On a first install OpenTofu records zh: for every platform in the release, because they all come from one signed SHA256SUMS file. It can record h1: only for the platform it actually downloaded and extracted.
# What is pinned, and with which hash schemes?awk '/^provider /{p=$2}/"h1:/{h[p]++}/"zh:/{z[p]++}END{for (k in h) printf "%-46s h1:%-3d zh:%d\n", k, h[k], z[k]}' .terraform.lock.hcl
"registry.opentofu.org/cloudflare/cloudflare" h1:1 zh:8"registry.opentofu.org/hashicorp/random" h1:1 zh:15"registry.opentofu.org/hashicorp/aws" h1:1 zh:15
One h1: against fifteen zh: is the signature of a lock file written on a single machine. The zh: count is simply how many platform builds that release contains, which is why cloudflare shows eight and the OpenTofu builds of aws and random show fifteen. Fix the imbalance with tofu providers lock, which downloads the package for each platform you name, verifies the signature, and writes the missing hashes. Naming a provider address limits the run to that provider; drop the address and it covers everything in the configuration.
tofu providers lock \-platform=linux_amd64 \-platform=darwin_arm64 \-platform=windows_amd64 \registry.opentofu.org/hashicorp/random
- Fetching hashicorp/random 3.9.0 for linux_amd64...- Retrieved hashicorp/random 3.9.0 for linux_amd64 (signed, key ID 0C0AF313E5FD9F80)- Fetching hashicorp/random 3.9.0 for darwin_arm64...- Retrieved hashicorp/random 3.9.0 for darwin_arm64 (signed, key ID 0C0AF313E5FD9F80)- Fetching hashicorp/random 3.9.0 for windows_amd64...- Retrieved hashicorp/random 3.9.0 for windows_amd64 (signed, key ID 0C0AF313E5FD9F80)- Obtained hashicorp/random checksums for linux_amd64; This was already tracked in the lock file- Obtained hashicorp/random checksums for darwin_arm64; Additional checksums were added to the lock file- Obtained hashicorp/random checksums for windows_amd64; Additional checksums were added to the lock fileSuccess! OpenTofu has updated the lock file.Review the changes in .terraform.lock.hcl above and commit them to yourversion control system if they represent changes you intended to make.
Taking the public internet out of the path
Air-gapped and tightly firewalled environments need plugins to come from inside the building. The pattern is a bonded warehouse: stock an internal shelf once, then lock the front door. tofu providers mirror builds that shelf from whatever the current configuration needs, and a provider_installation block in the CLI (command-line interface) configuration file rewires resolution to use it. On Linux that file is ~/.tofurc, and if both ~/.tofurc and the older ~/.terraformrc exist, .tofurc wins. TOFU_CLI_CONFIG_FILE overrides the location (TF_CLI_CONFIG_FILE still works for compatibility), which is how you hand a build agent a stricter policy than a developer laptop gets. The direct block with an exclude list is the part people forget: leave it out and anything missing from the mirror quietly falls back to the public registry, which is the behaviour you were trying to remove.
provider_installation {# Everything comes off the internal shelf. This is the directory that# "tofu providers mirror" builds, rsynced in, no HTTP server needed.filesystem_mirror {path = "/srv/tofu-mirror"include = ["registry.opentofu.org/*/*"]}# ...and nothing is allowed to fall back to the public registry.direct {exclude = ["registry.opentofu.org/*/*"]}# Served variant: a network_mirror needs a host that implements the# mirror protocol (index.json per provider), not a plain file listing.# network_mirror {# url = "https://tofu-mirror.internal.acme.co/providers/"# }}
# Stock the shelf from a machine that still has egresstofu providers mirror -platform=linux_amd64 -platform=darwin_arm64 /srv/tofu-mirror# Then, on the locked-down box, resolve through the mirror onlyTOFU_CLI_CONFIG_FILE=/home/you/.tofurc tofu init
- Mirroring hashicorp/aws...- Selected v6.55.0 to meet constraints "~> 6.0"- Downloading package for linux_amd64...- Package authenticated: signed, key ID 0C0AF313E5FD9F80- Downloading package for darwin_arm64...- Package authenticated: signed, key ID 0C0AF313E5FD9F80- Mirroring hashicorp/random...- Selected v3.9.0 to meet constraints ">= 3.6.0, < 4.0.0"- Downloading package for linux_amd64...- Package authenticated: signed, key ID 0C0AF313E5FD9F80- Downloading package for darwin_arm64...- Package authenticated: signed, key ID 0C0AF313E5FD9F80- Mirroring cloudflare/cloudflare...- Selected v5.22.0 to meet constraints "~> 5.22"- Downloading package for linux_amd64...- Package authenticated: signed, key ID DE413CEC881C3283- Downloading package for darwin_arm64...- Package authenticated: signed, key ID DE413CEC881C3283Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...- Installing hashicorp/aws v6.55.0...- Installed hashicorp/aws v6.55.0 (unauthenticated)- Installing hashicorp/random v3.9.0...- Installed hashicorp/random v3.9.0 (unauthenticated)- Installing cloudflare/cloudflare v5.22.0...- Installed cloudflare/cloudflare v5.22.0 (unauthenticated)OpenTofu has been successfully initialized!
(unauthenticated) looks alarming and is accurate reporting. A mirror serves packages without the publisher's signature attached, so the GPG chain ends back at the machine that built the mirror. From there, the h1: hashes in your lock file are the only integrity control you have left, which is why that init only succeeded: the lock file already carried an h1: for linux_amd64 from the earlier tofu providers lock run. That fixes the order of operations for going offline. Populate the lock file from the origin registry first, with signatures verified and every platform named, commit it, and only then point anything at a mirror. Build the mirror the other way round and you have carefully pinned whatever your mirror happened to contain.
One more setting in that same file deserves a permanent place on your review list. provider_installation also accepts a dev_overrides stanza, which points a provider address at a local directory. It exists for people writing providers, and it does something nothing else does: it bypasses tofu init and the dependency lock file for that provider, so no version is resolved and no checksum is checked. OpenTofu prints a warning on every plan and apply, which is easy to scroll past in a CI log. A dev_overrides line left behind in a shared ~/.tofurc on a build agent means every run silently loads a binary from disk that nobody verified, with your cloud credentials in the environment. Treat that file as security-relevant configuration: own it, review it, and make the runner's copy read-only rather than letting jobs write to $HOME.
The check that catches this whole class of drift is two lines long. Re-run the lock command for every platform your team and your pipeline use, then ask git whether the file moved. If it did, somebody added or bumped a provider without recording hashes for everyone else, and the next Linux runner or Windows laptop is going to trip over it.
tofu providers lock \-platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64 >/dev/nullgit diff --exit-code -- .terraform.lock.hcl && echo "lock file covers every platform"
lock file covers every platform
Try this
Run curl -s https://registry.opentofu.org/.well-known/terraform.json on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a lock file written on one laptop breaks other platforms. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.