21STARK
All posts
14 min read views

My GitHub org is a 273-line Terraform plan

Every toggle in your GitHub org settings is a production change with no plan, no diff, no reviewer. So the governance layer became code: 273 lines of HCL, two isolated states, import-first, hardened in 47 minutes, with a $30/month near-miss caught in review.

273 lines of HCL. Twelve .tf files. Five resources, one data source, four import {} blocks. That's the entire control plane for my personal GitHub estate: one enterprise, one org, 45 repos (40 private, 5 public), and exactly one human member.

It went from empty directory to hardened posture in 47 minutes. Seven commits and four PRs, all on the morning of 2026-07-11, between 11:14 and 12:01 UTC. The same morning the org got renamed. That timing matters, and I'll get to it.

First, precision, because the obvious reading of the title oversells it. Terraform here does not manage repos. It doesn't manage branch protection. teams.tf and apps.tf contain zero resources. What it manages is the layer above all of that: org and enterprise governance. The org settings singleton. Actions policy at both tiers. Org membership. Enterprise security-analysis defaults. The small set of switches that define the blast radius of everything underneath.

That layer is tiny. It's also the layer nobody reviews.

Governance by clicking is production changes without a diff

Comic panel of a hand casually flipping a large wall-mounted toggle switch while a wave of color sweeps across a grid of dozens of identical small buildings behind it

Every toggle in your GitHub org settings page is a production change. No plan. No diff. No reviewer. The audit log records what you did after the fact; nothing gates it before. And unlike a bad deploy, a flipped org-level bool doesn't page anyone. It just sits there, silently rewriting policy for every repo in the fleet, until it bites.

Here is how mine got wide open. Between 2026-06-18 and 2026-07-03 I ran about ten migration waves, moving the whole stark-* fleet from my employer's GitHub org into a personal one. Full authorship rewrite each time, git filter-repo --mailmap from fresh mirrors. The biggest single repo was 1,064 commits across 48 branches with 5 identities normalized. Mid-migration I found a committed tfplan file embedding Terraform state with plaintext generated passwords. Untrack, rotate, purge blobs. Lesson absorbed.

At the end of that month I had a 44-repo org (45 today) whose CI authenticates to production GCP via Workload Identity Federation. And the org holding all of it had the posture you get from clicking "create organization" and moving on: 2FA enforcement off, no Dependabot, no secret scanning, Actions allowed to run anything from anywhere. The header comment in org/settings.tf says it straight: that's weak for the repo hosting the whole stark-* fleet.

