Private registries & version pinning
Curated modules, version constraints, and lockfiles.
You sign a rental agreement, and one clause says the house rules are whatever is posted on the landlord's website. You sign it once. The paper in your drawer never changes a word. The rules on that website can be rewritten overnight, and nothing you are holding will look any different.
That clause is what `source = "git::https://github.com/acme/tf-modules.git//network?ref=main"` does to your infrastructure. `main` is a branch name, and a branch is a pointer that moves every time somebody pushes to it. Terraform fetches whatever is sitting there the next time it runs. Your repository shows a clean diff while the code that builds your firewall rules changes underneath you.
Where a version number is allowed to live
Terraform has two families of module sources and they pin in completely different ways. A registry source is a short address like `terraform-aws-modules/vpc/aws`, and it is the only kind that accepts a separate `version` argument. Every other source (a git address, a web link to a zip archive, a folder on your disk) has to carry its pin inside the source string itself.
Put a `version` line next to a git source and `terraform init` stops before it does anything: `Error: Invalid version constraint. Cannot apply a version constraint to module "network" (at main.tf:3) because it doesn't come from a module registry.` That one rule explains most of what follows.
terraform {required_version = "~> 1.9"required_providers {aws = {source = "hashicorp/aws"version = "~> 5.100"}}}# Registry source, so a separate `version` argument is allowed here.# This module builds a VPC (Virtual Private Cloud), your own# private slice of the AWS network.module "vpc" {source = "terraform-aws-modules/vpc/aws"version = "~> 5.8"name = "prod"cidr = "10.0.0.0/16" # the address range the VPC hands out}
`~>` is the pessimistic constraint operator, and the safest way to read it is this: the last number you wrote is allowed to grow, nothing to its left is. `~> 5.8` accepts 5.9 and 5.13 but never 6.0. `~> 5.8.0` accepts 5.8.4 and stops short of 5.9.0. An exact pin, `version = "5.8.1"`, accepts one release and no other.
The trade is a familiar one. Exact pins mean every upgrade becomes a commit that somebody reads before it ships, and somebody has to do that reading. Ranges mean a maintainer you have never met can change the code running in your account on a Tuesday afternoon, and the first you hear about it is the plan output.
$ terraform init
Initializing modules...Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.13.0 for vpc...- vpc in .terraform/modules/vpcInitializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 5.100"...- Installing hashicorp/aws v5.100.0...- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)Terraform has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou run "terraform init" in the future.Terraform has been successfully initialized!
Read the module line again. You asked for `~> 5.8` and Terraform handed you 5.13.0, because it takes the newest release that fits the constraint. Now look at what got written down. The lock file it created covers the provider and only the provider. Terraform has no lock file for modules, in any version, anywhere. HashiCorp says so in as many words: the dependency lock file tracks provider dependencies, and Terraform does not remember version selections for remote modules. The install record it leaves at `.terraform/modules/modules.json` is local scratch that you do not commit. Run the same `init` next quarter, from the same repository at the same commit, and you may get 5.19.0 instead. Nothing in your git history will mark the moment your network module changed.
A branch is a clause someone else can rewrite
For git sources, everything after `?ref=` is handed to git as a checkout target. A ref, short for reference, is git's word for anything that names a commit, so that field accepts a branch name, a tag, or a full commit SHA (Secure Hash Algorithm, the 40-character fingerprint git computes for every commit from the commit's own contents). All three look equally precise sitting in your file. Only one of them actually is.
The attack is short. Someone gets write access to the shared modules repository through a stolen token, a laptop somebody walked away from with a live session on it, or a team permission set to `maintain` two years ago and never revisited. They push one line to `main`: an extra inbound rule opening port 22 to the whole internet, or a trust policy on an IAM (Identity and Access Management) role that quietly adds their own AWS account to the list of who may assume it. They touch nothing else. Your next pipeline run is triggered by an unrelated change in a different directory, and it applies their line for them, with your credentials, against your production account.
Here is the detail that makes this land in continuous integration (CI, the automated build system that runs your pipeline) specifically. `terraform init` reuses a module it has already installed and does not re-clone it unless you pass `-upgrade`. On your laptop, where `.terraform/` has been sitting untouched for weeks, the branch looks frozen. A build runner starts every job from an empty checkout, so every job clones the branch fresh and gets whatever is on it at that second.
# Three ways to write the same dependency. Pick one; Terraform rejects# three blocks that share the name "network".# Dangerous: a moving target. `//network` is the subdirectory inside the repo.module "network" {source = "git::https://github.com/acme/tf-modules.git//network?ref=main"}# Better: a tag. Readable, and a bump shows up as a one-line diff someone reviews.# ssh:// means the clone authenticates with an SSH (Secure Shell) key, not a password.module "network" {source = "git::ssh://[email protected]/acme/tf-modules.git//network?ref=v1.4.0"}# Strongest: the commit fingerprint. Content-addressed, so it cannot be re-pointed.module "network" {source = "git::ssh://[email protected]/acme/tf-modules.git//network?ref=9f3c1e0a1b2d4c6e8f0a2b4c6d8e0f1a2b3c4d5e"}
You can have a machine check this for you on every pull request. Checkov is a free scanner that reads infrastructure code and flags patterns its rule library considers unsafe, and it ships a rule for exactly this.
$ checkov -d . --framework terraform
terraform scan results:Passed checks: 0, Failed checks: 1, Skipped checks: 0Check: CKV_TF_1: "Ensure Terraform module sources use a commit hash"FAILED for resource: module.networkFile: /main.tf:1-31 | module "network" {2 | source = "git::https://github.com/acme/tf-modules.git//network?ref=main"3 | }
`CKV_TF_1` demands a commit fingerprint. Its softer sibling `CKV_TF_2`, "Ensure Terraform module sources use a tag with a version number", accepts a version tag, which is the more realistic bar for most teams. Either way an unpinned source fails the build without anyone writing a line of policy. Trivy, the scanner that absorbed the retired tfsec project, looks at what your resources are configured to do rather than where the code came from, so the two tools cover different ground and pair well. If your rule is more specific than either tool offers, something like "every module source must begin with `app.terraform.io/acme-corp/`", write it once as a policy for conftest (a command-line test runner built on OPA, the Open Policy Agent, which lets you express rules as code) and run it in the same job.
The lock file is the part that verifies
Providers get the protection modules never received. Think of the foil seal under the cap of a new bottle of pills. The seal does not stop anyone from swapping the contents, it makes a swap impossible to hide. `.terraform.lock.hcl` is that seal, written in HCL (HashiCorp Configuration Language, the same syntax as your `.tf` files). `terraform init` writes it into your root module directory, and for every provider it records three things: the version that was selected, the constraint in force when that choice was made, and a list of checksums. A checksum is a short fingerprint computed from a file's bytes, and changing a single byte anywhere changes the fingerprint beyond recognition.
# This file is maintained automatically by "terraform init".# Manual edits may be lost in future updates.provider "registry.terraform.io/hashicorp/aws" {version = "5.100.0"constraints = "~> 5.100"hashes = ["h1:qYyk1CQ...HxwOQxA=", # darwin_arm64: hash of the extracted contents"h1:8pBnT2v...9mKdE1s=", # linux_amd64"zh:0f1c7b3...b5a9e2d1", # from the registry's signed checksum document"zh:2a4d9e0...c7f31b88","zh:5e8b1a2...4d0c6fa3",]}
The trailing notes above are mine, added for reading. A real lock file carries no annotations, which is part of why the next point trips people up so often. Two hash schemes live in that list. `zh:` stands for zip hash: those entries come from the registry's signed checksum document and cover every platform the provider publishes, whether you use them or not. `h1:` entries are computed from the extracted package contents, and Terraform records one only for a platform it has actually downloaded.
So you run `init` on an Apple laptop, commit a lock file carrying a `darwin_arm64` hash, and the Linux build runner turns up with nothing to compare against. Generate the hashes for every platform your team and your pipeline use, deliberately, in one command. Each platform you name gets both schemes recorded.
$ terraform providers lock \-platform=darwin_arm64 \-platform=linux_amd64
- Fetching hashicorp/aws 5.100.0 for darwin_arm64...- Retrieved hashicorp/aws 5.100.0 for darwin_arm64 (signed by HashiCorp)- Fetching hashicorp/aws 5.100.0 for linux_amd64...- Retrieved hashicorp/aws 5.100.0 for linux_amd64 (signed by HashiCorp)- Obtained hashicorp/aws checksums for darwin_arm64; This was the first packageof this provider version installed on this system, so no existing checksumswere available.- Obtained hashicorp/aws checksums for linux_amd64; This was the first packageof this provider version installed on this system, so no existing checksumswere available.Success! Terraform has updated the lock file.Review the changes in .terraform.lock.hcl and then commit to yourversion control system to retain the new checksums.
Then commit the file. A lock file that exists only on the machine that generated it verifies nothing, because the machine you need to catch tampering is the pipeline, and the pipeline reads what is in git.
Reading a checksum mismatch
This error is the whole reason the mechanism exists. It says the bytes that arrived are not the bytes recorded when the lock file was written.
$ terraform init -input=false
Initializing the backend...Initializing provider plugins...- Reusing previous version of hashicorp/aws from the dependency lock file- Installing hashicorp/aws v5.100.0...╷│ Error: Failed to install provider││ Error while installing hashicorp/aws v5.100.0: the local package for│ registry.terraform.io/hashicorp/aws 5.100.0 doesn't match any of the checksums│ previously recorded in the dependency lock file (this may indicate that the│ mirror is serving an altered copy of this package, or that a previously-recorded│ checksum was incorrect); for more information:│ https://developer.hashicorp.com/terraform/language/provider-checksum-verification╵
Two very different situations hide behind that one message, and you tell them apart by opening the lock file. If there is no `h1:` line for the platform you are running on, this is the laptop-versus-runner problem from the previous section, and the fix is `terraform providers lock -platform=...` with the result committed. If there is a hash for your platform and the package still fails to match, you have an integrity failure: a mirror, a proxy, or a shared plugin cache handed you different bytes than the ones a colleague recorded. Stop there. Keep the failing artifact, and find out where that download came from before you touch anything else.
When the lock and the constraint disagree
Change `version = "~> 5.100"` to `"~> 6.0"` in your configuration, leave the lock file alone, and Terraform refuses to guess what you meant.
$ terraform init
Initializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...╷│ Error: Failed to query available provider packages││ Could not retrieve the list of available versions for provider hashicorp/aws:│ locked provider registry.terraform.io/hashicorp/aws 5.100.0 does not match│ configured version constraint ~> 6.0; must use terraform init -upgrade to│ allow selection of new versions╵
$ terraform init -upgrade
Initializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...- Installing hashicorp/aws v6.0.0...- Installed hashicorp/aws v6.0.0 (signed by HashiCorp)Terraform has made some changes to the provider dependency selections recordedin the .terraform.lock.hcl file. Review those changes and commit them to yourversion control system if they represent changes you intended to make.Terraform has been successfully initialized!
That refusal is the control doing its job. A provider cannot roll forward by accident. It takes a deliberate flag typed by a person, and it produces a lock file diff showing a new version and an entirely new set of hashes, which is exactly the shape of change you want a second pair of eyes on. In the pipeline, make the read-only behaviour explicit so nobody can quietly regenerate that file as a side effect of running a plan.
name: terraformon: [pull_request]permissions:contents: readjobs:plan:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: hashicorp/setup-terraform@v3with:terraform_version: 1.9.8terraform_wrapper: false- uses: actions/setup-python@v5with:python-version: "3.12"- run: pip install checkov# Fails if init would need to add, change, or remove any lock entry.- run: terraform init -input=false -lockfile=readonly- run: terraform validate- run: checkov -d . --framework terraform
`-lockfile=readonly` turns "someone forgot to commit the lock file update" from a silent success into a failed build. While you have that file open, notice `actions/checkout@v4`. That is a tag on somebody else's repository, movable by exactly the mechanism described earlier, and the same reasoning applies to it. Pin third-party actions to a full commit fingerprint and put the human-readable version in a trailing comment so the next reader knows what they are looking at.
Private registries: a menu someone signed off
The public registry is a city full of restaurants. Some are excellent, some will make you ill, and nobody is checking on your behalf. A private registry is the company canteen: one kitchen, known suppliers, a menu a named person approved. It gives you a curated internal catalog where publishing is the review gate, so a team picks from modules that somebody actually read rather than from whatever ranked well in a search.
HCP Terraform (HashiCorp Cloud Platform, the hosted service previously called Terraform Cloud) runs a private registry for each organization. You connect a module repository, and every git tag shaped like a semantic version becomes a published version. Both `1.4.2` and `v1.4.2` work, and tags in any other shape are ignored. The consumer side then looks exactly like the public registry, which means you get the `version` argument back for internal code that used to be pinned by hand.
module "network" {source = "app.terraform.io/acme-corp/network/aws"version = "~> 1.4"environment = "prod"}
$ export TF_TOKEN_app_terraform_io="$(vault kv get -field=token secret/tfc)"$ terraform init
Initializing modules...Downloading app.terraform.io/acme-corp/network/aws 1.4.2 for network...- network in .terraform/modules/networkInitializing the backend...Initializing provider plugins...- Reusing previous version of hashicorp/aws from the dependency lock file- Using previously-installed hashicorp/aws v5.100.0Terraform has been successfully initialized!
That environment variable name is the hostname with every dot turned into an underscore, which is how Terraform matches a token to a registry. On a laptop, `terraform login` writes the same credential into `~/.terraform.d/credentials.tfrc.json` instead, so you rarely type it. Artifactory can host Terraform module and provider repositories behind the credentials your organization already manages, which is the usual choice for teams not on HCP Terraform. If you want none of that machinery, a single internal git repository with protected tags, a `CODEOWNERS` file (a plain text list saying which people must approve changes to which paths) and required reviews gets you most of the benefit for the price of a repository setting. You give up `version` constraints and go back to pinning refs by hand, a fair trade for a small team.
Providers have an equivalent, and it is the piece people forget. A mirror is a local depot that keeps its own stock instead of ordering from the factory every time. It serves provider packages from inside your network, so a registry outage or a release yanked by its author does not stop your deploys, and every download crosses a boundary you control.
provider_installation {network_mirror {url = "https://tf-mirror.acme.internal/providers/"}direct {exclude = ["registry.terraform.io/*/*"]}}
Notice what still protects you here. A mirror with a valid certificate can serve a modified copy of an upstream provider, and HashiCorp's own documentation warns about pointing this setting at a host you do not trust. The mirror decides which bytes to hand over, and the committed lock file decides whether Terraform accepts them, which is precisely why that mismatch error names the mirror as a suspect. A compromised mirror produces a failed init rather than a silent provider swap.
Verify the whole arrangement the way an auditor would, on a schedule. Clone the repository fresh onto a machine with an empty plugin cache, run `terraform init -lockfile=readonly`, and confirm that no module source anywhere in the tree resolves to a branch. If both checks pass, two applies a month apart are running the same code, and you can prove it rather than assume it.