An Enterprise Git Branching Strategy: main, develop, prod, and Promoting Through Staging
Most teams I've worked with don't fail at branching because they picked the wrong model. They fail because nobody wrote the rules down. A new engineer joins, opens a PR straight into main, ships a hotfix that quietly clobbers someone's half-merged release, and a week disappears trying to figure out what happened.
This is the write-up I wish I'd handed to every team I joined. It's a four-branch model that's held up for me in larger orgs: long-lived main, develop, and prod, with feature/* and fix/* for the actual work. Code promotes through a Staging pipeline (QC/Test, then PreProd) before anything touches Production, and every release loops back into main so the baseline doesn't quietly drift away from what's running.
The branching model
feature/* fix/* hotfix/*
\ / /
\ / /
main ─────► develop ─────────────► prod ─────► (back-merge into main)
▲ │ │
│ ▼ ▼
baseline Staging Production
(QC/Test → PreProd)
Four long-lived branches, two short-lived families:
mainis the baseline. It always mirrors what's currently running in Production. Nobody commits to it directly.developis the integration branch. Every feature and fix lands here first. CI ships every push into Staging (QC/Test).prodis the release branch. When a release is cut fromdevelop, it merges (or fast-forwards) intoprod. CI deploys every push to Production.feature/*are short-lived branches for new work, cut fromdevelop.fix/*are short-lived branches for bugs, also cut fromdevelop. Hotfixes are different and get their own section below.hotfix/*are short‑lived branches for urgent production fixes.
After each Production release, prod gets back-merged into main so main keeps reflecting what's actually live.
Why three long-lived branches instead of one
Pure trunk-based development is great if you have feature flags everywhere, fast CI, and a culture that's comfortable shipping on green. Most enterprises don't. They have compliance reviews, change advisory boards, customer maintenance windows, and a QA team that needs something stable to test against. Pretending otherwise just pushes the complexity into Friday afternoons.
Three long-lived branches give you three stable answers:
| Branch | What it represents | Who deploys it |
|---|---|---|
main |
The last thing successfully released to Production | Nobody, automated |
develop |
What QA is currently testing | CI on every push |
prod |
What's currently running in Production | CI on every push |
That's the whole payoff. When a PM asks "what's in QA right now," the answer is git log develop. When a support engineer asks "what's actually running for customers," the answer is git log prod (or main, which mirrors it after every release).
Branch naming standard, with ticket references
Branch names aren't decoration. They're the link between a commit, a PR, a CI run, a deploy, and the ticket that justified the work in the first place. Keep them disciplined and your git log doubles as a searchable audit trail.
The standard:
<type>/<TICKET-ID>-<short-kebab-description>
Where:
<type>is one offeature,fix,hotfix,chore,docs,refactor.<TICKET-ID>is your issue tracker key. Jira (PROJ-1234), Linear (ENG-87), GitHub issues (gh-512). This part is required. No exceptions.<short-kebab-description>is 3 to 6 words, lowercase, hyphenated, describing intent.
Examples that pass review:
feature/PROJ-1421-add-saml-login
feature/PROJ-1455-export-invoices-to-csv
fix/PROJ-1488-null-pointer-on-empty-cart
fix/gh-512-trim-whitespace-in-email-field
hotfix/PROJ-1502-payment-gateway-timeout
chore/PROJ-1499-bump-spring-boot-to-3-1
Examples that should get bounced at PR time:
feature/new-stuff no ticket, no scope
feature/khaled-experiment owned by a person, not by a ticket
PROJ-1421 missing the type prefix
feature/PROJ_1421_add_saml underscores instead of hyphens
Why the ticket ID matters
When the ticket ID is in the branch name, it shows up everywhere for free:
- PR titles auto-populate from templates and link back to the ticket.
- Commit messages with
PROJ-1421in them get attached to the ticket automatically by Jira or Linear smart commits. - Release notes can be assembled by grouping commits on ticket prefix.
- Anyone running
git blamesix months later can trace a line back to the reason it exists.
A small enforcement helper for .github/workflows/branch-name.yml:
name: Branch name check
on:
pull_request:
branches: [develop, prod]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Validate branch name
run: |
BRANCH="${{ github.head_ref }}"
if [[ ! "$BRANCH" =~ ^(feature|fix|hotfix|chore|docs|refactor)/[A-Z]+-[0-9]+-[a-z0-9-]+$ ]] \
&& [[ ! "$BRANCH" =~ ^(feature|fix|hotfix|chore|docs|refactor)/gh-[0-9]+-[a-z0-9-]+$ ]]; then
echo "Branch '$BRANCH' does not match required pattern:"
echo " <type>/<TICKET-ID>-<short-description>"
exit 1
fi
Pair it with a commit message check (Commitlint or a custom hook) that wants the same ticket ID inside the squashed commit, and the convention starts enforcing itself.
How developers actually work day to day
The flow is the same whether it's a feature or a bug fix:
Pull
developand branch off it.git checkout develop git pull --ff-only git checkout -b feature/PROJ-1421-add-saml-loginCommit small, push often. Push early so CI runs against your branch and your team can see what you're up to.
Open a PR into
develop. The PR template should ask for:- The ticket link (auto-filled from the branch name).
- A short "why," not just "what."
- Test evidence: screenshots, logs, or a link to the test run.
- A rollback note. "If this breaks Production, what do we do?"
Get a code review. At least one approving reviewer. All CI checks green: lint, unit, integration, security scan.
Squash-merge into
develop. Squash, not merge-commit. The unit of history ondevelopis "one ticket, one commit." Delete the branch on merge.Watch Staging. Within a few minutes your change is live on QC/Test and QA picks it up against the ticket.
That's the loop. Nobody ever commits directly to develop, prod, or main. GitHub branch protection rules enforce it: required reviews, required status checks, no direct pushes, no force-pushes.
The Staging pipeline: QC/Test, then PreProd
Splitting "staging" into two stages is the single change that makes this model work for enterprise use, and it's the one I see most often skipped. The two stages do completely different jobs. Don't conflate them.
Stage 1: QC/Test
- Trigger: every push to
develop. - Data: synthetic or anonymized. Reset whenever. Cheap to break.
- Audience: QA engineers, devs verifying their own work, product owners doing UAT against tickets.
- Goal: does the change behave correctly? Does it pass functional acceptance?
QC/Test is allowed to be unstable. That's its job. It should fail loudly while there's still time to fix things.
Stage 2: PreProd
- Trigger: promotion from QC/Test once a release candidate is identified (usually a tag on
develop, e.g.rc-2023.08.15). - Data: sanitized, but production-shaped. Same database engine, same versions, similar scale.
- Audience: SRE, performance testers, security review, the change advisory board.
- Goal: does the change still behave correctly under conditions that actually look like Production? Migrations, integrations, load, secrets, networking.
PreProd is the dress rehearsal. If it's green, the Production deploy is mechanical. If you're nervous on Production deploy day, your PreProd isn't doing its job.
Promotion in CI:
# .github/workflows/promote-to-preprod.yml
on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release candidate tag on develop'
required: true
jobs:
promote:
environment: preprod # Requires reviewer approval
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: ${{ inputs.release_tag }}
- run: ./deploy.sh preprod
GitHub Environments give you the approval gate for free. Configure the preprod environment with required reviewers and promotion is never accidental.
Releasing to Production
Once PreProd has signed off:
- Open a release PR from
developintoprod. No new commits in this PR. It's just promoting what's already been tested. - Get release approval. Usually the release manager and one engineering lead.
- Merge. CI deploys
prodto Production automatically. - Tag the release on
prod:git checkout prod git pull --ff-only git tag -a v2023.08.15 -m "Release 2023.08.15" git push origin v2023.08.15 - Back-merge
prodintomain:git checkout main git pull --ff-only git merge --ff-only prod # if main was clean; otherwise open a back-merge PR git push origin main
After step 5, main is identical to what's running in Production again. That property is what makes main useful as a baseline for hotfixes and for auditors.
Hotfixes, the one exception
Bugs found in Production can't wait for the regular develop → prod cycle. The model handles this with a controlled exception:
Branch from
main(or equivalentlyprod), not fromdevelop. You want to fix exactly what's live, without dragging in unreleased changes.git checkout main git pull --ff-only git checkout -b hotfix/PROJ-1502-payment-gateway-timeoutMake the fix. Add a regression test. Open two PRs:
- One into
prod, which deploys to Production after PreProd validation. PreProd can be fast-tracked, but it isn't optional. - One into
develop, so the fix isn't lost in the next regular release.
- One into
After the hotfix ships, back-merge
prodintomainas usual.
The "two PRs" rule looks like duplication. It's actually the single biggest defense against a classic enterprise bug: a hotfix ships to Production, the next regular release accidentally reverts it, and the same bug reappears at the worst possible moment. Usually on a weekend.
Branch protection: the rules CI enforces
Configuration is half the strategy. On GitHub, set these for main, develop, and prod:
- Require a pull request before merging. No direct pushes.
- Require approvals. At least 1 for
develop, at least 2 forprodandmain. - Require status checks to pass: branch name check, lint, unit, integration, security scan.
- Require branches to be up to date before merging.
- Require linear history on
develop(squash merges only). - Restrict who can push to
mainandprod(release managers only, even though merges still go through PR). - No force pushes. No deletions.
I know this list looks like bureaucracy. It isn't. It's what turns a Confluence document into an actual process. Without enforcement, the document is just a document, and people will route around it the first time it's inconvenient.
Common failure modes, and how this model handles them
"Someone merged a half-finished feature and now QA is blocked."
QC/Test is the right place for that to surface. Revert the PR on develop, redeploy, and you've lost minutes instead of days.
"A hotfix shipped to Production but the bug came back next sprint."
The hotfix wasn't merged into develop. The two-PR rule prevents this. Branch protection enforcing it makes the rule real instead of aspirational.
"main and Production drifted apart."
The back-merge step got skipped somewhere. Add a scheduled CI job that fails if main is more than N commits behind prod. You want this loud and early, not discovered during the next hotfix.
"Nobody knows what's in this release." Branch names weren't referencing tickets, so the release notes can't be auto-generated. The branch-name CI check fixes this at the source.
"PreProd passed but Production broke." Almost always means PreProd doesn't actually resemble Production. Different data shape, missing integration, smaller scale, secrets stubbed out. Spend the money on PreProd parity. It's the cheapest incident prevention I know.
When this model isn't the right fit
Worth being honest about:
- Trunk-based shops with mature feature-flag infrastructure and continuous deployment will find this heavy. They're right for their context.
- Tiny teams (1 to 3 people) shipping a side project don't need three long-lived branches. You'd just be paying overhead for fun.
- Libraries and SDKs, where "Production" means "whatever your users have installed," need a release-train model instead.
This model earns its keep when you have multiple teams contributing to one codebase, a separate QA function, change-management requirements, and an SRE or platform team running Production. Outside of that, simpler is fine.
Wrapping up
The branches aren't really the strategy. The discipline around them is. You can pick a different name for develop or prod and the model still works, but skip the back-merge into main for a couple of releases and the whole thing falls apart quietly.
To recap the loop: feature/* and fix/* come off develop and go back via PR. develop deploys automatically to QC/Test. Release candidates get promoted to PreProd behind an approval gate. Once PreProd is green, develop merges into prod, which deploys to Production. After every release, prod is back-merged into main so the baseline keeps mirroring what's live. Branch names always reference a ticket, so the trail from "why did we ship this" to "what code changed" is one git log away.
Write the rules down. Put them behind CI checks and branch protection. Then mostly forget about Git, and let everyone spend their energy on the actual product.