The last few articles set up the folder structure that scales for a full stack SaaS: where the frontend and backend live, how a monorepo shares types, where config sits. None of that matters much if the team can't move code through Git without stepping on each other. This is the last piece of Module 1 in the Full Stack SaaS Masterclass, the series that started with choosing the stack and has been building the foundation ever since. Git workflow is one of those topics every team has an opinion on, usually inherited from wherever they worked last. Some of those opinions come from companies with fifty engineers and three release trains a day. Applying that process to a team of two or three people building a SaaS product is a good way to spend more time managing branches than writing features. I've settled on a workflow that's boring on purpose. It borrows the useful parts of GitFlow and trunk-based development, drops the ceremony neither needs, and scales down cleanly to a solo founder and up reasonably far before it needs to change. Trunk-based, not GitFlow GitFlow gives you develop, release/*, hotfix/*, and long-lived feature branches that merge back through a maze of rules. It was designed for teams shipping infrequently, with formal release cycles and multiple versions in production at once. A SaaS with one production environment and continuous deploys doesn't have that problem, so most of GitFlow's structure is solving something you don't have. What I use instead is trunk-based development: one long-lived branch, main, that is always deployable. Every feature branches off main, gets reviewed, and merges back into main within a day or two. There's no develop branch acting as a staging area, because that staging area just becomes a second place bugs can hide and a second merge conflict waiting to happen. The rule that makes this work is short-lived branches. A feature branch that lives for two weeks accumulates drift from main, and the merge at the end becomes its own project. A feature branch that lives for a day or two barely drifts, so the merge is quick and the diff is reviewable. If a feature is big enough that it can't land in a day or two, that's a signal to break it into smaller, independently mergeable pieces, not a signal to let the branch live longer. git checkout main git pull origin main git checkout -b feat/invoice-pdf-export # work, commit, push git push -u origin feat/invoice-pdf-export # open PR, get it reviewed, merge same day if possible Enter fullscreen mode Exit fullscreen mode Branch naming and what actually goes in a commit Branch names are cheap to standardize and useful once you have more than one branch open. I use a type/short-description format: feat/invoice-pdf-export fix/tenant-scoping-in-search chore/upgrade-nestjs-10 refactor/extract-billing-service Enter fullscreen mode Exit fullscreen mode The prefix tells anyone scanning the branch list what kind of change it is before they open a single file. It also maps directly onto commit message conventions, which is where the real payoff shows up. I use Conventional Commits for the commit message itself: git commit -m "feat(billing): add PDF export for invoices" git commit -m "fix(auth): scope refresh token lookup to tenant" git commit -m "chore(deps): bump nestjs to v10" Enter fullscreen mode Exit fullscreen mode The type and optional scope in parentheses aren't decoration. They're what a tool like semantic-release or changelog generator reads to decide whether a merge is a patch, minor, or major version bump, and what goes in the changelog under which heading. Even if you're not auto-generating changelogs yet, writing commits this way costs nothing and means you can add that tooling later without rewriting history. The message itself should describe the why when the diff doesn't make it obvious. "fix(auth): scope refresh token lookup to tenant" tells you what and where. If the fix involved a non-obvious tradeoff, that belongs in the commit body, not just the PR description, because the PR description doesn't travel with git blame. Pull requests as the actual quality gate The pull request, not the branch, is where quality gets enforced. On a small team it's tempting to skip review because "everyone knows what's going on," but a PR does two things that verbal context doesn't: it creates a written record of the decision, and it forces the CI pipeline to run before the code reaches main. A minimal PR template keeps reviews fast because the reviewer isn't reconstructing context from scratch: ## What changed ## Why ## How to test ## Screenshots (if UI) Enter fullscreen mode Exit fullscreen mode Four sections, no essay required. On a two-person team, "why" is often one sentence. That's fine. The point is making sure the next person (including future you) doesn't have to guess. Branch protection on main is worth setting up on day one, not after the first incident caused by a direct push: # GitHub branch protection settings (not a workflow file, just documenting intent) required_status_checks: - lint - typecheck - test - build required_pull_request_reviews: required_approving_review_count: 1 enforce_admins: true Enter fullscreen mode Exit fullscreen mode On a team of two, required_approving_review_count: 1 means you review each other. On a team of one, you can relax that specific rule, but keep the required status checks. CI catching a broken build before it reaches main doesn't need a second human; it just needs the pipeline to run. Merge strategy: squash, and why it matters more than it sounds This is a small decision with an outsized effect on how usable your Git history stays. I squash-merge every PR into main. The feature branch might have eleven commits, six of which say "wip" or "fix typo," but the squashed commit that lands on main is one clean entry with the PR's title and description. # GitHub: set the merge button to "Squash and merge" as the repository default Enter fullscreen mode Exit fullscreen mode The payoff is that git log --oneline main reads like a changelog: one line per shipped feature or fix, not a scroll of intermediate commits. When you need to find when something broke, git bisect walks through meaningful units of change instead of half-finished intermediate states, which makes each bisect step actually testable in isolation. The tradeoff is that you lose the granular commit history within a feature branch once it's merged. For a small team, that history was rarely useful after the fact anyway. If you genuinely need fine-grained history for a specific piece of work (a complex migration, say), that's a case for a well-organized set of commits within the PR and a merge commit instead of squash for that one PR, not a reason to abandon squash-merge as the default. Hotfixes and the one exception to the branching rule Trunk-based development assumes main is always deployable, which holds until it doesn't: a bug ships, it's affecting real users, and you need a fix out now. The instinct to skip process here is understandable and also how a rushed fix introduces a second bug. The pattern that holds up is the same one used for every other change, just faster: branch from main, fix, PR, merge, deploy. The speed comes from urgency and focus, not from skipping review. git checkout main git pull origin main git checkout -b fix/expired-token-500 # minimal, targeted fix git push -u origin fix/expired-token-500 # fast review, one approval, merge, deploy Enter fullscreen mode Exit fullscreen mode If your CI pipeline and deploy process are fast (and for a small SaaS they should be), a hotfix through this path takes minutes, not hours. If it doesn't, that's a sign to invest in deploy speed, not to build a separate emergency process that bypasses review. Production pitfalls worth naming explicitly A few failure modes show up repeatedly on small teams, mostly from copying process that doesn't fit the team's size: Long-lived feature branches are the single biggest source of painful merges. If a branch has been open more than a few days, rebase it onto main regularly rather than letting the drift compound, or split the work into smaller PRs. Force-pushing to a shared branch after someone else has pulled it rewrites history out from under them. git push --force-with-lease on your own feature branch, after a rebase, is fine. Force-pushing main is not something a small-team workflow should ever need. Committing directly to main "just this once" erodes the one guarantee the whole workflow depends on: that main only changes through a reviewed PR with passing CI. It only takes one exception for that guarantee to stop being trustworthy. Skipping the PR description because "it's a small change" is a habit that costs you exactly when you need it: six months later, trying to understand why a particular line exists. Key takeaways Trunk-based development with short-lived feature branches fits small teams better than GitFlow, which solves problems (multiple release trains, long release cycles) most SaaS teams don't have. Consistent branch naming (type/description) and Conventional Commits cost little and pay off the moment you add changelogs or semantic versioning. Pull requests are the quality gate, not a formality; branch protection with required status checks enforces that even on a team of one or two. Squash-merging keeps main's history readable and git bisect useful, at the cost of granular in-branch history you rarely need later. Hotfixes should follow the same reviewed path as every other change, just faster; skipping review under pressure is how a fix becomes a second incident. The workflow should scale down to a solo founder and up a fair distance before it needs to change; adding process ahead of team size is the same mistake as adding infrastructure ahead of scale. FAQ Should a small team use GitFlow or trunk-based development? Trunk-based development, for most small SaaS teams. GitFlow's extra branches (develop, release/*, hotfix/*) solve problems around multiple release trains and long release cycles that a continuously deployed SaaS with one production environment usually doesn't have. What's the right commit message format for a small team? Conventional Commits (type(scope): description) works well because it's cheap to adopt and unlocks tooling like automated changelogs and semantic versioning later, without requiring you to rewrite history to get there. Should I squash-merge or use a merge commit for pull requests? Squash-merge as the default. It keeps main's history to one entry per shipped change, which makes git log and git bisect far more useful. Use a merge commit only for the rare PR where the internal commit history is genuinely worth preserving. How should a small team handle production hotfixes in Git? The same way as any other change, just faster: branch from main, make a minimal fix, open a PR, get one review, merge, deploy. Skipping review under pressure is a common way a rushed fix introduces a second bug. How long should a feature branch stay open before merging? A day or two, ideally. Branches that live longer accumulate drift from main, which turns the eventual merge into its own project. If a feature can't land in a day or two, break it into smaller, independently mergeable pieces. Do I need branch protection rules on a two-person team? Yes, at least for required status checks (lint, typecheck, test, build). Required review count can be relaxed for a solo founder, but a CI gate that blocks a broken build from reaching main doesn't need a second human to be worth having. Further reading Previous: A Folder Structure That Scales for Full Stack SaaS Next: JWT Authentication in NestJS Start of series: Choosing the Right Tech Stack for Your SaaS About the Author Hi, I'm Aman Singh — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications. I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS. Portfolio: https://amanksingh.com GitHub: https://github.com/amansingh1501 Product: https://vowerole.com Email: aman97aman@gmail.com
A Git Workflow for Small Teams
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.