Manage your GitHub organization from config files, not the settings UI.
Tidyorg is a config-driven engine for GitHub organizations. You describe repositories, teams, access, branch protection, and org settings in YAML; Terraform reconciles GitHub to match. Every change is a pull request — reviewed, versioned, and reversible.
Anything you change by hand in the GitHub UI drifts back on the next apply. The config is the single source of truth.
- Auditable — who can do what is answered by reading config, not clicking through settings. Every change is a commit.
- Reproducible — the whole org can be rebuilt from config.
- Least privilege by construction — access comes from team membership derived from config; org-owner escalation lives in a separate, review-gated file the dashboard cannot write.
config/*.yml engine (Terraform) GitHub
what you want ──▶ reconciles ──▶ reality
(PR-reviewed) (this image) (one-way)
One optional layer on top: a web dashboard to view access across the org and make changes (add/remove members, edit repo access, branch protection, labels, org settings). The dashboard never talks to Terraform directly — every change it makes is a pull request against the config, so the same review gate applies. It authenticates each user via GitHub device flow and can batch many edits into a single PR.
You need: a GitHub organization you own, and a GitHub App installed on it (below). The published image includes the engine and a starter config, so cloning this repository is not required.
The commands below use Bash syntax. On Windows, the Compose route immediately below is the simplest option because it avoids shell-specific volume syntax.
# 1. Pull the image and scaffold a local config directory
docker pull ghcr.io/uslanozan/tidyorg:0.1.3
mkdir -p config state
docker run --rm \
-v "$PWD/config:/config" \
ghcr.io/uslanozan/tidyorg:0.1.3 scaffold
# edit config/ to describe your org (organization.yml, people.yml,
# privileged.yml, repositories/*.yml)
# 2. Drop your GitHub App private key next to the config
# (downloaded when you created the App)
cp ~/Downloads/your-app.private-key.pem ./app.pem
# 3. Preview, then apply
docker run --rm \
-v "$PWD/config:/config" \
-v "$PWD/state:/state" \
-v "$PWD/app.pem:/secrets/app.pem:ro" \
-e TF_VAR_github_org_name=your-org \
-e TF_VAR_github_app_id=123456 \
-e TF_VAR_github_app_installation_id=12345678 \
ghcr.io/uslanozan/tidyorg:0.1.3 plan
# swap `plan` for `apply` once the plan looks rightState is kept in ./state on your host — no HCP / Terraform Cloud required. Point at your
own remote backend if you prefer (see Backend below).
docker compose users can download docker-compose.ghcr.yml,
fill in its variables, then run:
docker compose -f docker-compose.ghcr.yml run --rm engine scaffold # first run only
docker compose -f docker-compose.ghcr.yml run --rm engine plan
docker compose -f docker-compose.ghcr.yml up -d dashboardThe dashboard is then available at http://localhost:8080. Before starting it, replace
the placeholder engine App IDs and dashboard GITHUB_CLIENT_ID, CONFIG_OWNER, and
CONFIG_REPO values in the Compose file. The file pins v0.1.3 by default; set
TIDYORG_VERSION when you deliberately upgrade.
The regular docker-compose.yml remains the source-build setup for
contributors.
GHCR is the canonical registry and Docker Hub is an automated mirror. Both receive the same multi-architecture build and version tags, so users may choose either source:
# Engine
docker pull ghcr.io/uslanozan/tidyorg:0.1.3
docker pull uslanozan/tidyorg:0.1.3
# Dashboard
docker pull ghcr.io/uslanozan/tidyorg-dashboard:0.1.3
docker pull uslanozan/tidyorg-dashboard:0.1.3To use the Docker Hub mirror with Compose, set TIDYORG_REGISTRY=uslanozan.
Tidyorg authenticates as a GitHub App (short-lived tokens, org-owned identity — no personal access token). Depending on what you run, you create up to two Apps:
1. Engine bot (required) — used by Terraform. Broad, because it reconciles the whole org:
| Scope | Permission |
|---|---|
| Repository → Administration | Read & write |
| Repository → Contents | Read & write |
| Repository → Issues | Read & write |
| Repository → Workflows | Read & write |
| Repository → Metadata | Read |
| Organization → Members | Read & write |
| Organization → Administration | Read & write |
Supply TF_VAR_github_app_id, TF_VAR_github_app_installation_id, and the private key
(-v ./app.pem:/secrets/app.pem).
2. Dashboard app (optional — only if you run the dashboard) — deliberately narrow. It
opens PRs on the signed-in user's behalf and nothing else, so it cannot delete repos or
edit privileged.yml:
| Scope | Permission |
|---|---|
| Repository → Contents | Read & write |
| Repository → Pull requests | Read & write |
| Repository → Actions | Read-only (live "applying/in-sync" badge) |
| Repository → Metadata | Read |
Enable "Device Flow" and install it on only the config repo. For the published container,
pass its client_id as the runtime variable GITHUB_CLIENT_ID. Local Vite development uses
VITE_GITHUB_CLIENT_ID instead. See integrations/github-app/
for both manifests and step-by-step setup.
To let project mentors open dashboard PRs, set config_repository in
organization.yml to that repo's name and declare the repo under
config/repositories/. The engine grants the generated
tidyorg-dashboard-writers team push on the config repo; protected main still
requires the config repo's normal review rules.
Four files under config/, split by ownership:
| File | Owns | Who writes it |
|---|---|---|
organization.yml |
roles, defaults, org settings, profile | humans |
repositories/<name>.yml |
one repo: access, branch protection, labels, files | humans / dashboard |
people.yml |
org membership (a list of usernames) | humans / dashboard |
privileged.yml |
org owners + org-scoped roles | humans only (review-gated) |
The split of people.yml / privileged.yml is the escalation gate: the file a dashboard
can write cannot express "make this person an org owner." That lives in privileged.yml,
which is protected by CODEOWNERS. The org name itself is not in config — it comes from
TF_VAR_github_org_name, so there is a single source of truth. See config.example/ for a
working set and terraform/config/*.example.yml for the fully-commented schema of every field.
description: "Payment gateway service"
language: go # display metadata; CI auto-detects the real languages
mentors: [alice] # repo admins (>=1)
developers: [bob, carol]
viewers: [dan] # read-only (optional)
# everything else inherits from organization.yml defaults;
# override only what differs, e.g.:
protected_branches:
main:
required_reviews: 2The TF_STATE environment variable selects where Terraform keeps its state:
TF_STATE |
State lives in | Extra variables |
|---|---|---|
local (default) |
the mounted /state volume — zero setup |
— |
hcp |
HCP Terraform / Terraform Cloud (recommended for teams) | TF_CLOUD_ORGANIZATION, TF_WORKSPACE, TF_TOKEN_app_terraform_io |
custom |
your own backend (S3, GCS, azurerm, …) | mount your backend config at /engine/backend.tf |
# HCP / Terraform Cloud instead of local state:
docker run --rm \
-v "$PWD/config:/config" \
-v "$PWD/app.pem:/secrets/app.pem:ro" \
-e TF_STATE=hcp \
-e TF_CLOUD_ORGANIZATION=your-tf-org \
-e TF_WORKSPACE=tidyorg \
-e TF_TOKEN_app_terraform_io=... \
-e TF_VAR_github_org_name=your-org \
-e TF_VAR_github_app_id=123456 \
-e TF_VAR_github_app_installation_id=12345678 \
ghcr.io/uslanozan/tidyorg:0.1.3 planThe image is built with a local backend, so switching to hcp/custom re-runs terraform init -reconfigure at startup (cached providers are reused).
The release candidate passed the repository CI, multi-architecture image checks, container
smoke tests, and a manual end-to-end run against a disposable GitHub organization. The test
steps, evidence, and remaining automation gaps are recorded in
docs/verification.md.
- No backup is built in. Code history and issue/PR metadata are not backed up by Tidyorg; add your own off-GitHub backup if you need one.
- Free-plan GitHub cannot use branch protection on private repos; Tidyorg surfaces this but cannot work around it.
- Some org settings (e.g. billing email, PAT policy) are not readable by the API, so their drift is not detected — Tidyorg treats the config as the source of truth for those.
# Dashboard (React + Vite + TS)
cd dashboard && npm ci && npm run dev
# Engine (Terraform) — providers only, no state/credentials
terraform -chdir=terraform init -backend=false && terraform -chdir=terraform validate
# Checks CI runs before merge
terraform -chdir=terraform fmt -check -recursive
terraform -chdir=terraform test
cd dashboard && npm run build && npm run verify:yamlBuild the images locally with docker build -t tidyorg . (engine) and
docker build -t tidyorg-dashboard ./dashboard. See CONTRIBUTING.md
for conventions.
MIT.
Pre-1.0. The engine, the web dashboard (full write mode via PRs), and both Docker images are functional. A fresh disposable organization has been verified through initial apply, dashboard change, pull request, merge, reconciliation, and a final no-change plan. The live pilot is still manual; automated browser and disposable-organization E2E coverage remain before a 1.0 tag.