Skip to main content

GitHub As Your Terraform Engine (Part 3)

·1613 words·8 mins
Mattias Fjellström
Author
Mattias Fjellström
Cloud architect · Author · HashiCorp Ambassador · Microsoft MVP
Don’t rely solely on the public Terraform registry: build a private registry as part of your Terraform landing zones on GitHub.

You and your organization are transitioning from a paid Terraform automation platform to GitHub1.

You have followed the guidance in this blog series and implemented Terraform landing zones on GitHub. Landing zones come with git repositories, OIDC authentication to target platforms, GitHub Actions workflows for Terraform, and custom properties and rulesets to enforce governance and standardization across every landing zone. This is not rocket science and this does not require the latest AI models to achieve, this is straightforward and easy.

A missing piece in this setup is a private registry.

Your organization has built a number of private modules that you want to share with your Terraform consumers. Sharing modules directly from git repositories works, but is not ideal.

Repositories sourced from a git repository does not support the version argument and thus does not support version constraints. Instead you have to do something like this:

module "resource_group" {
  source  = "git::https://github.com/unico/terraform-azurerm-vnet.git?ref=v1.0.0"
}

Each module block is locked to a single module version unless you explicitly update it to a new version. Version constraints are not supported.

Introducing a private registry gives you the same benefits as for the public registry, only available to your organization.

Info

In this blog post I will build a private registry hosted on registry.tfmulti.cloud. If you build your own registry following this post you can use any domain you have administrative access to.

A private registry must implement two protocols:

  • The remote service discovery protocol.
  • The module registry protocol.

A private registry that supports private providers must also implement the provider registry protocol. My sample implementation will not include providers.

The code discussed in this and the previous part is available here:

The remote service discovery protocol
#

The service discovery process begins by Terraform querying the initial discovery URL https://registry.tfmulti.cloud/.well-known/terraform.json.

The response from this URL should indicate what services this host implements. My private registry only implements modules. The response body from the discovery URL is:

{
  "modules.v1": "/terraform/modules/v1/"
}

The value of the modules.v1 field is the path where the module registry protocol is implemented. This does not have to follow a particular structure, and it does not even have to be located on the same hostname.

My module registry is available on https://registry.tfmulti.cloud/terraform/modules/v1/.

You can see my sample implementation of the discovery URL here.

The module registry protocol
#

Terraform uses the module registry protocol to fetch metadata for modules published to your private registry.

Modules published to a registry is uniquely identified by a composite string /namespace/name/system:

  • namespace is an organization name or similar under which the modules are published. For a private registry this could be a team name or business unit.
  • name is a descriptive name of the cloud resource abstraction that this module is implementing.
  • system is typically the main Terraform provider used in the module.

Each unique module can be published in multiple versions.

The module registry protocol must implement the following two endpoints:

  • GET :namespace/:name/:system/versions to list all published versions of a unique module.
  • GET :namespace/:name/:system/:version/download to get the location where the specified module version can be downloaded. This endpoint does not directly return the module, only a pointer to where the source code can be found.

Full details of the module registry protocol is available in the official documentation.

I want to include an additional endpoint for my private registry:

  • POST :namespace/:name/:system/:version to publish a new version of a unique module.

See my sample implementation of this protocol here.

Build a private Terraform registry on Azure
#

You can implement a private registry in any number of ways. In essence there should be an API component and a data storage component.

A simple and cheap option is to use Azure Functions with a Cosmos DB database to store the module metadata2.

The infrastructure of my private registry consists of:

  • An Azure Functions app implementing the service discovery protocol, the module registry protocol, and my additional endpoint to publish new module versions.
  • A Cosmos DB account, database, and container. The container uses a hierarchical primary key consisting of /namespace/name/system. I use the id property of each item as the module version.
  • A user-assigned managed identity and required role-assignments to interact with Azure services.
  • A custom hostname (a DNS CNAME record)
Note

When implementing my sample private registry I discovered a few (possible) bugs with the Python programming model for Azure Functions. One of these bugs was that some bindings for Cosmos DB were not compatible with Entra ID authentication (avoiding connection strings for Cosmos DB), while others were compatible. In the end I opted for using a connection string for all Cosmos DB bindings.

Note

My sample implementation does not include authentication. This is a clear drawback, but this is a sample implementation after all. If you want to implement authentication you need to build your own Terraform credentials helper. Read about credentials helpers in the official documentation.

See how I configured this infrastructure here.

Introduce a landing zone for a private module
#

To simplify how you create new Terraform modules you can package a module into a similar construct as we did for Terraform landing zones on GitHub.

A platform team initializes a new Terraform module by using a dedicated Terraform module module (I hope it is not too confusing!). An example of creating a new module repository for an Azure resource group module is shown below:

module "azurerm_resource_group" {
  source = "./modules/terraform-module-azurerm"

