The right git branching strategy for small teams is the one with the fewest branches you can get away with, and for a solo developer or a team of three that is trunk-based development with short-lived feature branches. GitFlow, with its permanent develop branch, release branches, and a separate hotfix lane, was designed for shipping versioned desktop software to customers who upgrade on their own schedule. Apply it to a web app two people deploy daily and all you get is ceremony: a develop branch that drifts from main, merge commits nobody reads, and a release dance for a thing that goes live the moment it merges. I spent a year running GitFlow on a three-person team before I admitted it was solving problems we did not have. The fix is to make main the only long-lived branch, keep everything else alive for hours not weeks, and let branch protection plus CI do the enforcing.
Why is GitFlow overkill for a small team?
GitFlow assumes you maintain multiple versions in the wild at once. If you ship v2.3 to some customers while v2.4 is in QA and v2.2 still needs a security patch, then yes, you need release branches and a hotfix lane to backport into all of them. That is a real problem for shrink-wrapped or on-prem software. It is not your problem if you run a single deployed instance of a web app, because there is exactly one version live: whatever is on main right now.
On a small team the develop branch is the worst part. It becomes a second main that is always slightly ahead, so 'is this deployed?' stops having a clear answer. You merge feature branches into develop, then periodically open a develop-to-main PR that is a giant undifferentiated blob of changes, which is impossible to review and the first thing to go wrong in a bad merge. Every branch you keep alive is a branch that drifts, conflicts, and needs reconciling. Delete it and the drift cannot happen.
What does the trunk-based model actually look like?
One rule anchors everything: main is always deployable. Every commit on main should be a state you would be comfortable shipping, because at any moment it might be. From that, the daily loop falls out. You branch off main for one focused change, push it, open a pull request, let CI run, and once it is green and reviewed you squash-merge it back into main and delete the branch. The branch existed for a few hours to a day or two, never longer.
- main is the single source of truth and the only permanent branch; it is always in a deployable state.
- A feature branch is short-lived and scoped to one change. If it lives longer than a couple of days, it is too big, split it.
- Every change reaches main through a pull request, never a direct push, so CI and review always run.
- Merge with squash so each feature lands as one clean commit on main and the messy work-in-progress history stays in the PR.
- Delete the branch on merge. A merged branch is dead weight that only invites confusion later.
Branch naming barely matters at this scale, but a tiny convention helps you skim git branch output: I use a type prefix and a short description, like feat/webhook-retry or fix/null-cart-total. Here is the entire everyday loop, start to finish.
# Start from an up-to-date main
git switch main
git pull --ff-only
# Branch for one focused change
git switch -c feat/webhook-retry
# ... do the work, commit as often as you like ...
git add -A
git commit -m "Add retry with backoff to webhook job"
# Push and open the PR
git push -u origin feat/webhook-retry
gh pr create --fill
# After CI is green and the PR is approved: squash-merge and clean up
gh pr merge --squash --delete-branchI use --ff-only on the pull so a fast-forward is the only outcome; if it cannot fast-forward, something is wrong with my local main and I want to know before I branch, not after. The gh pr merge --squash --delete-branch line does the merge, the squash, and the branch deletion in one go, both locally and on the remote.
How do you protect main so the model holds?
A convention you have to remember is a convention you will eventually forget at 6pm on a Friday. The model only holds if the platform enforces it. On GitHub that means a branch protection rule (or a ruleset) on main that blocks direct pushes, requires the PR to pass your status checks, and requires the branch to be up to date before merging. Set it once and the rule catches the mistake you would otherwise make tired.
- Require a pull request before merging, which blocks pushing straight to main.
- Require status checks to pass, naming your CI jobs explicitly so a red build cannot be merged.
- Require branches to be up to date before merging, so checks run against the code as it will actually land.
- Include administrators, otherwise you will quietly bypass your own rule and the discipline rots.
- Set the merge method to squash only, so history stays one-commit-per-change without anyone choosing.
If you live on the command line, you can set the ruleset with the GitHub CLI rather than clicking through Settings. This requires your CI check to be named, here ci, matching the job name your workflow reports.
# Replace OWNER/REPO with your repository
gh api -X PUT repos/OWNER/REPO/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": {
"strict": true,
"contexts": ["ci"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1
},
"restrictions": null
}
JSONOn a true solo project requiring an approving review is awkward since there is no second person to approve. Drop required_pull_request_reviews to zero approvals but keep the PR-and-status-check requirement: you still get CI gating every change and a clean squashed history, you just self-merge once the build is green. The status check is the part that saves you. The review is a nicety you can add back the moment a second person joins. CI is what turns the PR into a real gate, and wiring that up is its own topic, I walk through a full pipeline in my post on CI/CD with GitHub Actions.
A long-running develop branch is just a second main that lies to you about what is actually deployed.
How do releases and hotfixes work without release branches?
You do not need a release branch to have releases. A release is just a point on main worth naming, so you tag it. Annotated tags carry a message and a tagger, which is what you want for anything you might later reference, and pushing the tag is what makes a GitHub Release possible from it.
git switch main
git pull --ff-only
# Annotated, semver-style tag pointing at the current main
git tag -a v1.4.0 -m "v1.4.0: webhook retries, cart total fix"
git push origin v1.4.0
# Optional: cut a GitHub Release with auto-generated notes
gh release create v1.4.0 --generate-notesA hotfix is not a special branch type, it is just another short-lived branch off main, the same loop you already run. Production has a bug, you branch fix/cart-total-null off main, fix it, open the PR, let CI run, squash-merge, and tag a patch release like v1.4.1. There is no separate hotfix lane to remember because the everyday loop already is the hotfix lane; the only difference is urgency.
The one case that genuinely needs more is maintaining multiple release lines at once, the GitFlow scenario: an old major version still under support while you develop the next. Then you keep a long-lived release/1.x branch, fix the bug on main, and cherry-pick the commit back onto the supported line.
# Fix landed on main as commit abc1234; back-port it to the 1.x line
git switch release/1.x
git pull --ff-only
git cherry-pick abc1234
git push origin release/1.xBut be honest about whether you are in that situation. Most small teams run a single live deployment and will never cherry-pick anything, so adopting the machinery for multiple release lines pre-emptively is paying GitFlow's full tax to insure against a risk you do not carry. If you do reach the point of supporting several versions or several environments, the conversation shifts toward infrastructure choices too, and platform tradeoffs like self-hosted GitLab versus GitHub start to matter more than your branching diagram.
Keeping branches genuinely short-lived
The whole model rests on one word in 'short-lived feature branches', and it is short-lived, not feature. A branch that lives two weeks is a develop branch wearing a disguise: it drifts from main, accumulates conflicts, and becomes a high-risk merge nobody wants to review. The fix is to scope work so a branch can merge within a day or two. If a feature is too big for that, ship it in slices behind a flag, each slice its own branch and its own PR, so main keeps moving and nothing sits in a corner rotting.
That cadence of small, frequent PRs also pays off in review quality and in automated checks, because a 200-line diff gets a real review while a 4,000-line diff gets a rubber stamp. I lean into deliberately small PR batches for exactly that reason, which I cover in my notes on automated QA with small PR batches.
If you take one thing from this: stop importing a strategy built for versioned, multi-line software into a project that has a single live version. For a solo developer or a small team, main is always deployable, every change is a short-lived branch behind a PR that CI gates and you squash-merge then delete, releases are tags, and a hotfix is just another short branch. Cherry-picking only earns its place the day you actually support more than one release line, and most teams never do. The point of a branching strategy is to make shipping boring and safe, not to give you a diagram to maintain, so pick the lightest model that keeps main green and spend the saved attention on the code instead.

