Git Workflow for SaaS Teams: Branching, Releases, and Hotfixes at Sustainable Pace
Ask five engineers how to branch and you will get five confident answers and at least one argument. Git workflow discussions have a religious quality to them, which is odd because for most companies the right answer is fairly boring. This post lays out a git workflow for SaaS teams of roughly 3 to 15 engineers, the size where we see the most confusion. It is opinionated on purpose. You can disagree with individual choices, but the overall shape has held up across every SaaS codebase we have worked on.
The short version: trunk-based development with feature branches that live less than two days, feature flags for anything unfinished, a weekly tagged release, and hotfixes cut from the last release tag. Everything else in this post is detail.
Why long-lived branches hurt teams this size
The classic failure mode looks like this. An engineer starts a feature branch for a big piece of work. Two weeks later the branch has 60 commits, main has moved on, and the merge produces conflicts in files the engineer never touched. The review is unreadable because it covers three thousand lines. QA tests the branch, then tests it again after the merge because the merge itself changed behavior. Deployments get scheduled around the big merge. Everyone is slightly afraid.
The underlying problem is batch size, and git is just where the pain shows up. Merge conflicts grow roughly with the square of branch lifetime, because both the branch and main accumulate changes that can collide. Keep branches under two days and conflicts mostly disappear. Reviews shrink to a size a colleague can actually read before lunch. And because each merge is small, finding the commit that broke something takes minutes instead of an afternoon of bisecting.
GitFlow, with its develop branch, release branches, and ceremony, was designed for boxed software with parallel supported versions. A SaaS product has exactly one version in production. You do not need the machinery.
The git workflow for SaaS teams we recommend
Here is the whole model:
- Branch from
main, named after the ticket, for examplefeat/1234-invoice-export. - Keep the branch alive for less than two days. If the work is bigger than that, split it and hide the unfinished parts behind a flag.
- Open a pull request. CI runs the full gate: linting, static analysis, tests. A green gate plus one approving review is the requirement to merge.
- Merge to
main. That deploys to staging automatically, every time, no manual step. - Once a week, cut a release: tag
main, and the tag deploys to production.
That is the entire happy path. There is no develop branch, and no merge windows either. The weekly cadence is a starting point, not a law. Teams that get comfortable often move to releasing twice a week or on every merge. The important part is that releasing is a tag and a pipeline, not a meeting.
One practical note on merges: squash-merge the PR. One branch becomes one commit on main, which keeps history readable and makes cherry-picking a single fix trivial. It also means the PR title becomes the commit message, which matters for the changelog automation below.
Feature flags instead of long branches
The obvious objection to two-day branches is that real features take longer than two days. True. The answer is to merge unfinished work behind a feature flag instead of letting it age on a branch.
A flag does not have to mean a vendor product. A config value or a database-backed toggle checked in one place is enough to start:
if ($featureFlags->enabled('invoice_export', $account)) {
// new path, merged but dark
}
This changes what a branch is for. A branch stops being the place where a feature lives until it is done and becomes the vehicle for the next reviewable slice. The feature itself accumulates on main, dark, until you turn it on. You get continuous integration in the literal sense, plus a rollout mechanism and a kill switch for free.
Flags come with a hygiene cost. Every flag is a fork in your code, and stale flags rot. Put a removal ticket in the backlog the day a flag ships, and delete the flag once the feature is fully live. A codebase with forty forgotten flags is its own kind of legacy problem, and we have seen that during code audits more than once.
The weekly release cut
Once a week, at a fixed time that nobody has to think about, someone tags main:
git tag -a v2026.08.27 -m "Release 2026-08-27"
git push origin v2026.08.27
The tag triggers the production deploy. Calendar versioning fits SaaS better than semver here, because your customers do not consume version numbers, and a date tells you instantly how old a release is.
Why weekly and not on every merge? Purely as a stepping stone. Teams migrating from monthly or ad hoc releases find weekly releases a comfortable first target: frequent enough that each release is small, infrequent enough that the team can build confidence in the pipeline. Staging has been receiving every merge all week, so by cut time the release candidate has effectively been tested for days. When the weekly cut becomes a non-event, shortening the cycle is easy.
Hotfixes without heroics
Production breaks on Wednesday. The Tuesday release tag is v2026.08.26, but main already contains three merged features you do not want to ship under pressure. This is the one scenario where a branch from something other than main is correct:
git checkout -b hotfix/payment-timeout v2026.08.26
# fix, commit, PR against the tag's branch, CI runs
git tag -a v2026.08.26-hotfix.1
git push origin v2026.08.26-hotfix.1
The hotfix tag deploys to production. Then, and this is the step teams forget, cherry-pick the fix back to main:
git checkout main
git cherry-pick <hotfix-commit-sha>
Skip the cherry-pick and next week's regular release silently reverts your fix. If you take one thing from this section, make the cherry-pick part of the written hotfix procedure, not something the on-call engineer has to remember at 11pm.
Release notes from conventional commits
Changelogs written by hand are always late and usually wrong. The fix is to make commit messages carry the information and let a tool assemble them. Conventional commits give you the format: feat: add invoice CSV export, fix: handle payment provider timeout, with feat!: marking breaking changes. Since squash merges turn PR titles into commit messages, enforcing the format on PR titles is enough.
A commit-msg hook keeps the format honest locally:
#!/bin/sh
# .git/hooks/commit-msg
pattern='^(feat|fix|chore|docs|refactor|test|perf)(\(.+\))?!?: .+'
if ! head -1 "$1" | grep -qE "$pattern"; then
echo "Commit message must follow conventional commits, e.g. 'feat: add export'"
exit 1
fi
Run the same check in CI with commitlint so the hook is a convenience rather than the only line of defense. At release time, a changelog generator such as git-cliff or release-please turns the commits since the last tag into grouped release notes. Nobody writes them, and they are never out of date.
Branch protection that enforces the workflow without bureaucracy
The workflow only works if it cannot be bypassed on a bad day. On GitHub, protect main with exactly these rules: require a pull request with one approving review, require the CI status checks to pass, require branches to be up to date before merging, and block force pushes and deletions. Apply the rules to administrators too, because the person most likely to push directly to main under pressure is the founder.
Resist the temptation to add more. Two required reviewers, mandatory review from code owners on every path, and merge queues all have their place in larger organizations, but at 3 to 15 engineers each extra rule mostly adds waiting. Add a rule only after its absence has caused a real incident.
The GitHub Actions pipeline
Here is a condensed version of the pipeline that runs the whole model, for a PHP backend, adjust the steps to your stack:
name: ci
on:
pull_request:
push:
branches: [main]
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/php-cs-fixer check
- run: vendor/bin/phpstan analyse
- run: vendor/bin/phpunit
deploy-staging:
needs: gate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./bin/deploy staging
deploy-production:
needs: gate
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./bin/deploy production
The environment: production line gives you an audit trail of production deploys, and optionally a manual approval gate if your compliance situation requires one. The structure is the point: one gate job that every path shares, staging fed by main, production fed by tags.
Where teams actually get stuck
In our experience the git mechanics are the easy part. The hard part is the discipline around them: slicing work small enough for two-day branches, trusting flags enough to merge unfinished code, and cleaning up flags afterwards. Those are habits, and habits take a few months to settle.
If your team is somewhere in that transition, or your current workflow has grown into something nobody can explain, we can help. We review branching, CI, and release setups as part of our code quality consulting, and we build delivery pipelines like the one above for clients as part of custom software development engagements. Write to hello@wolf-tech.io or visit wolf-tech.io to see how we work.