The settings were load-bearing and invisible. So on 2026-07-11, the same day I renamed the org (the rename survived because Workload Identity Federation was keyed to the org ID, 294881062, not the slug; org-level API calls don't redirect, so slug-keyed anything would have broken), I wrote the governance down as code.

Two tiers, two states

Flat vector illustration of two glass bell jars on a workbench, each sealing its own small machine, with an orange key hanging beside one jar and a blue key beside the other, and a cut, capped pipe between them

The repo is 21stark, and its structure is the first decision worth stealing. GitHub gives you two scopes with different owners, different tokens, and different failure modes. So: two directories, two completely separate Terraform states.

DirManagesTokenCadence
enterprise/Enterprise 21Stark: Actions policy, security-analysis defaultsclassic PAT, admin:enterpriserare
org/Org 21StarkCom: settings singleton, Actions policy, membersclassic PAT, admin:orgthe bulk

Both states live in the same GCS bucket under different prefixes (github/21stark/enterprise and github/21stark/org). The ADR rejected a single flat state explicitly, and the reasoning is one sentence: "an enterprise apply could then break org membership." State isolation is blast-radius isolation. An apply in one tier physically can't touch the other tier's resources.

Policies cascade downward: what the enterprise enforces, the org can't override. So the enterprise tier holds the defaults that must never regress, and the org tier holds the day-to-day.

Two more deliberate choices:

  • Token asymmetry is real, not theoretical. My default gh keyring token is read:org only. It can plan-read basic org settings but can't read Actions policy or apply anything. The org tier needs gh auth refresh -s admin:org. The enterprise tier needs a separate classic PAT that lives in the macOS Keychain. Escalation is a conscious act, per tier.
  • Local-apply only, no CI. Deliberate: governance changes are rare and sensitive. No CI apply until it earns it. I have solved the self-referential WIF deadlock twice during the migration (the CI that applies Terraform authenticates through the very binding the apply rewrites). I'm in no hurry to build a third instance of that puzzle for the repo that governs everything else.

The provider is integrations/github, constrained ~> 6.0 and locked at v6.13.0 in both tiers' committed lock files. Half the design is about what this provider can and can't manage. More on that below.

Import-first: the org already exists, respect that

This is the core move. The org was live: members, settings, 44 repos, real CI. The one thing a first apply must never do is try to create a member, team, or org that's already there. Terraform is brilliant at converging on desired state, and that's exactly the danger: if your code doesn't match reality, Terraform will happily "fix" reality.

The workflow, per resource:

  1. Write the resource block, mirroring the live values exactly.
  2. Write a matching import {} block next to it.
  3. terraform plan must show import, not create. If it shows anything else, your mirror is wrong. Fix the code, not the org.
  4. Apply. Delete the import block once absorbed.

This is org/members.tf, lightly abridged. It's the complete membership model for the org:

# org/members.tf
resource "github_membership" "member" {
  for_each = var.owners
  username = each.key
  role     = each.value
}

# Import existing memberships (id = "org:username").
import {
  to = github_membership.member["aryeh-stark"]
  id = "21StarkCom:aryeh-stark"
}

One member. Yes, managing a single membership with for_each looks like ceremony. It is not. The shape is the contract: the next member is a one-line diff in a PR, not a click.

The settings singleton is where the discipline gets explicit. This comment sits at the top of org/settings.tf, and it's the most important paragraph in the repo:

# org/settings.tf
# github_organization_settings is a SINGLETON. Every field is authoritative
# once applied. These values MIRROR THE LIVE ORG (fetched 2026-07-11) so the
# first apply after import is a strict no-op. Change nothing here except via
# a deliberate PR. A flipped bool silently rewrites org-wide policy.

The proof is in the commit history: "both tiers plan clean (import-only, 0 change)." That was the acceptance test for the whole scaffold. Adopt first, change second, and never both in the same step.

I call this discipline the zero-diff invariant, and it deserves its own section further down, because it's the single transferable idea here.

The provider ceiling: document what you can't manage

An honest IaC repo states what it doesn't control. The Terraform GitHub provider has a ceiling, and the worst response to a ceiling is scaffolding fake resources that imply coverage you don't have.

What is above the ceiling, and what I did about each:

  • App installations. Terraform can't install a GitHub App. The provider can only manage which repos an already-installed app reaches. So org/apps.tf is 22 lines of pure documentation: the three least-privilege automation apps (Stark Claude 21S, Stark Codex 21S, Stark Gemini 21S) with their installation IDs recorded (141330560, 141330526, 141330618), the install click-op described, and repo-pinning left as a commented example. Zero resources. On purpose.
  • The enterprise tier is thin. SAML, SCIM, billing and seats, spending limits, enterprise rulesets, the org-creation flow: none of it is Terraform-able. All of it is written up as click-ops in enterprise/README.md, and enterprise.tf ends with an explicit "What the provider CANNOT manage" comment block that says do NOT scaffold resources for these. The repo's own instruction: don't pretend otherwise in code.
  • Teams: none. org/teams.tf is a commented scaffold. The fleet is currently flat under a single owner. An empty file that says why it's empty beats a fake team hierarchy.
  • Enterprise metadata is GraphQL-only. The REST route for the enterprise 404s. The provider's data source resolves it via GraphQL, which is why the enterprise token scope matters. This is recorded as a comment where the data source lives, so the next 404 costs nobody an afternoon.

Every one of these is a documented click-op: a manual action that is named, located, and justified inside the repo, instead of being silently absent from it. The repo's coverage map is honest. When someone (future me) asks "is X managed?", the answer is in the code either way.

The hardening morning, including a $30 near-miss

Scaffolding was commits one through three. PRs #1 through #4 were the hardening, same morning. What shipped:

  1. Dependabot plus dependency graph (org tier), ON for new repos. Free on private repos, zero breakage. The cheapest posture win that exists.
  2. Secret scanning plus push protection (enterprise tier, cascades to the org), ON for new repos. Given the tfplan leak story above, this wasn't theoretical for me.
  3. Backfill to the existing fleet. Defaults only cover new repos, so a 41-line idempotent script (scripts/backfill-repo-security.sh, paginated, skips archived) swept the fleet: 32 active repos enabled, 12 archived skipped, 0 failures, 0 secret alerts surfaced.
  4. The cost fix. This one is the reason to review governance like code.

GitHub splits its security products into two separately billed items: Secret Protection at $19 per committer per month, and Code Security at $30 per committer per month. I use the first (1 of 1 seats, solo committer, no overage). I don't use the second (0 seats). The imported enterprise defaults, left alone, would have auto-enabled Code Security on the next repo I created. Not a breach, not an outage. Just a quiet $30/month subscription initiated by a default nobody chose.

PR #4 killed it at the org tier (advanced_security_enabled_for_new_repositories = false). And the enterprise side of that fix carries my favorite comment in the repo, because it documents an API behavior that would otherwise cost every future reader a confused hour:

# enterprise/enterprise.tf
# Code Security ($30): this enterprise flag reflects PRODUCT AVAILABILITY and
# is NOT toggleable off via the API (it reads back true regardless; a false
# here is a perpetual diff). Pinned true to match reality. The COST-CONTROLLING
# switch is the ORG-tier default (advanced_security_enabled_for_new_repositories
# = false in org/settings.tf). That stops new repos from auto-enabling Code
# Security, so committers used = 0 = $0. To truly drop the $30 product,
# cancel it in enterprise billing, not here.
advanced_security_enabled_for_new_repositories = true

Read that twice. The flag is pinned true not because I want the product, but because the API refuses to report anything else, and a false in code would be a diff forever. The actual control lives one tier down. This is the kind of knowledge that evaporates when governance lives in a settings page.

The rest of the API gotchas from that morning, recorded in comments where they were hit:

  • Enabling advanced_security per-repo returns 422 "not available, nor a pre-requisite" under the split billing model. Secret scanning enables directly without it.
  • secret_scanning_validity_checks_enabled doesn't persist at enterprise scope. The API reads it back false no matter what you send. Pinned false to keep the plan clean.
  • 2FA enforcement is read-only in the provider. Not managed, and the file says so (low value at one human member).

Each of these is an hour of debugging that happened exactly once and will never happen again, because the conclusion is committed next to the resource it applies to.

Deferred, loudly

One thing didn't ship that morning: the Actions allowlist. The fleet's CI reaches production GCP through WIF, and Actions policy is still permissive. That's the biggest remaining hole, and the ADR doesn't hide it. It closes with: "The WIF→GCP supply-chain blast radius is NOT yet tightened (Actions still all/all). Tracked as the deferred follow-up."

Two concrete blockers:

  • No action inventory yet. The provider can set the allowlist today: the allowed_actions_config block (github_owned_allowed, patterns_allowed, verified_allowed) on the same resource already sitting in org/actions.tf, under allowed_actions = "selected". What's missing is the input. Building patterns_allowed means inventorying every action the fleet's workflows actually use. Guess and you break CI; wildcard and you've hardened nothing.
  • Flipping sha_pinning_required = true org-wide breaks every workflow using tag refs (@v4) across the whole fleet until each one is rewritten to a SHA. Mass CI breakage. Not a flip, a migration.

This is what deferral should look like: the risk sits in an ADR next to the code, with the reason it's still open on record. A named open risk beats a hidden one every time. The alternative (quietly shipping the flag and torching CI across the fleet, or pretending the gap doesn't exist) is worse on both axes.

