Skip to content
advanced Phase 7 · Module Design and the Module Registry

Publishing to a Private Module Registry

Versioning and sharing modules through Terraform Cloud, Enterprise, or Git sources.

55m
0 problems
Topic Progress 0%

Choosing the Host: HCP Terraform Namespaces Versus a Tagged Git Repo

Your network module is finished and three other teams want it. Now the question is where it lives, because that decides how consumers pin it, how they authenticate, and what happens the day you release version 2.0.

Terraform Cloud and Terraform Enterprise ship a private module registry built into the product. Modules there are addressed by host, namespace, name, provider — the same four segments as the public registry, with your host in front:

module "vpc" {
  source  = "app.terraform.io/acme-corp/vpc/aws"
  version = "~> 1.4.0"
}
app.terraform.io / acme-corp / vpc / aws
└──── host ────┘ └─ org ──┘ └name┘ └ecosystem label┘

Self-hosted Terraform Enterprise uses the same shape with its own hostname — tfe.acme.internal/acme-corp/vpc/aws — so migrating from Cloud to Enterprise is a hostname edit, not a redesign. The provider segment is a label rather than code selection, but the protocol requires it, and it is how registries namespace vpc from a networking team distinctly from vpc from a data team.

Authentication happens outside the source string, in the CLI configuration or the environment:

# ~/.terraformrc   (Windows: %APPDATA%/terraform.rc)
credentials "app.terraform.io" {
  token = "xxxxxx.atlasv1.zzzzzz"
}
# CI: dots in the hostname become underscores
export TF_TOKEN_app_dot_terraform_io="$TOKEN"

The token never appears in the source address or the repository — it is per-host configuration, so rotating it is an environment change rather than a code change.

The alternative needs no service at all: host the module in Git and let consumers pin a tag.

module "vpc" {
  source = "git::https://github.com/acme/tf-modules.git//vpc?ref=v1.4.0"
}

Tags are the poor man's version: a pushed v1.4.0 ref that consumers type out in full. It works, it is free, and it is what most teams do on day one. What it costs becomes clear the moment a consumer wants to ask "what versions exist, and which satisfies ~> 1.4?":

Capability Private registry Git source with ?ref=
Semver constraints (~> 1.4) Yes — init selects the match No — one literal ref per call
Version discovery / init -upgrade Yes — init lists and re-resolves No — you edit the ref yourself
Rendered docs for consumers Registry UI from README/descriptions Whatever the repo renders
Auth One host token Git credentials or SSH keys
Infrastructure to operate HCP Terraform or Terraform Enterprise None

That table is the decision. If your organization already runs Terraform Cloud or Enterprise, publishing there costs a repository link and buys every consumer constraint-based pinning; if not, tagged Git sources are a legitimate staging ground — Composing Modules into Layers already assumes you know which of the two the composition will draw from.

Two things stay identical either way, and knowing them saves a debugging session. First, the protocol does not change: init reads the host's service-discovery document, requests the version list for acme-corp/vpc/aws, and picks the newest release matching the constraint — the same code path as the public registry, with your hostname and your 401 in the middle. Everything you know about constraint syntax carries over unchanged: ~> 1.4.0 for patches, = 1.4.0 for exact, >= 1.4, < 2.0 for a window. Second, the namespace is an ownership decision: an organization-wide namespace (acme-corp) says "published, supported, ours," while team-scoped namespaces (acme-corp/networking) let several groups publish without stepping on each other's vpc. Pick it before the first consumer pins it — changing a namespace later breaks every address in flight.

Publishing 1.4.0: From Git Tag to a Version Consumers Can Pin

A registry version is only as adoptable as its documentation, so the publish checklist starts with content, not commands:

  • README with a usage snippet — the exact module block a consumer pastes, with real values.
  • Every variable described — name, type, default, allowed values; the description is the API reference.
  • Every output described — what it returns and what it is meant to be wired into.
  • examples/ directory — a minimal and a complete configuration, so adoption is copy-paste rather than archaeology.
  • CHANGELOG entry — consumers read it before bumping a constraint.

With that in place, release through a tag:

git add . && git commit -m "vpc: support secondary CIDR blocks"
git tag -a v1.4.0 -m "vpc 1.4.0: secondary CIDR support"
git push origin main v1.4.0

On HCP Terraform you link the module to its VCS repository in the Registry UI; the tags you push become the published versions consumers see. On Terraform Enterprise the flow is the same against your instance. Without a registry, that tag is the release — consumers reference it with ?ref=v1.4.0 and nothing else.

Version numbers carry the contract you built in Designing Module Input and Output Contracts:

Change in this release Semver Consumer action
Bug fix inside a resource, no interface change Patch → 1.4.1 None required
New input with a default, new output Minor → 1.5.0 Optional
Renamed input, removed output, tightened validation Major → 2.0.0 Edit call sites, then bump
Dropped support for a provider version Major Upgrade provider first