  github_owner                 = var.github_owner
  module_registry_protocol_url = local.module_registry_protocol_url

  namespace = "umbrella-security"
  name      = "resource-group"
  system    = "azurerm"
}
Note

I use the namespace umbrella-security. This is a fictitious company I invented.

This module sets up:

  • A new GitHub repository for the Terraform module.
  • A starter Terraform configuration.
  • A dedicated OIDC workload identity connection to Azure.
  • A GitHub Actions workflow for publishing new versions to the private registry.
  • Another GitHub Actions workflow for running module tests.
  • The required connection details for where to find the private registry.

See the module source code here.

Publishing modules to your private registry
#

A module landing zone comes with boilerplate testing code using the Terraform test framework. Opening up a PR targeting the main branch will trigger the test workflow:

on:
  pull_request:
    branches:
      - main

See the full workflow definition here and learn more about the Terraform testing framework here:

Once a change is approved and merged to the main branch you can create a new release tag:

$ git tag -a v1.0.0 -m "Release v1.0.0"
$ git push origin v1.0.0

This version tag pattern triggers another GitHub Actions workflow:

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"

This workflow creates a GitHub release based on this version tag and publishes metadata for the release to the private registry. See the full workflow definition here.

Consuming modules from your private registry
#

I can use my private registry in the same way I use the public registry, but I have to explicitly specify the hostname in the source argument (otherwise it would default to registry.terraform.io):

module "resource_group" {
  source  = "registry.tfmulti.cloud/umbrella-security/resource-group/azurerm"
  version = "~> 1.0"

  suffix   = "platform-networking"
  location = "swedencentral"
}

Run terraform init with trace logging and you will see how the interaction with your private registry happens (the output is truncated to show a few of the relevant logs):

$ TF_LOG=trace terraform init
2026-01-26T06:32:27.041+0100 [TRACE] ModuleInstaller: resource_group is a registry module at registry.tfmulti.cloud/umbrella-security/resource-group/azurerm
2026-01-26T06:32:27.041+0100 [DEBUG] Service discovery for registry.tfmulti.cloud at https://registry.tfmulti.cloud/.well-known/terraform.json
2026-01-26T06:32:27.340+0100 [TRACE] HTTP client GET request to https://registry.tfmulti.cloud/terraform/modules/v1/umbrella-security/resource-group/azurerm/versions
2026-01-26T06:32:27.748+0100 [DEBUG] found available version "1.0.0" for
Downloading registry.tfmulti.cloud/umbrella-security/resource-group/azurerm 1.0.0 for resource_group...
2026-01-26T06:32:27.748+0100 [DEBUG] looking up module location from "https://registry.tfmulti.cloud/terraform/modules/v1/umbrella-security/resource-group/azurerm/1.0.0/download"
2026-01-26T06:32:27.749+0100 [TRACE] HTTP client GET request to https://registry.tfmulti.cloud/terraform/modules/v1/umbrella-security/resource-group/azurerm/1.0.0/download
2026-01-26T06:32:28.158+0100 [TRACE] ModuleInstaller: resource_group registry.tfmulti.cloud/umbrella-security/resource-group/azurerm 1.0.0 is available at "https://github.com/mattias-fjellstrom-org/terraform-umbrella-security-resource-group-azurerm/archive/refs/tags/v1.0.0.tar.gz"
2026-01-26T06:32:28.158+0100 [TRACE] getmodules: fetching "https://github.com/mattias-fjellstrom-org/terraform-umbrella-security-resource-group-azurerm/archive/refs/tags/v1.0.0.tar.gz" to ".terraform/modules/resource_group"
2026-01-26T06:32:29.193+0100 [TRACE] ModuleInstaller: resource_group "https://github.com/mattias-fjellstrom-org/terraform-umbrella-security-resource-group-azurerm/archive/refs/tags/v1.0.0.tar.gz" was downloaded to .terraform/modules/resource_group

From the logs we can tell it interacts with the different endpoints of the service discovery protocol and module registry protocol.

Key takeaways
#

There was one big missing piece from my implementation of Terraform landing zones on GitHub: a private module registry.

A Terraform registry allows you to publish modules in different versions and use version constraints in your Terraform code. It is also easier for Terraform consumers to consume modules from a registry instead of consuming them directly from a GitHub repository.

A private registry must implement the service discovery protocol that tells Terraform which services this registry implements (e.g. modules and providers). To host modules the registry must implement the module registry protocol. These two protocols consists of three endpoints in total.

You can extend the private registry with additional features. In this post I added an endpoint for publishing new module versions.

Building and hosting your own private registry is easy. Similar to the rest of the details I have covered around building your own Terraform automation platform on GitHub: this is not rocket science!


  1. You are, of course, paying for GitHub as well - but that is a relatively minor cost and one that you would have either way. ↩︎

  2. On AWS you can use one or more Lambda functions together with a Dynamo DB table. ↩︎

Related