Terraform GitHub Provider: How to Manage Your Org as Code
A real GitHub organization has hundreds of repositories, a dozen teams with overlapping permissions, and a CI/CD pipeline that needs its own credentials. The Terraform GitHub provider was built to handle that scale once set up correctly.
GitHub is effectively an API with a web interface on top. That interface starts failing teams well before the API does. Click through enough repositories, teams, and branch rules by hand, and you will eventually end up with three teams that have almost the same name, a repository nobody remembers granting push access to, or no idea which of your thirty repositories actually enforces review before merge.
The same discipline that already makes cloud infrastructure as code (IaC) useful applies directly to GitHub itself.
The GitHub Terraform provider (published to the registry as integrations/github) turns that entire surface – repositories, teams, branch rules, GitHub Actions secrets, even organization membership – into a configuration you can read, review, and apply the same way you already manage your cloud resources.
It is a genuinely large provider (over a hundred resources and data sources covering most of the objects a GitHub user account or organization can own, at the time of writing), and most of its value comes as you scale and use it as a system of record for an entire GitHub organization.
This guide starts with provider setup and authentication (including the GitHub App installation path and GitHub Enterprise Server) and with reading organization data before you start changing it.
From there, it moves to managing repositories at the scale for_each enables, rather than one resource block at a time, running the plan and apply workflow against them, applying branch protection uniformly, and controlling which teams can touch which repository.
The last part covers a few GitHub-specific mistakes that catch experienced engineers off guard, the GitHub Actions side of the equation (secrets and a working CI/CD pipeline), and how to keep Terraform state secure once GitHub itself is the system doing the automating.
Setting up the Terraform GitHub provider
The current integrations/github provider is at major version 6, and the required_providers block should reflect that rather than an older pin. The following code snippet is the whole of it:
Versions 5 and 6 each shipped breaking changes, and the provider has kept changing within the 6.x line too: 6.12.0 renamed the arguments that hold secret values, and 6.13.0 replaced the client the provider uses to talk to the GitHub API.
If a configuration was written against an older major version, bumping required_providers is the easy part. Re-reading the resources you use against the current documentation is the part worth budgeting time for, since a silently renamed or removed argument fails at plan time, not init time.
Compatibility is the thing that quietly breaks when a major version bump gets skipped for a year or two.
The provider supports two main ways to authenticate, a token or a GitHub App installation, and the two are mutually exclusive, so you configure one or the other. If neither is set, the provider falls back to the GitHub CLI’s stored credentials (gh auth token), and if that isn’t available either, it runs in anonymous, read-only mode.
The first two cover almost every real setup; the GitHub CLI fallback mostly helps local development, where an engineer already authenticated with gh shouldn't also need a separate token just to run terraform plan.
Authenticating with a personal access token
The simplest path, and still a sensible one for a small setup or a personal account, is a token. To authenticate using a GitHub OAuth or personal access token, the provider reads the token argument or the GITHUB_TOKEN environment variable:
Treat the environment variable as the default, not the hardcoded string in the provider block the way older examples show it.
GitHub itself now recommends fine-grained personal access tokens over the classic variety: a fine-grained token is scoped to a single organization or user account, can be limited to specific repositories, and grants specific permissions instead of broad scopes like repo or admin:org. A classic token, once created, can reach every repository the issuing user account can reach, which is a lot of blast radius for a credential sitting in a CI secret store.
Authenticating with a GitHub App installation
For anything running unattended, in a pipeline, or at Enterprise scale, a GitHub App installation is the stronger choice over a personal token, and for good reason: a GitHub App’s access doesn’t evaporate when the engineer who created the token leaves the company.
The pem_file argument is the GitHub App's private key, the actual contents of the .pem file GitHub generates when you create the app, not a path to it.
Store that string in a secrets manager or a CI secret, never in version control, and pass it into the pem_file argument or the GITHUB_APP_PEM_FILE environment variable at runtime.
One real challenge you may encounter: under GitHub App authentication, github_repository needs the App to have contents:write permission even to read a repository’s settings.
Without it, the merge settings arguments (such as allow_merge_commit, allow_squash_merge, and allow_rebase_merge) are silently ignored rather than erroring, which shows up as confusing diffs.
Check the App’s permissions against the resources you manage before assuming everything behaves identically under both auth methods.
GitHub Enterprise Server and other base URLs
If the organization runs GitHub Enterprise Server, or GitHub Enterprise Cloud with data residency, the provider needs to know that explicitly through base_url:
Leave it unset against github.com. Get it wrong against a self-hosted instance, and every request fails at the API layer, which looks like an authentication problem and could waste an afternoon before anyone checks the URL.
Reading organization data before you manage it
Before writing resources that change anything, it's worth pulling what already exists. The github_organization data source returns the organization's repositories, members, and settings in one call:
The name argument takes the organization's login, the slug used in URLs and API requests, not its display name. Pass summary_only = true when you only need the organization's settings and don't want the provider pulling every repository and user account on each terraform plan, which is an important consideration once an organization gets into the hundreds of repositories and a plan that reads all of them starts adding real seconds to every pull request.
Managing GitHub repositories at scale
A single github_repository resource looks like this:
visibility is the current argument (it accepts public, private, or internal for Enterprise accounts); the older private boolean still works, but visibility takes precedence if both happen to be set, so pick one and keep every repository's configuration consistent about it.
The resource also still accepts a pages block for GitHub Pages, though it's deprecated in favor of the dedicated github_repository_pages resource (worth knowing if an older configuration still carries it).
When using Terraform GitHub, the approach that scales to multiple repositories is for_each over a map, which turns the single resource above into as many repositories as the organization needs, each with its own description, from one configuration.
This block replaces the single sre_scripts resource rather than sitting alongside it, since both would try to create a repository called sre-scripts:
Add a repository to the organization by adding a line to local.repositories, not by writing a new resource block.
Once implemented in this way, every Terraform-managed repository in the project traces back to one source of truth instead of a settings page nobody remembers editing. The Terraform configuration becomes the list of what should exist, and terraform plan tells you the moment reality drifts from that list, when you're managing GitHub repositories like this.
Running the plan and apply workflow
The workflow itself doesn't change just because the resources are GitHub instead of cloud infrastructure:
Get into the habit of reading plan output line by line instead of skimming for the summary: every time you run terraform plan, that output is the only chance to verify the change matches intent before anything is actually created.
Sample console output from a plan against the for_each repository example above looks like this (illustrative, not copied from a real run):
For a repository that already exists outside Terraform's state, the terraform import command brings it under management without recreating it, which counts for more here than in most providers: a GitHub organization that's three years old almost always has repositories, teams, and branch rules that predate the Terraform configuration files entirely, and recreating them from scratch isn't an option.
Read the import details in the command's output as carefully as a plan before assuming it landed cleanly.
Protecting branches and enforcing review rules
Creating a repository is only part of what needs to be done. github_branch_protection enforces the rules that keep changes to it reviewed:
repository_id takes the repository's node ID (or its name, which the provider resolves for you), and looping it over the same for_each map used to create the repositories means every one of them gets identical review rules without retyping the block.
Read the execution plan for a change like this closely: required_status_checks.contexts has to match the check name a CI workflow actually reports (the job’s name, or its ID if it has no name), and a mismatch produces a branch protection rule that silently never passes, not an error. Every repository in the loop also needs a workflow that reports that check on every pull request, or merges to main stay blocked.
On newer organizations, rulesets cover similar ground: github_repository_ruleset applies rules to a single repository, and github_organization_ruleset applies them across many repositories at once, which branch protection alone can’t express.
Managing teams and who can touch which repository
Access at any real scale runs through teams, not individual collaborators added one at a time. Keeping the running example from earlier, a team that needs push access to the repositories it owns looks like this:
That's github_team_membership, which controls who's on a team and whether they're a member or a maintainer of it.
It's a different resource, and a different idea, from github_membership, which controls whether a user account is in the organization at all:
An organization's role is member or admin (GitHub's UI calls the latter "owner"). A team's role is member or maintainer.
The two resources, and the two role vocabularies, aren't interchangeable, and managing access through role-based permissions gets genuinely confusing at scale if a configuration conflates them.
Destroying github_membership either cancels a pending invitation or removes the user outright, so running terraform destroy (or dropping an entry from a for_each map) to delete someone's access has an immediate, real effect on GitHub, not just a change in state.
A few GitHub-specific mistakes to avoid making
Most of the mistakes that show up when you are managing GitHub as code aren't Terraform problems. They're GitHub quirks that a declarative configuration makes visible instead of letting slide.
CODEOWNERS is the one that catches the most people out. GitHub's own documentation is explicit that a team listed in a CODEOWNERS file must have explicit write access to the repository, even if every individual member of that team already has access through organization membership or another team.
A configuration that creates the team and references it in CODEOWNERS but never grants that team a github_team_repository entry on the repository in question will pass terraform plan cleanly and then simply fail to request the review GitHub was supposed to enforce, with no error anywhere in the pipeline to point at.
Conflating organization ownership with team membership, covered above, causes a quieter version of the same kind of surprise: a terraform plan that keeps wanting to "fix" someone's role back to maintainer because the configuration set them as a team member when GitHub's API has already decided, correctly, that an organization owner can't hold that status.
If more than one person has console access, and someone changes a branch rule or a team's repository list by hand in the GitHub UI during an incident, the next terraform plan reports drift instead of a clean run. That's not a bug; it's the point of running this as code. However, it only pays off if the team actually looks at drift reports instead of treating a dirty plan as background noise.
Setting up a Terraform GitHub Actions pipeline
Everything above runs locally just as well as it runs in CI, which is where most organizations actually want it running. github_actions_secret manages the credentials a pipeline needs:
The workflow that consumes it is a normal GitHub Actions workflow file, not a Terraform resource; there's no dedicated resource for a workflow itself, because a workflow is a YAML file in .github/workflows/, not an API object the provider can address.
A realistic version, running a plan on every pull request against an OIDC role rather than a long-lived AWS key, looks like this:
If you are wondering how you integrate Terraform with GitHub for deployments, that's a solution. A pull request triggers a plan; the plan runs against short-lived, OIDC-issued credentials instead of a static key sitting in a secret indefinitely, and the actual apply step (left out here deliberately) belongs on merge to the default branch, gated behind the same required reviews the branch protection resource above already enforces.
The best Terraform GitHub workflow for automated infrastructure changes is rarely the most intelligent one. It's the one where a human looked at the plan before anything applied, every time, without that review becoming a bottleneck.
Keeping Terraform state secure when GitHub is doing the automating
Running Terraform from GitHub Actions solves the problem of knowing who applied it and when, creating a narrower one: the pipeline that can apply infrastructure changes now needs credentials with real permissions, sitting somewhere a compromised workflow or a malicious pull request from a fork could potentially reach.
OIDC federation (the id-token: write permission and aws-actions/configure-aws-credentials in the example above, or the equivalent action for another cloud) avoids storing long-lived cloud credentials as GitHub secrets at all; the pipeline requests a short-lived token at runtime instead.
GitHub Environments with required reviewers add a manual gate specifically on the apply job, separate from the pull request review on the code itself, so a merge doesn't automatically mean a cloud change goes out unattended.
The state backend itself, whether that's an encrypted S3 bucket, HCP Terraform, or a graph-backed system like Stategraph, needs access controls that are at least as strict as the GitHub repository permissions guarding the configuration that reads it, because state frequently contains sensitive values that never appear in the HCL itself.
None of that is really about the GitHub provider specifically. It's the same discipline that keeping Terraform state consistent already demands, applied to a pipeline where a misconfigured permission can now trigger a change automatically instead of waiting for someone to run terraform apply from a laptop.
A locked state file that an interrupted GitHub Actions run leaves behind is a familiar version of the same failure mode: the automation has to be at least as careful about how it fails as the human workflow it replaced.
This is also where Stategraph Orchestration fits naturally into a Terraform GitHub setup. It runs plans and applies directly inside GitHub pull requests, on the organization’s own GitHub Actions runners rather than a separate hosted runner, with policy checks and the required approvals enforced before an apply goes out, whether that’s triggered by an apply comment on the pull request or by a merge.
It doesn't replace the Terraform configuration covered throughout this guide; it runs it, with the review and audit trail that a GitHub organization at real scale eventually needs anyway.
Conclusion
The Terraform GitHub provider was never designed as a one-repository, one-team solution. It's designed for the version of GitHub an Enterprise platform team actually runs: hundreds of repositories, teams whose membership changes every quarter, branch rules that need to be identical across every service, and a CI/CD pipeline that has to be trusted with real credentials.
for_each, a GitHub App installation instead of a personal token, and branch protection applied uniformly are what make that version manageable instead of a weekly source of drift and surprises.
Getting the GitHub side right still leaves the state and pipeline security question open, and that's the part worth solving deliberately rather than inheriting by accident.
If a PR-based workflow with policy checks and resource-level visibility into what's about to change is the piece missing from the current setup, try Stategraph free or read the documentation to see how it fits on top of the Terraform and GitHub Actions already in place.
Terraform GitHub FAQs
Can Terraform create a GitHub Actions workflow file directly?
Not through a dedicated resource, because a workflow is a YAML file rather than an API object GitHub exposes for the provider to manage. The common pattern is github_repository_file, which commits the workflow file to .github/workflows/ the same way it would commit any other file, keeping the workflow under Terraform management without a provisioner.
What's the difference between github_membership and github_team_membership?
github_membership controls whether a user account belongs to the GitHub organization at all, with a role of member or admin.
github_team_membership controls whether that user belongs to a specific team inside the organization, with a role of member or maintainer. A user needs the first to be in the organization and the second to be on any particular team; neither resource implies the other.
Should I use a personal access token or a GitHub App for Terraform?
A fine-grained personal access token is reasonable for a personal account, a small setup, or local testing. For anything running unattended in CI, or at Enterprise scale, a GitHub App installation is the documented, recommended path, because its access is tied to the app rather than to whichever engineer generated the token, and it doesn't quietly stop working the day that person leaves.
How do I manage Terraform state securely using GitHub?
Keep long-lived cloud credentials out of GitHub Actions entirely in favor of OIDC federation, add required reviewers on the environment that runs apply so a merge doesn't trigger an unattended change, and make sure whatever backend holds the state (an encrypted object store, HCP Terraform, or a graph-backed system like Stategraph) is locked down at least as tightly as the repository permissions protecting the configuration itself.