Consumers then prove the release works before anyone depends on it — a throwaway directory is enough:

mkdir /tmp/probe && cd /tmp/probe
# main.tf: source = "app.terraform.io/acme-corp/vpc/aws", version = "= 1.4.0"
terraform init
Initializing modules...
- vpc in app.terraform.io/acme-corp/vpc/aws
Downloading app.terraform.io/acme-corp/vpc/aws 1.4.0 for vpc...

Upgrading an existing consumer is deliberately boring: edit the constraint, re-run init, read the plan.

terraform init -upgrade    # re-resolve providers and modules within constraints
terraform plan -out=tf.plan

There is one last verification worth building into the release routine, because it protects consumers from your own mistakes: after tagging, upgrade a real consumer and read the plan.

# in a staging root that consumes the module at the previous version
terraform init -upgrade          # moves 1.4.0 → 1.4.1 under "~> 1.4.0"
terraform plan -out=tf.plan

If a patch release produces a plan with destroys or replacements, the release was not a patch — an interface or default changed when it should not have, and you would rather learn that from staging than from a consumer's incident. The same check at minor scope should show only additive changes: new resources from new optional inputs, nothing removed. The plan diff is the honest changelog for infrastructure code, and publishing without reading it is how a module earns version numbers nobody trusts.

-upgrade is what moves an already-installed module to the newest version the constraint allows — without it, init keeps the installed version and your constraint edit appears to do nothing. The resolved record lands in .terraform/modules/modules.json, which is why the constraint in code, not a committed .terraform directory, is the source of truth for which module version a workspace runs. That manifest's recorded version is exactly what init compares against your edited constraint — a mismatch is what triggers the reinstall in the first place.

When CI Cannot Fetch the Module: Credentials, Namespace, and Init Order

Production failures with private modules are rarely about the module. They are about a runner that cannot prove who it is, or a job that skipped the step that installs code. Learn to read the four symptoms:

Symptom Root cause Fix
Error: Module not installed Plan/validate ran before init — .terraform/modules is empty Run terraform init -backend=false in validate-only jobs
Error: Module not found + registry hostname Wrong host, namespace, name, or provider segment Correct the four-segment address; publish the version
401 Unauthorized during init No token for the host in the runner Set TF_TOKEN_app_dot_terraform_io from your secret store
Code changed, constraint did not Source pinned to a branch ref that moved Pin a tag or SHA; re-run init

The first one owns most first-day outages. The error is unambiguous once you have seen it:

Error: Module not installed

This module is not yet installed. Run "terraform init" to install all
modules required by this configuration.

Containers make it easy to hit: the image has the configuration, but not the workspace's .terraform directory. The pipeline order that avoids it is fixed — init, then validate, then plan — and a job that only lints configuration can skip backend access with terraform init -backend=false, which still installs modules and providers without touching state.

terraform fmt -check            # no installation needed
terraform init -backend=false   # modules + providers, no state access
terraform validate

terraform init                  # plan job: full init, backend included
terraform plan -input=false -out=tf.plan

Note what never appears: a committed .terraform directory or a vendored copy of the module. Both are generated output, both drift the moment a constraint moves, and both hide the very failure this chapter is about. If a job cannot reach the registry, the fix is network or credentials — not shipping the code in the repository.

The second and third come from where credentials and addresses live. Init contacts the host before it evaluates any version constraint, so a token that exists on a laptop but not in CI fails the same address that worked locally. Keep three things separate: the address in code (host and namespace are not secret), the token in the environment, and the version in the constraint. Rotating a token must never require a pull request — and prefer a short-lived, read-only token scoped to module downloads over a broad user token, so the credential your CI holds cannot do more than fetch code.

The fourth is a discipline problem. Treat a module upgrade like any dependency upgrade:

# one PR: bump the constraint, then show reviewers the infrastructure diff
terraform init -upgrade
terraform plan -out=tf.plan

The plan is the release note for infrastructure. A ~> 1.4 constraint silently absorbing a 1.5 release with a new default is exactly the class of surprise that makes teams pin = 1.4.0 forever and never upgrade at all — better to constrain deliberately, upgrade deliberately, and read the diff.

Once your modules fetch reliably in CI, the remaining configuration surface is the providers those modules declare, which is where Declaring Required Providers picks up: the required_providers block, short names mapped to full source addresses, and what a child module's provider requirements do to your root's terraform init.

Quiz

1. What is the full source address of a private module named `vpc`, provider `aws`, in org `acme-corp` on HCP Terraform?

Question 1 options

2. Your team has no Terraform Cloud or Enterprise. Which approach gives consumers versioned pinning with the least infrastructure?

