Featured image of post GitHub Organization as Code with Terraform

GitHub Organization as Code with Terraform

Manage your entire GitHub organization through code. Declarative repos, teams, and secrets.

Why This Matters

If you’ve ever tried to explain to your team that “we can’t create a new repo right now, I’m at dinner,” you understand why GitHub management should be code. Every infrastructure change in my homelab goes through code review — including how we manage GitHub itself.

The problem: GitHub’s web UI is fine for 3 repos, painful for 30+. You can’t track who changed what, can’t enforce naming conventions, and can’t ensure consistency across repositories.

The solution: treat your GitHub organization like database infrastructure. Define everything in YAML, let Terraform handle the drift, and sleep better at night.

Scale: This setup manages 40+ repositories across my organization with full configuration, teams, secrets, and custom properties.

All Terraform resources support lifecycle { create_before_destroy = true } for zero-downtime deployments.

Architecture

The github-management-plane repository manages my GitHub organization through Terraform — the same configuration-driven pattern as my homelab infrastructure:

  graph TB
    G[github-management-plane]
    R[Repository Module]
    S[Secrets/Variables Module]
    O[Organization Custom Properties]
    
    G --> R
    G --> S
    G --> O
    
    Repos["All Repositories"]
    Vars["Actions Variables"]
    Secs["Actions Secrets"]
    
    Repos --> tf-infra-homelab["tf-infra-homelab"]
    Repos --> tf-module-proxmox-talos["tf-module-proxmox-talos"]
    Repos --> applications-homelab["applications-homelab"]
    Repos --> ...["24+ repositories"]

What This Module Does

  1. Repository management — create and configure all repositories via YAML
  2. Team management — define teams and members
  3. Organization custom properties — classify repositories
  4. Actions secrets/variables — manage organization-wide secrets
  5. Issue labels — standardize labels across repositories

Quick Start

module "repositories" {
  source   = "./modules/repository"
  for_each = local.filtered_repo_configurations
  
  configuration = each.value
  organization  = var.github_organization
}

Repository Management

All repositories are defined as YAML configurations:

# configurations/repository/tf-infra-homelab.yaml
name: tf-infra-homelab
description: A terraform infrastructure repository for managing my homelab environment
enabled: true
archived: false
visibility: private
type: terraform-infrastructure

topics:
  - homelab
  - proxmox

enabled_features:
  vulnerability_alerts: true
  issues: true
  wiki: false
  projects: false
  discussions: false

Repository Types

The module supports different repository types with defaults:

locals {
  repository_types = {
    terraform-infrastructure = {
      license_template = "mit"
      auto_init       = true
      topics        = ["terraform", "homelab"]
    }
    terraform-module = {
      license_template = "mit"
      auto_init       = true
      topics         = ["terraform", "proxmox"]
    }
    python-docker-application = {
      license_template = "mit"
      auto_init       = true
      topics         = ["python", "docker"]
    }
    generic = {
      auto_init = true
    }
  }
}

Repository Resource

resource "github_repository" "this" {
  name        = var.configuration.name
  description = var.configuration.description
  visibility  = var.configuration.visibility
  
  allow_rebase_merge    = true
  allow_squash_merge  = true
  delete_branch_on_merge = true
  
  vulnerability_alerts = var.configuration.enabled_features.vulnerability_alerts
  has_discussions     = var.configuration.enabled_features.discussions
  has_issues         = var.configuration.enabled_features.issues
  has_projects       = var.configuration.enabled_features.projects
  has_wiki           = var.configuration.enabled_features.wiki
}

Organization Custom Properties

Custom properties allow classification and filtering:

locals {
  organization_custom_properties = {
    "can-be-public" = {
      description   = "To indicate whether the repository can be made public"
      value_type  = "single_select"
      required   = true
      allowed_values = ["true", "false"]
      default_value = "false"
    }
    
    "managed-by" = {
      description = "To identify who manages the repository"
      value_type = "single_select"
      required = true
      allowed_values = [
        "github-management-plane",
        "manual-management",
      ]
      default_value = "manual-management"
    }
    
    "repository-type" = {
      description = "To indicate the type of repository"
      value_type = "single_select"
      required = true
      allowed_values = [
        "generic",
        "repository-template",
        "golang-linux-package",
        "golang-docker-application",
        "python-docker-application",
        "python-package",
        "terraform-infrastructure",
        "terraform-module",
      ]
    }
  }
}

Apply them:

resource "github_organization_custom_properties" "managed_properties" {
  for_each   = local.organization_custom_properties
  
  property_name = each.key
  value_type  = each.value.value_type
  required   = each.value.required
  description = each.value.description
  default_value = each.value.default_value
  allowed_values = each.value.allowed_values
}

