Building Reusable Terraform Modules and Consuming Them Securely with a GitHub App

By Martin Fahlbeck Aug 12, 2026

If you’ve published a private Terraform module and tried to consume it from a GitHub Actions workflow, you’ve probably hit this exact wall: from your own machine, everything works fine. You’re authenticated to GitHub, so git clone or terraform init can reach the private module repo without any trouble.

But the moment that same terraform init runs inside a GitHub Actions runner, it fails. The runner has no identity of its own — it isn’t you, it isn’t logged in, and it has no credentials to reach a private repository outside the one it’s already checked out. That’s the actual problem to solve: giving the workflow itself a way to authenticate to your private module repo.

Example repository

To make this concrete, I put together a small example repo: marfha88/Terraform-module. It contains a couple of reusable Azure Terraform modules (a resource group and a storage account), versioned with Git tags, plus the CI/release workflows and docs referenced throughout this post. Feel free to use it as a reference while reading along.

Structuring and versioning your module

Before worrying about authentication, it’s worth getting the module repo itself set up properly. A layout like this works well:

modules/terraform_azurerm_<module_name>
examples/terraform_azurerm_<module_name>
docs/MODULE-CONSUMPTION.md
docs/MODULE-PUBLISHING.md

The key practice is versioning with semantic version tags (v1.0.1, for example) and always pinning consumers to a specific tag rather than a moving ref like main. That way a change to the module never silently breaks something already consuming it:

hcl
module "example" {
  source = "git::https://github.com/marfha88/Terraform-module.git//modules/terraform_azurerm_resource_group?ref=v1.0.1"
}

In the example repo, a single Git tag applies to all modules at once — so cutting a release is as simple as:

git tag v1.1.0 -m "Add Storage Account module"
git push origin v1.1.0

That tag push triggers a release workflow that publishes a GitHub Release with auto-generated notes. Consumers then upgrade by bumping the ?ref= value and running terraform init -upgrade.

A few habits worth keeping:

  • Tag every release with semantic versioning
  • Never let consumers reference main or any other moving branch
  • Keep the examples/ directory runnable and actually validated, not just illustrative

A quick note on authentication

You could authenticate the workflow with a personal access token (PAT), but we used a GitHub App instead — mainly because it’s not tied to a person’s account and it mints short-lived tokens on every run instead of relying on a long-lived secret. Either approach can work; the GitHub App is just the more robust option for CI.

Create and install the GitHub App

Create the app in your GitHub organizations settings with the minimum permissions it actually needs:

  • Repository Contents: Read-only
  • Repository Metadata: Read-only

Install it only on the repositories that need it — for module consumption, that’s your module repo.

Generate a private key and store both values as secrets in the consumer repository:

  • GHE_MODULES_APP_ID
  • GHE_MODULES_APP_PRIVATE_KEY

Two things that trip people up here:

  • The App ID must be numeric
  • The private key must include the full PEM content, BEGIN and END lines included

The workflow pattern

In your consumer workflow, mint a GitHub App token and rewrite git URLs before terraform init runs:

- name: Create GitHub App token for private modules
  id: ghe_app_token
  if: ${{ env.GHE_MODULES_APP_ID != '' && env.GHE_MODULES_APP_PRIVATE_KEY != '' }}
  uses: actions/create-github-app-token@v1
  with:
    app-id: ${{ env.GHE_MODULES_APP_ID }}
    private-key: ${{ env.GHE_MODULES_APP_PRIVATE_KEY }}
    owner: ${{ github.repository_owner }}
    github-api-url: ${{ github.api_url }}

- name: Configure git for private modules
  if: ${{ steps.ghe_app_token.outputs.token != '' }}
  run: |
    git config --global url."https://x-access-token:${{ steps.ghe_app_token.outputs.token }}@<your-ghe-host>/".insteadOf "https://<your-ghe-host>/"

From there, Terraform runs exactly as it would otherwise:

terraform init
terraform validate
terraform plan
terraform apply  # where relevant

Validating the setup

After you’ve wired this up, check your workflow logs for:

  • Successful GitHub App token creation
  • Successful module download from the private module repository
  • Terraform plan running

Troubleshooting

If module authentication fails, work through this checklist:

  1. Is the app installed on the module repo?
  2. Does it have Contents and Metadata read permissions?
  3. Are GHE_MODULES_APP_ID and GHE_MODULES_APP_PRIVATE_KEY actually present in the workflow environment?

Wrapping up

The core issue was simple once we named it: our own accounts could reach the private module, but the GitHub Action runner couldn’t. Giving the workflow its own scoped, short-lived identity — plus properly versioned, pinned module releases like the ones in marfha88/Terraform-module — closed that gap for good.

Author


Discover more from Agder in the cloud

Subscribe to get the latest posts sent to your email.

Leave a Reply