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

If your platform team publishes shared Terraform modules, you’ve probably run into the same problem eventually: how do you let other repositories pull those modules in CI without handing out a personal access token tied to someone’s GitHub account? PATs work, but they’re a liability — they expire unpredictably, they’re bound to a human, and when that person leaves the team, your pipelines break.

The fix is a GitHub App. It gives your automation its own non-human identity, with scoped permissions and short-lived tokens minted fresh on every workflow run. Here’s how to set the whole pattern up, from module publishing through to consumption in Actions.

Who this is for

This guide is for platform teams that:

  • Publish reusable Terraform modules in a private GitHub Enterprise repository
  • Consume those modules from other private repositories
  • Want CI authentication that isn’t tied to personal accounts

The goal

By the end, you’ll have:

  1. A module producer repository that publishes pinned module versions
  2. Consumer repositories that download those modules in GitHub Actions
  3. Authentication handled by a GitHub App with short-lived tokens
  4. Azure authentication for Terraform handled via OIDC

Part 1: Publish modules in a dedicated repository

Structure your module repo like this:

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

Always pin module usage to immutable tags — never a moving ref like main:

module "example" {
source = "git::https://<your-ghe-host>/<org>/<module-repo>.git//modules/terraform_azurerm_<module_name>?ref=v1.1.3"
}

A few publishing practices that save you pain later:

  • Use semantic version tags
  • Never consume modules from moving refs
  • Keep your examples runnable and validated, not just illustrative

Part 2: Why a GitHub App beats a PAT

A PAT represents a user. Whoever generated it, the token acts as them — with their permissions, tied to their account’s lifecycle.

A GitHub App gives you something better:

  • A non-human identity built for automation
  • Permissions scoped to specific repositories
  • Short-lived installation tokens, minted fresh at workflow runtime

The result: no long-lived, user-bound credentials sitting in your CI secrets.

Part 3: Create and install the GitHub App

Create the app in your GitHub Enterprise 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 (<org>/<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

Part 4: The consumer 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

Part 5: 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 cleanly for both test and prod, where applicable

Part 6: 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

This pattern — pinned module versions, a purpose-built GitHub App identity, and short-lived tokens minted at runtime — gives teams a secure, repeatable way to scale Terraform module consumption across repositories, without a single personal credential in sight.

Leave a comment