The same ADR carries a "What this is not" section: not a retroactive scan, not 2FA enforcement, not an Actions lockdown. Scope stated in the negative is scope you can hold someone to.

The zero-diff invariant

Risograph-style print of a brass balance scale perfectly level, one pan holding a small office building and the other a rolled paper scroll, with the pointer needle at dead center

Here is the named concept, stated properly.

The zero-diff invariant: when adopting infrastructure-as-code over a live system, every step must pass through a state where terraform plan reports zero changes.

Unpack what it forces:

  • Imports show import, never create. A create in the plan means your code disagrees with reality. Reality wins; fix the code.
  • The first apply after import is a strict no-op. You've proven the mirror is faithful before you're allowed to change anything.
  • Perpetual diffs are bugs in your model of reality. When the API refuses a value (reads back true regardless, doesn't persist a field), you pin the code to what the API actually reports and document why. A plan that's never clean trains everyone to ignore the plan, and then the one diff that matters scrolls past unread.

And change is a separate, deliberate step. Adoption and modification never share a commit. First you converge on zero, then you diff on purpose.

The invariant isn't GitHub-specific. It's the only sane way to put Terraform (or anything declarative) over any brownfield system: a DNS zone, a Kubernetes cluster someone built by hand, an IAM policy archaeology site. The plan output is your only trustworthy instrument, and it's only trustworthy if zero means zero.

Steal this

If you own an org, personal or corporate, the playbook is small:

  • Start with governance, not repos. The settings singleton, Actions policy, membership, security defaults. Highest blast radius per line of HCL by a wide margin. Repos can come later or never.
  • Two tiers, two states. Match your state boundaries to your token boundaries, so an apply physically can't exceed the credential that runs it.
  • Hold the zero-diff invariant. Mirror live, import, prove the no-op, then and only then change things.
  • Document the ceiling. Empty scaffolds with honest comments, click-ops written down next to the resources they neighbor. Coverage lies are worse than coverage gaps.
  • Hunt the billing defaults. My review caught one $30/month auto-subscribe. Yours has one too. Per-committer products with enabled-for-new-repos defaults are the shape of cost that never shows up in any review, because no review exists.
  • Defer loudly. An ADR that names the open risk is governance. A settings page that hides it is a liability with a UI.

The part that scales

The repo's CLAUDE.md names the next tenant: an evinced/ directory for the enterprise I run platform for at work, same layout, same invariant. That's the real payoff of the 47 minutes. The first org cost a morning because the thinking happened there. The second one inherits the thinking and costs an afternoon of imports.

273 lines, and most of the value is in the comments. None of that knowledge existed anywhere before, except as scar tissue in my head.

Your org's governance is already a Terraform plan. It's just stored in your head, unreviewed, unversioned, with apply access granted to anyone who can find the settings page. Write it down and make the machine hold the diff.

Get in touch

I write about AI-first engineering on LinkedIn. Specs in, production out, nobody types code. Follow along there, or send a note.

hi@21stark.com · LinkedIn opens my profile, message me from there


Or send it from here