GitOps workflow turning a declarative YAML organization definition into GitHub resources with Terraform, authenticated by a GitHub App.
- Automated GitHub Organization management - Define repositories using simple YAML file.
- Repository metadata - Define description, homepage URL, topics.
- GitOps Composite Action - Manage configurations using pull requests and automate updates using a composite action.
- Terraform - Uses Terraform under the hood to apply changes efficiently.
- Terraform State Management - Stores Terraform state securely in AWS S3.
- GitHub App Integration - Uses a GitHub App for authentication and API interactions.
- Configure an AWS S3 bucket to store Terraform state files.
- Set up a GitHub App and its installation to handle authentication and authorization for your GitHub Organization.
- Implement GitOps by setting up a GitHub repository with:
- YAML-based configuration
- GitHub workflows
- Repository variables and secrets
To create a GitHub App and a GitHub App Installation:
- GitHub / Organization / Settings / Developer settings / GitHub Apps
- New GitHub App
- Create GitHub App
- GitHub App name: name
- Description: description
- Homepage URL: homepage URL
- Webhook
- Active: off
- Permissions
- Organization permissions
- Administration: Read and write
- Where can this GitHub App be installed?: choose what suits you best
- Organization permissions
- Create GitHub App
- Create GitHub App
- your app
- General
- Generate a private key
- Install App
- your organization: Install
- General
- New GitHub App
Create GitHub organization YAML configuration file. See GitHub Organization YAML below.
For example, config.yaml:
---
repositories:
- name: .githubCreate a workflow, for example, .github/workflows/github-organization-as-code.yaml:
---
name: GitHub Organization as Code
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Terraform
uses: bruzit/github-organization-as-code@v0
with:
path: config.yaml
owner: ${{ vars.GH_TF_OWNER }}
app-id: ${{ vars.GH_TF_APP_ID }}
app-installation-id: ${{ vars.GH_TF_APP_INSTALLATION_ID }}
app-pem-file: ${{ secrets.GH_TF_APP_PEM_FILE }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-bucket: ${{ vars.AWS_TF_BUCKET }}
aws-endpoint-url-s3: ${{ vars.AWS_ENDPOINT_URL_S3 }}The action runs the Terraform code shipped with the action against the configuration file at path, relative to the workspace, so the caller checks out its repository first. It sets up the latest Terraform, checks formatting, initializes the S3 backend in aws-bucket, selects the workspace named after owner, validates, and applies with -auto-approve. concurrency queues pushes instead of failing the apply on the state lock.
Set up GitHub actions, variables and secrets:
- GitHub / Repository / Settings
- Secrets and variables / Actions / Actions secrets and variables
- Secrets
- New repository secret
GH_TF_APP_PEM_FILE(GITHUB_APP_PEM_FILE_PATHcontents)AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
- New repository secret
- Variables
- New repository variable
GH_TF_OWNER(GITHUB_OWNER)GH_TF_APP_ID(GITHUB_APP_ID)GH_TF_APP_INSTALLATION_ID(GITHUB_APP_INSTALLATION_ID)AWS_ENDPOINT_URL_S3AWS_TF_BUCKET(S3 bucket name for Terraform state)
- New repository variable
- Secrets
- Secrets and variables / Actions / Actions secrets and variables
Similar to Use Terraform Action, with the reusable workflow:
---
name: GitHub Organization as Code
on:
push:
branches:
- main
jobs:
call-terraform:
uses: bruzit/github-organization-as-code/.github/workflows/terraform.yaml@v0
with:
path: config.yaml
gh_tf_owner: ${{ vars.GH_TF_OWNER }}
gh_tf_app_id: ${{ vars.GH_TF_APP_ID }}
gh_tf_app_installation_id: ${{ vars.GH_TF_APP_INSTALLATION_ID }}
aws_bucket: ${{ vars.AWS_TF_BUCKET }}
aws_endpoint_url_s3: ${{ vars.AWS_ENDPOINT_URL_S3 }}
secrets:
gh_tf_app_pem_file: ${{ secrets.GH_TF_APP_PEM_FILE }}
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Create the configuration file:
---
repositories:
- name: repo-slug
# Metadata
description: "The repository description." # OPTIONAL, DEFAULT none
homepage_url: https://example.com/ # OPTIONAL, DEFAULT none
topics: # OPTIONAL, DEFAULT none
- some-topic
- another-topic
# Properties
is_template: true # OPTIONAL, DEFAULT false
# Contents
template: # OPTIONAL, DEFAULT none
owner: bruzit
repository: template
include_all_branches: true # OPTIONAL, DEFAULT falseRemoving a repository from the YAML archives it instead of deleting it. Every repository is created with archive_on_destroy = true, so terraform apply after a removal archives the repository — the live repository is preserved while being removed from the organization's active configuration.
Every repository is managed with delete_branch_on_merge = true, so GitHub deletes a pull request's head branch once it is merged. A deleted branch can be restored from its pull request.
Set it as source of truth:
# The path is relative to the terraform main module (terraform directory)
export TF_VAR_config="../test.yaml"Export variables GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PEM_FILE, or when using direnv copy the template .env.tmpl to .env and fill it in.
direnv allow
# direnv: loading ~/bruzit/github-organization-as-code/.envrc
# direnv: export +AWS_ACCESS_KEY_ID +AWS_BUCKET +AWS_ENDPOINT_URL_S3 +AWS_SECRET_ACCESS_KEY +GITHUB_APP_ID +GITHUB_APP_INSTALLATION_ID +GITHUB_APP_PEM_FILE +GITHUB_APP_PEM_FILE_PATH +GITHUB_OWNER +TF_VAR_config
# Use Terraform as you need
terraform -chdir=terraform init -backend-config="bucket=$AWS_BUCKET"
terraform -chdir=terraform plan
terraform -chdir=terraform applyFormat Terraform configuration by terraform -chdir=terraform fmt -recursive.
Test by terraform -chdir=terraform init -backend=false && terraform -chdir=terraform test, the repository module by terraform -chdir=terraform/modules/repository init -backend=false && terraform -chdir=terraform/modules/repository test.
MIT License
Copyright © 2026 Martin Bružina