An Enterprise Git Branching Strategy: main, develop, prod, and Promoting Through Staging

By · · Technology

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:

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:

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:

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:

  1. Pull develop and branch off it.

    git checkout develop
    git pull --ff-only
    git checkout -b feature/PROJ-1421-add-saml-login
    
  2. Commit small, push often. Push early so CI runs against your branch and your team can see what you're up to.

  3. 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?"
  4. Get a code review. At least one approving reviewer. All CI checks green: lint, unit, integration, security scan.

  5. Squash-merge into develop. Squash, not merge-commit. The unit of history on develop is "one ticket, one commit." Delete the branch on merge.

  6. 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

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

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:

  1. Open a release PR from develop into prod. No new commits in this PR. It's just promoting what's already been tested.
  2. Get release approval. Usually the release manager and one engineering lead.
  3. Merge. CI deploys prod to Production automatically.
  4. 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
    
  5. Back-merge prod into main:
    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:

  1. Branch from main (or equivalently prod), not from develop. 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-timeout
    
  2. Make 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.
  3. After the hotfix ships, back-merge prod into main as 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:

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:

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.