Question 2 options

3. A consumer on `version = "~> 1.4.0"` needs the new 1.5.0 release. What sequence works?

Question 3 options

4. A fresh CI container fails with `Error: Module not installed`. What is the correct fix?

Question 4 options

Flashcards

Question

What does a private registry module source address look like, and how is it authenticated?

Answer

Four segments: `host/namespace/name/provider`, e.g. `app.terraform.io/acme-corp/vpc/aws` (Terraform Enterprise same shape with its hostname). Authentication is per-host and lives outside the address — a `credentials "app.terraform.io" { token = ... }` block in `.terraformrc`, or `TF_TOKEN_app_dot_terraform_io` in CI.

Question

How do HCP Terraform / Terraform Enterprise produce module versions?

Answer

You link the module to a VCS repository and publish semver releases: tags pushed to the repo become versions consumers pin with `version` constraints. The registry then handles discovery, constraint matching, and docs rendering for consumers.

Question

What are git tags in place of a registry, and what do they cost?

Answer

Poor man's versions: `git tag -a v1.4.0` + a consumer source of `git::https://...//vpc?ref=v1.4.0`. No service to run, but no constraint syntax, no version listing, no `init -upgrade` resolution, and no rendered docs — consumers edit the ref by hand for every upgrade.

Question

What must exist before you publish a module version?

Answer

README with a paste-able usage snippet, every variable described (name/type/default/allowed values), every output described, an `examples/` directory with minimal and complete configs, and a changelog entry — otherwise consumers cannot adopt it without reading your source.

Question

Why does `terraform init -upgrade` matter when bumping a module constraint?

Answer

Plan never re-resolves installed modules, and plain init keeps the installed version; `-upgrade` re-resolves providers and modules to the newest versions the constraints allow. Without it the edited constraint looks like it did nothing — then review the plan, because a module bump can reshape infrastructure.

Revision Notes

Key Takeaways

  • 1. Private registry modules are addressed as `host/namespace/name/provider` and authenticated per host — token in CLI config or `TF_TOKEN_*`, never in the source
  • 2. HCP Terraform and Terraform Enterprise publish semver versions from linked VCS tags, giving consumers constraint syntax and `init -upgrade` resolution
  • 3. With no registry, git tags are poor man's versions: one literal `?ref=` per consumer, no constraint matching, no discovery, no rendered docs
  • 4. A publishable module ships documentation first: usage snippet, described variables and outputs, examples, changelog — that is the adoption interface
  • 5. Module upgrades follow one loop: bump constraint, `terraform init -upgrade`, read the plan; plain plan never re-resolves what is installed

Interview Tips

  • • "How do you share modules across teams?" — private registry on HCP Terraform/Enterprise with `host/namespace/name/provider` and pinned constraints; then give the no-registry fallback (tagged git) and name exactly what it costs
  • • "Where do you keep the registry token?" — CLI credentials file or `TF_TOKEN_app_dot_terraform_io` environment variable; say why it must never be in the source address or the repo
  • • "A module upgrade broke prod — what changed in your process?" — constraints are dependencies: bump in its own PR, `terraform init -upgrade`, and treat the plan diff as the release note reviewers must read
  • • "How do you make a module adoptable?" — README paste-snippet, described inputs/outputs, minimal and complete examples, changelog; mention that descriptions are the docs consumers actually read

Cheat Sheet

Publishing to a Private Module Registry

Source addresses:

# HCP Terraform / Terraform Enterprise private registry
source  = "app.terraform.io/acme-corp/vpc/aws"   # host / namespace / name / provider
version = "~> 1.4.0"                              # constraint → init picks the match

# No registry? tagged Git
source = "git::https://github.com/acme/tf-modules.git//vpc?ref=v1.4.0"

Auth (never in the source string):

credentials "app.terraform.io" { token = "..." }   # ~/.terraformrc or %APPDATA%/terraform.rc
TF_TOKEN_app_dot_terraform_io="$TOKEN"             # CI: dots → underscores

Publish checklist: README usage snippet · every variable described · every output described · examples/ (minimal + complete) · CHANGELOG · then git tag -a v1.4.0 && git push origin v1.4.0.

Semver for modules: fix = patch · new input with default / new output = minor · renamed input, removed output, tightened validation, dropped provider support = major.

Consumer upgrade loop: edit constraint → terraform init -upgrade → terraform plan → review diff → apply. Resolution recorded in .terraform/modules/modules.json (generated — never committed).

CI failure table:

Error Cause Fix
Module not installed init skipped (empty .terraform) terraform init -backend=false first
Module not found + host bad 4-segment address fix host/namespace/name/provider
401 Unauthorized no token for host in runner set TF_TOKEN_* secret
drifted code, same ref branch ref moved pin tag or SHA

Continue Learning