Secrets and Variables

Actions secrets and variables are managed through Bitwarden:

# configurations/secrets_variables/global.yaml
variables:
  - name: RUNNER
    value: self-hosted
    visibility: all

secrets:
  - name: BWS_ACCESS_TOKEN
    is_manual: true
    visibility: private

Variable Resource

resource "github_actions_organization_variable" "managed_variables" {
  for_each = { for v in var.configuration.variables : v.name => v }
  
  variable_name = each.value.name
  visibility   = each.value.visibility
  value        = each.value.value
}

Secret Resource

For secrets, two patterns:

# Manual secrets (value set outside Terraform)
resource "github_actions_organization_secret" "managed_secrets_manual" {
  for_each = { for v in var.configuration.secrets : v.name => v if v.is_manual }
  
  secret_name = each.value.name
  visibility = each.value.visibility
  plaintext_value = "NONE"
}

# Synced secrets (from Bitwarden)
resource "github_actions_organization_secret" "managed_secrets_sync" {
  for_each = { for v in var.configuration.secrets : v.name => v if !v.is_manual }
  
  secret_name     = each.value.name
  visibility     = each.value.visibility
  plaintext_value = data.bitwarden-secrets_secret.secrets[each.value.name].value
}

Get secrets from Bitwarden:

data "bitwarden-secrets_secret" "secrets" {
  for_each = { for v in local.all_secrets : v.name => v if !v.is_manual }
  id      = each.value.bw_secret_id
}

Team Management

Teams are defined in the main module:

resource "github_team" "organization_administrators" {
  name        = "organization-administrators"
  description = "Team with administrative access to the organization"
  privacy     = "closed"
}

resource "github_team_members" "organization_administrators_members" {
  team_id = github_team.organization_administrators.id
  
  members = {
    "your-username" = {
      role = "maintainer"
    }
  }
}

Issue Labels

Labels are standardized across repositories:

locals {
  shared_labels = {
    "bug" = {
      color   = "d73a4a"
      description = "Bug report"
    }
    "enhancement" = {
      color   = "a2eeef"
      description = "New feature"
    }
    "documentation" = {
      color   = "0075ca"
      description = "Documentation improvements"
    }
  }
}

resource "github_issue_labels" "this" {
  repository = var.configuration.name
  
  label = merge(
    local.shared_labels,
    try(var.configuration.labels, {})
  )
}

My Repository Configuration

Here’s the list of repositories managed:

configurations/repository/
├── tf-infra-homelab.yaml          # Homelab infrastructure
├── tf-infra-github-management-plane.yaml  # GitHub management
├── tf-module-proxmox-lxc.yaml    # LXC module
├── tf-module-proxmox-vm.yaml    # VM module
├── tf-module-proxmox-talos.yaml  # Talos module
├── tf-module-proxmox-docker.yaml # Docker module
├── applications-homelab.yaml   # Kustomize apps
├── cf-worker-terraform-registry.yaml  # TF registry worker
├── cf-worker-apt-repository.yaml   # APT repository worker
├── template-terraform-basic.yaml   # Module template
├── template-cloudflare-worker-python.yaml  # Worker template
└── ... (24 total)

Each is just a YAML file — adding a new repository is adding a file.

Configuration Structure

github-management-plane/
├── configurations/
│   ├── repository/           # Repository definitions
│   ├── secrets_variables/   # Actions secrets/variables
│   └── rulesets/           # Branch protection (future)
├── modules/
│   ├── repository/         # Repository module
│   ├── secrets_variables/  # Secrets module
│   └── organization/        # Org properties module
├── main.tf                 # Orchestration
├── locals.tf              # Configuration loading
└── providers.tf          # Provider setup

This mirrors the homelab infrastructure structure.

Outputs

The module doesn’t have specific outputs since it manages the organization passively.

What’s Next

  1. Branch protection rulesets — via rulesets API (when Terraform provider supports it)
  2. Repository invitations — managing outside collaborators
  3. Security advisories — automated security scanning

What Most People Get Wrong

  1. “GitHub Terraform is just for repos” — It’s org-wide: teams, custom properties, secrets, variables. The whole thing.

  2. “One Terraform run is enough” — Git rate limits apply. Use ratenode provider or caching.

  3. “Manual changes are fine if you revert” — Terraform drift will catch you. Enable logento or run terraform refresh regularly.

When to Use / When NOT to Use

Use GitHub Management Plane Do it manually
20+ repositories 1-5 repos
Team collaboration Personal projects
Audit requirements Quick experiments
Secret/variable management Ad-hoc scripts only

This makes GitHub management declarative — every change goes through code review.