A few years ago, a teammate force-pushed to main on a Friday afternoon. Not maliciously — they were trying to clean up their commit history and ran git push --force on the wrong branch. The result? Our latest Terraform state reference was gone, three people’s work vanished from the history, and we spent the weekend piecing things back together from reflog and backups.
That Monday, the first thing I did was set up branch protection rules. It took fifteen minutes. The weekend it would have saved us? Priceless.
If you’ve ever had a broken build sneak into production, a PR merged without review, or someone accidentally delete a release branch, this post is for you. Branch protection is the bouncer at the door of your main branch, and every team needs one.
What Are Branch Protection Rules?
Branch protection rules are guardrails you configure on specific branches (usually main or release/*) that enforce policies before code can be merged or pushed. GitHub evaluates these rules on every push and every pull request merge attempt.
Think of them as a checklist that GitHub enforces automatically — no human discipline required.
Here’s the full menu of options available under classic branch protection:
| Protection Rule | What It Does |
|---|---|
| Require a pull request before merging | No direct pushes — all changes must come through a PR |
| Required approving reviews | 1-6 reviewers must approve before merge (configurable count) |
| Dismiss stale reviews | New pushes invalidate previous approvals |
| Require review from Code Owners | Owners defined in CODEOWNERS must approve changes to their files |
| Require last push approval | The person who pushed last cannot be the one to approve |
| Required status checks | Specific CI checks must pass before merge |
| Require branches to be up-to-date | Branch must be current with the base branch |
| Require signed commits | All commits must be GPG, SSH, or S/MIME signed |
| Require linear history | Only squash or rebase merges allowed (no merge commits) |
| Require merge queue | PRs enter a queue and are tested in order before merging |
| Require deployments to succeed | Deployment environments must report success |
| Lock branch | Make the branch read-only |
| Do not allow bypassing | Even admins must follow the rules |
| Restrict who can push | Limit push access to specific users, teams, or apps |
| Allow force pushes | (Dangerous) Let specific people force push |
| Allow deletions | Allow the protected branch to be deleted |
That’s a lot of knobs. Let’s talk about the ones that matter most for infrastructure teams.
The Rules That Matter Most
Required Pull Request Reviews
This is rule number one. Never let code reach main without another set of eyes. For infrastructure code especially — where a bad terraform apply can delete a production database — requiring at least one approving review is non-negotiable.
I recommend enabling Dismiss stale pull request approvals alongside it. If someone approves your PR and you push new commits afterward, the approval should reset. The reviewer approved a specific version of your code, not a blank check.
Require last push approval is another good one for teams larger than two. It prevents the author from pushing a last-minute change and immediately merging — someone else has to verify that final push.
CODEOWNERS Integration
The CODEOWNERS file lets you map file paths to responsible reviewers. When combined with the Require review from Code Owners protection rule, GitHub automatically requests reviews from the right people.
Place the file in .github/CODEOWNERS, the repo root, or the docs/ directory. GitHub checks them in that order and uses the first one found. Here’s a practical example for an infrastructure repo:
# Default owners for everything
* @platform-team
# Terraform modules owned by infra team
/terraform/ @infra-team
/terraform/modules/network/ @network-team @infra-team
# CI/CD pipelines
/.github/workflows/ @devops-leads
# Ansible playbooks
/ansible/ @infra-team @sre-team
# The CODEOWNERS file itself -- only leads can modify
/.github/CODEOWNERS @engineering-leads
An approval from any listed code owner satisfies the requirement. The CODEOWNERS file is read from the base branch of the PR, so changes to CODEOWNERS itself require approval on the current base — you can’t sneak yourself in as an owner via the same PR.
Required Status Checks
This is where CI meets branch protection. You can require that specific GitHub Actions workflows (or external CI systems) pass before a PR can be merged. Common checks for infrastructure repos:
terraform fmt -check— formatting is consistentterraform validate— configuration is syntactically validterraform plan— changes are previewed (even if you review the output manually)tflint— catches common Terraform mistakescheckovortfsec— security scanningyamllint— for Ansible playbooks or Kubernetes manifests
Enable Require branches to be up-to-date before merging if you want to guarantee that CI ran against the latest version of the base branch. This prevents a scenario where two PRs pass CI individually but break when combined. The trade-off is that contributors will need to merge or rebase more often, which can be frustrating on high-traffic repos.
Signed Commits
Requiring signed commits ensures that every commit is cryptographically verified — proving it came from who it claims to come from. GitHub supports GPG, SSH, and S/MIME signing.
For most individual contributors, SSH signing is the easiest option since you likely already have an SSH key. GPG is the traditional choice with broader tooling support. S/MIME is typically used in enterprise environments with existing certificate infrastructure.
I’ll be honest: not every team needs this. But if you’re in a regulated industry, handle sensitive infrastructure, or want to prevent commit spoofing, it’s worth the setup cost.
Merge Strategies: Pick Your Approach
GitHub offers three ways to merge a PR, and you can enable or disable each one in your repository settings. Branch protection’s Require linear history rule forces squash or rebase only. Here’s when to use each:
| Strategy | What Happens | Best For |
|---|---|---|
| Merge commit | Creates a merge commit preserving all branch commits | Open source repos, audit trails, preserving contributor history |
| Squash and merge | Condenses all commits into one on the base branch | Feature work with messy WIP commits, clean main history |
| Rebase and merge | Replays each commit individually onto the base branch | Linear history lovers, small PRs with clean commits |
My recommendation for infrastructure repos: Default to squash and merge. Infrastructure PRs often have commits like “fix typo”, “try different approach”, “revert that”, “actually this one”. Squashing hides that journey and gives you one clean commit per PR on main. Your git log becomes a readable changelog.
If you require linear history through branch protection, merge commits are disabled automatically — leaving squash and rebase as your options.
GitHub Rulesets: The Modern Approach
GitHub introduced rulesets as a more powerful, scalable alternative to classic branch protection. If you’re setting up a new repository or managing protection across an organization, rulesets are the way to go.
Key advantages over classic branch protection:
- Multiple rulesets can apply simultaneously — classic protection only allows one rule set per branch pattern
- Most restrictive rule wins — when rulesets overlap, GitHub picks the strictest setting
- Organization-wide rulesets — define once, apply across hundreds of repos
- Bypass lists — grant specific users or apps the ability to bypass rules without making them admins
- Tag protection — rulesets cover tags too, not just branches
- Evaluate mode — test a ruleset without enforcing it to see what would be blocked
Rulesets and classic branch protection rules coexist — you don’t have to migrate all at once. But for new setups, I’d start with rulesets directly.
Auto-Merge and Merge Queues
Auto-Merge
GitHub’s auto-merge feature lets a PR author signal “merge this as soon as all requirements are met.” Once approvals and status checks pass, GitHub merges automatically. This is great for:
- Dependency update PRs (Dependabot, Renovate)
- Small documentation fixes that don’t need babysitting
- Infrastructure changes that pass all automated checks
Enable it in repository settings under Allow auto-merge, then PR authors can opt in per PR.
Merge Queues
Merge queues solve a specific problem: on busy repos, PRs that pass CI individually can break when merged together. A merge queue batches PRs, runs CI on the combined result, and only merges if the batch passes.
To enable a merge queue:
- Add the Require merge queue rule to your branch protection (or ruleset)
- Update your GitHub Actions workflow to trigger on
merge_groupevents:
on:
pull_request:
branches: [main]
merge_group:
branches: [main]
Configuration options include merge method (squash, rebase, or merge), build concurrency (1-100), minimum/maximum group size, and a status check timeout.
Merge queues are most valuable for repos with 10+ daily PRs where integration failures are a real risk. For smaller teams, the overhead usually isn’t worth it.
Configuring Branch Protection via the API
GitHub REST API
You can configure branch protection programmatically using a PUT request:
curl -L \
-X PUT \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/OWNER/REPO/branches/main/protection \
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["terraform-validate", "terraform-plan", "tflint"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": true,
"required_approving_review_count": 1,
"require_last_push_approval": true
},
"restrictions": null
}'
Set "restrictions": null to allow anyone with write access to push (after PR requirements are met). Set it to a list of users/teams to lock it down further.
Terraform
The github_branch_protection resource from the integrations/github provider is the infrastructure-as-code approach. This is my preferred method — your branch protection rules live in version control right alongside the repos they protect:
resource "github_branch_protection" "main" {
repository_id = github_repository.infra.node_id
pattern = "main"
enforce_admins = true
required_linear_history = true
allows_deletions = false
allows_force_pushes = false
required_status_checks {
strict = true
contexts = [
"terraform-validate",
"terraform-plan",
"tflint",
"checkov"
]
}
required_pull_request_reviews {
dismiss_stale_reviews = true
require_code_owner_reviews = true
required_approving_review_count = 1
require_last_push_approval = true
}
}
This approach has a huge advantage: when someone asks “what are our branch protection rules?”, the answer is a terraform plan away. No clicking through the UI, no tribal knowledge.
PR Review Best Practices for Infrastructure Code
Reviewing Terraform, Ansible, or Kubernetes manifests is different from reviewing application code. Here’s what I’ve learned:
-
Always include plan output — attach
terraform planoutput to the PR as a comment (or use a tool like Atlantis, Spacelift, or Scalr to post it automatically). Reviewers should see what will change, not just the code diff. -
Use collapsible sections for large plans — a 500-line plan output will bury the discussion. Wrap it in a
<details>tag so reviewers can expand it when they need it. -
Separate refactoring from changes — if you’re renaming resources AND adding new ones, split into two PRs. Mixed PRs with
destroy/createcycles are terrifying to review. -
Run security scanning in CI — don’t rely on reviewers to catch misconfigured S3 bucket policies. Tools like
checkovandtfseccatch these automatically. -
Review the plan, not just the code — a one-line change in a Terraform module can cascade into dozens of resource modifications. The diff looks small; the blast radius might not be.
Hands-On Lab: Set Up Branch Protection
Let’s configure branch protection from scratch using the GitHub CLI.
Step 1: Create a test repository
gh repo create branch-protection-lab --public --clone
cd branch-protection-lab
echo "# Branch Protection Lab" > README.md
git add README.md && git commit -m "chore: initial commit"
git push -u origin main
Step 2: Add a CODEOWNERS file
mkdir -p .github
cat > .github/CODEOWNERS <<'EOF'
# Default reviewer
* @your-github-username
# Terraform files
*.tf @your-github-username
EOF
git add .github/CODEOWNERS
git commit -m "chore: add CODEOWNERS"
git push
Step 3: Add a CI workflow
mkdir -p .github/workflows
cat > .github/workflows/ci.yml <<'YAML'
name: CI
on:
pull_request:
branches: [main]
merge_group:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint check
run: echo "All checks passed"
YAML
git add .github/workflows/ci.yml
git commit -m "ci: add validation workflow"
git push
Step 4: Enable branch protection via the API
# Replace OWNER and REPO with your values
gh api repos/OWNER/REPO/branches/main/protection \
-X PUT \
-H "Accept: application/vnd.github+json" \
-f required_status_checks='{"strict":true,"contexts":["validate"]}' \
-f enforce_admins=true \
-f 'required_pull_request_reviews={"dismiss_stale_reviews":true,"required_approving_review_count":1}' \
-f restrictions=null
Step 5: Test it
git checkout -b test/branch-protection
echo "test change" >> README.md
git add README.md && git commit -m "test: verify branch protection"
git push -u origin test/branch-protection
gh pr create --title "Test branch protection" --body "Verifying rules work"
Try merging the PR immediately — GitHub should block it until CI passes and a review is submitted. That’s your bouncer working.
Step 6: Clean up
gh repo delete branch-protection-lab --yes
Troubleshooting Guide
| Problem | Cause | Fix |
|---|---|---|
| PR says “merging is blocked” but checks passed | Status check names don’t match exactly | Compare the check name in the workflow jobs: key with the required check name in settings |
| Admin merged without review | ”Do not allow bypassing” is not enabled | Enable it — or use rulesets with no bypass list |
| CODEOWNERS review not requested | File is on wrong branch or wrong directory | Ensure CODEOWNERS exists on the base branch in .github/, root, or docs/ |
| ”Branch is not up to date” errors | Strict status checks require rebasing | Merge or rebase against the base branch, then push |
| Force push was allowed | Rule not applied or admin bypass | Check “Do not allow bypassing” and “Restrict force pushes” settings |
| Signed commit check failing | Contributor hasn’t set up GPG/SSH signing | Share signing setup docs — git config --global commit.gpgsign true |
| Merge queue stuck | CI not triggered on merge_group event | Add merge_group to your workflow triggers |
| Status check “Expected — Waiting for status to be reported” | Workflow never ran for this PR | Verify the workflow’s on: trigger matches the PR’s target branch |
Quick Reference
# View current branch protection via gh CLI
gh api repos/OWNER/REPO/branches/main/protection
# List rulesets
gh api repos/OWNER/REPO/rulesets
# Check required status checks
gh api repos/OWNER/REPO/branches/main/protection/required_status_checks
# Delete branch protection (use with caution)
gh api repos/OWNER/REPO/branches/main/protection -X DELETE
What’s Next
Next post: PR Templates and Issue Templates. We’ll build standardized templates that make every pull request and issue self-documenting — so reviewers know what they’re looking at and contributors know what to include.
Happy automating!