Gitflow #
Git without an agreed workflow is a recipe for chaos — branches with unclear purposes, merges straight to main out of hurry, hotfixes forgotten to be merged back into develop, and releases that can’t be cleanly rolled back. Gitflow is one answer to this problem: a branching model separating feature development, release preparation, and production fixes into branches with clear purposes and rules. But Gitflow also has costs — it’s complex, verbose, and can become a bottleneck for fast-moving teams. This article covers Gitflow from its fundamental structure, end-to-end workflows, comparisons with alternative workflows, and the often-debated question: when to deploy from a tag vs from the main branch.
Why Gitflow Exists #
Vincent Driessen introduced Gitflow in 2010 when the software world was very different — continuous deployment wasn’t common, teams were larger, and release cycles were longer (weekly or monthly). He needed a model that could:
Problems Gitflow tries to solve:
1. Parallel feature development without disturbing stability
→ Every developer can work on their own branch without fearing
"breaking" code being tested or about to be released
2. A release process separate from active development
→ The team can keep developing new features while another team
does final testing and bug fixes for the upcoming release
3. Hotfixes that don't disturb an in-flight sprint
→ Production bugs are fixed from main directly, not from develop
which might already contain half-finished features
4. Clean, auditable history
→ Every release version is clear — a tag on main shows exactly
what code was in production at a specific time
The Five Gitflow Branches and Their Roles #
gitGraph commit id: "initial" branch develop checkout develop commit id: "dev setup" branch feature/login checkout feature/login commit id: "add login UI" commit id: "add login logic" checkout develop merge feature/login id: "merge login" branch feature/payment checkout feature/payment commit id: "add payment" checkout develop merge feature/payment id: "merge payment" branch release/1.0.0 checkout release/1.0.0 commit id: "bump version" commit id: "fix minor bugs" checkout main merge release/1.0.0 id: "release v1.0.0" tag: "v1.0.0" checkout develop merge release/1.0.0 id: "sync develop" checkout main branch hotfix/1.0.1 checkout hotfix/1.0.1 commit id: "fix critical bug" checkout main merge hotfix/1.0.1 id: "hotfix v1.0.1" tag: "v1.0.1" checkout develop merge hotfix/1.0.1 id: "sync hotfix"
The main Branch — Production History
#
The main branch (or master) represents the code currently or previously in production. Every commit here is a released version, marked with a version tag.
main rules:
✗ No direct commits to main
✗ No feature branch merges straight to main
✓ Only accepts merges from release/* and hotfix/*
✓ Every merge to main must come with a version tag
Why so strict?
→ Main is the "single source of truth" for production
→ If main contains untested or unready code,
rollback becomes difficult and it's unclear which version is safe
The develop Branch — Integration Branch
#
The develop branch is where all finished features gather before release. It’s “production-in-progress” — reflecting what will be in the next release.
develop rules:
✗ No direct feature commits to develop
✓ Accepts merges from feature/*, release/*, and hotfix/*
✓ Must always be in a "buildable and testable" state
✓ CI must run on every push to develop
Why is develop separate from main?
→ Separates "in progress" from "production-ready"
→ Developers can integrate and test together without
affecting main's stability
The feature/* Branches — Feature Development
#
Every new feature is worked on in a separate branch, created from develop, and merged back into develop when finished.
# Creating a feature branch
git checkout develop
git pull origin develop
git checkout -b feature/user-profile
# After finishing — merge into develop via PR/MR
git checkout develop
git merge --no-ff feature/user-profile # --no-ff preserves merge history
git branch -d feature/user-profile
git push origin develop
# Recommended naming convention:
feature/login-google
feature/payment-integration
feature/order-history-export
feature/JIRA-123-add-search # can include the ticket ID
Feature branch rules:
✓ Created from develop (not from main!)
✓ One branch per feature or per story
✓ Kept short-lived (ideally < 1-2 sprints)
✓ Merged into develop via a reviewed Pull Request
✓ Deleted after merging
Anti-patterns:
✗ Feature branches living for weeks without updating from develop
→ Divergence grows → merge conflicts grow
✗ A developer never syncing with the latest develop
→ Integration hell when finally merging
The release/* Branches — Release Preparation
#
When develop has all the features for the upcoming release, a release branch is created for final preparation — last bug fixes, version updates, changelog updates — without any new features.
# Creating a release branch
git checkout develop
git checkout -b release/1.3.0
# Activities on the release branch:
# - Fix minor bugs found during testing
# - Update the version in package.json / go.mod / build.gradle
# - Update CHANGELOG.md
# - Update documentation
# NOT allowed: adding new features!
# When ready to release:
git checkout main
git merge --no-ff release/1.3.0
git tag -a v1.3.0 -m "Release v1.3.0"
git checkout develop
git merge --no-ff release/1.3.0 # sync release changes to develop!
git branch -d release/1.3.0
Release branch purpose:
→ Isolate release "polishing" from in-flight feature development
→ The QA team can focus testing on release/1.3.0 while the dev team
already starts working on 1.4.0 features in develop
→ Release bug fixes don't delay the next sprint
What's allowed on a release branch:
✓ Minor bug fixes from testing
✓ Version and changelog updates
✓ Release documentation updates
What's NOT allowed:
✗ Adding new features (new features go into develop, not release)
✗ Letting the release branch live too long
The hotfix/* Branches — Emergency Production Fixes
#
When there’s a critical production bug that must be fixed now and can’t wait for the next release cycle, a hotfix branch is created directly from main.
# Critical bug found in production v1.3.0
git checkout main
git checkout -b hotfix/1.3.1
# Fix the bug
# Bump the version to 1.3.1
# Update the changelog
# Merge to main and tag
git checkout main
git merge --no-ff hotfix/1.3.1
git tag -a v1.3.1 -m "Hotfix v1.3.1: fix payment gateway timeout"
# MUST also merge into develop!
git checkout develop
git merge --no-ff hotfix/1.3.1 # so the fix isn't lost in the next release
git branch -d hotfix/1.3.1
Merging hotfixes into develop is the most often forgotten step. If a hotfix isn’t merged into develop, the same bug must be fixed again at the next release — because develop doesn’t have the fix. This is known as version drift and can cause “already fixed” bugs to reappear in the next release.
Deployment: Tags vs the main Branch #
This is the most debated topic, especially for engineers moving between teams with different workflows.
flowchart LR
subgraph WrongWay["A less proper approach for Gitflow"]
WDev["develop"]
WMain["main (HEAD)"]
WProd["Production"]
WDev -->|"merge"| WMain
WMain -->|"deploy from branch HEAD"| WProd
Note1["main HEAD is mutable\ncan change at any time"]
end
subgraph RightWay["The correct approach for Gitflow"]
RDev["develop"]
RMain["main"]
RTag["tag v1.4.0\n(immutable)"]
RProd["Production"]
RDev -->|"merge via release"| RMain
RMain -->|"create tag"| RTag
RTag -->|"deploy from tag"| RProd
Note2["tags are immutable\nrollback is always consistent"]
end
style Note1 fill:#E74C3C,color:#fff
style Note2 fill:#27AE60,color:#fffWhy tags, not the main branch?
Branches are mutable:
New commits can be added at any time
main@HEAD today isn't main@HEAD last week
→ Deploying from HEAD means it can't be consistently reproduced
Tags are immutable:
v1.4.0 always points to the same commit
Can't change after creation
→ Deploying from a tag means you can roll back to the exact same version
A concrete example of tag benefits:
Production incident! Need to roll back to the previous version.
With tags:
kubectl set image deployment/api api=gcr.io/project/api:v1.3.0
→ Always gets the same image, guaranteed
With branch HEAD:
main at rollback time may differ from main at first deploy
→ Must track the exact commit SHA — harder and error-prone
# GitHub Actions — CI/CD triggered from tags
name: Deploy to Production
on:
push:
tags:
- 'v*.*.*' # triggers when a new tag matching vX.Y.Z is created
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build -t gcr.io/myproject/api:${{ env.VERSION }} .
docker push gcr.io/myproject/api:${{ env.VERSION }}
- name: Deploy to production
run: |
kubectl set image deployment/api \
api=gcr.io/myproject/api:${{ env.VERSION }}
# Benefits:
# → Deploys only happen on an explicit decision (creating a tag)
# → Every deployment can be reproduced and cleanly rolled back
# → A clear audit trail: which version was deployed when
Gitflow vs GitHub Flow vs Trunk-Based Development #
Gitflow isn’t the only workflow out there. It’s important to know the alternatives to choose the right one.
Gitflow:
Structure: 5 branch types (main, develop, feature, release, hotfix)
Best for: Enterprises, large teams, version-based releases
Pros: Very tight release control, supports multiple versions
Cons: Complex, lots of merge overhead, not ideal for daily deploys
GitHub Flow:
Structure: main + feature branches only
Best for: Startups, small teams, continuous deployment
Flow: feature branch → PR → merge to main → automatic deploy
Pros: Very simple, fast, great for CD
Cons: No QA isolation, not great for multiple versions
Trunk-Based Development:
Structure: All developers commit to one branch (main/trunk)
Best for: Very mature teams with high discipline
Flow: Small commits straight to main, feature flags to hide incomplete features
Pros: No long-lived branches, no merge hell
Cons: Needs high discipline, feature flag management gets complex
Workflow selection guidance:
Small teams (< 5 people) + daily deploys:
→ GitHub Flow or Trunk-Based
→ Gitflow is too much overhead
Medium teams (5-20 people) + weekly/bi-weekly releases:
→ Gitflow or GitHub Flow with an added release branch
→ The most common sweet spot in industry
Large teams (> 20 people) + monthly or versioned releases:
→ Gitflow
→ The control and structure Gitflow provides is very valuable
Products with multiple versions to support:
→ Gitflow — supports maintaining multiple release branches
→ GitHub Flow has no mechanism for this
Consistent Naming Conventions #
Consistent naming conventions enable CI/CD automation and make navigation easier.
# Feature branches
feature/<short-description>
feature/user-authentication
feature/payment-integration
feature/TICKET-123-export-report # include the ticket ID if present
# Release branches
release/<semver>
release/1.3.0
release/2.0.0-beta
# Hotfix branches
hotfix/<semver>
hotfix/1.3.1
hotfix/1.3.2
# Tags
v<semver>
v1.3.0
v1.3.1
v2.0.0
Semantic Versioning in Gitflow #
Gitflow and SemVer (Semantic Versioning) pair together very naturally.
Format: MAJOR.MINOR.PATCH
MAJOR: breaking changes — not backward compatible
v1.x.x → v2.0.0
→ Usually from a release branch with a major version bump
→ Very rare, usually comes with a migration guide
MINOR: backward-compatible new features
v1.2.x → v1.3.0
→ From a regular release branch
→ Several new features, nothing breaking
PATCH: backward-compatible bug fixes
v1.3.0 → v1.3.1
→ From a hotfix branch
→ Bug fixes only, no new features
A real chronology example:
v1.0.0 → initial release
v1.0.1 → hotfix: fix login timeout
v1.1.0 → release: add notification feature
v1.1.1 → hotfix: fix duplicate notifications
v1.2.0 → release: add export feature
v2.0.0 → release: API overhaul (breaking change)
Gitflow Anti-Patterns to Avoid #
Feature Branches Living Too Long #
// ✗ Anti-pattern:
feature/redesign-checkout → lives for 3 months
→ develop has moved much further ahead
→ Massive merge conflicts at final merge
→ "Merge hell" eating days of time
// ✓ Solution: break large features into small features
feature/checkout-step-1-cart-review → 3 days
feature/checkout-step-2-address → 3 days
feature/checkout-step-3-payment → 4 days
feature/checkout-step-4-confirmation → 2 days
→ Each merged into develop regularly
→ No large divergence
→ Unfinished features hidden with feature flags
Not Merging Hotfixes into Develop #
# ✗ Anti-pattern: hotfix merged into main, but forgotten for develop
git checkout main
git merge hotfix/1.3.1
git tag v1.3.1
# STOP — no merge into develop!
# Result: the same bug reappears in v1.4.0!
# ✓ Solution: always merge into develop too
git checkout main
git merge --no-ff hotfix/1.3.1
git tag -a v1.3.1 -m "Hotfix: fix payment timeout"
git checkout develop
git merge --no-ff hotfix/1.3.1 # MANDATORY!
git push origin develop
Branch Protection Not Configured #
// ✗ Anti-pattern: no branch protection
Anyone can push directly to main or develop
→ One accidental push can break production
→ Code review isn't mandatory
// ✓ Solution: branch protection rules in GitHub/GitLab
For the main branch:
- Require pull request before merging
- Require approvals: 1-2 reviewers
- Require status checks to pass (CI must be green)
- Restrict pushes: only the CI bot may merge
- Do not allow bypassing the above settings
For the develop branch:
- Require pull request before merging
- Require approvals: 1 reviewer minimum
- Require status checks to pass
Deploying from develop to Production #
// ✗ Anti-pattern often seen in the field:
developer merges feature into develop
→ CI/CD deploys develop straight to production
→ Even though develop may contain half-finished features!
// ✓ Solution: create a release branch first
develop (stable) → release/x.y.z → testing → merge to main → tag → deploy
// The correct mapping:
develop → staging environment (for preview, not production!)
main → production (via tags)
Gitflow Checklist #
INITIAL SETUP:
□ main and develop branches exist and are configured as protected branches
□ Branch protection rules enabled: required PRs, required reviews, CI must pass
□ .gitignore and .gitattributes configured correctly
□ Branch naming conventions documented and agreed by the team
□ Semantic versioning agreed (tag format: vX.Y.Z)
FEATURE DEVELOPMENT:
□ Every feature worked on in a separate feature branch
□ Feature branches created from develop (not from main)
□ Feature branches synced with develop regularly (pull/rebase)
□ Feature branches merged into develop via PR with at least 1 reviewer
□ Feature branches deleted after merging
RELEASE:
□ Release branch created from develop when all features are ready
□ No new features on the release branch — only bug fixes and finalization
□ Version bumped on the release branch (package.json, go.mod, changelog)
□ Release branch merged into main AND develop after release
□ Tag created on main after merging (annotated tag: git tag -a vX.Y.Z)
HOTFIX:
□ Hotfix branch created from main (not from develop!)
□ After fixing, merged into main AND develop
□ Patch version bumped
□ Tag created on main
CI/CD:
□ CI runs on every push to feature, develop, release, and hotfix branches
□ Production deployment triggered from tags (not from branch HEAD)
□ Staging deployment triggered from develop
□ Rollback procedure defined and tested
Summary #
- Gitflow is a release discipline, not just a branching model — its value lies in consistency. Half-heartedly applied Gitflow gives overhead without benefits.
- Five branches with very different purposes — main (production history), develop (integration), feature (development), release (release finalization), hotfix (emergency fixes). Every branch has rules for where it’s created from and where it merges to.
- Hotfixes must be merged into develop, not just main — this is the most often forgotten step and causes the same bug to reappear in the next release (version drift).
- Deploy from tags, not from branch HEAD — tags are immutable and reproducible. Branch HEAD can change at any time. Tags enable clean rollbacks and clear audit trails.
- CI/CD triggers from tag patterns —
on: push: tags: 'v*.*.*'in GitHub Actions ensures deployments only happen on an explicit release decision, not on every commit to main.- Feature branches must be small and short-lived — a feature branch living for weeks is a sign the feature is too big. Break it down into sub-features merged more often.
- Branch protection is a must — without branch protection, all Gitflow rules can be violated with a single
git push --force. Configure required PRs, required reviews, and required CI.- Gitflow isn’t for every team — for early-stage startups or teams deploying daily, GitHub Flow is far more pragmatic. Choose a workflow based on team needs, not trends.
- Release branches enable parallel development — the team can keep working on next-version features in develop while another team does final testing on the release branch.
- Semantic versioning complements Gitflow — hotfixes produce PATCH bumps, regular releases produce MINOR bumps, breaking changes produce MAJOR bumps. Staying consistent makes changelogs and upgrade paths easier to understand.