· 5 min read
Branch-to-domain mapping, in practice
Treat domains as separate destinations, not mirrors. Then the branch-per-domain pattern stops fighting with itself.
By Marten
Open any branching-strategy article and count how many of them describe one branch feeding multiple domains. Now count how many of them describe what happens when the domains drift apart. That second number is usually zero.
Most teams I work with start with the same model: main goes to production, staging goes to staging, feature branches go to preview URLs. Then one domain gets ahead. Then the other catches up. Then they diverge in a way that nobody planned. Then the branching strategy stops describing what actually happens.
Branch-to-domain mapping only works if you stop treating domains as copies of each other and start treating them as different destinations.
What the pattern actually says
The implicit contract, when teams set up branch-per-domain:
mainbranch →example.comstagingbranch →staging.example.compreview/foobranch →foo.preview.example.com
Push to the branch, the corresponding domain updates. Simple.
The contract people think they have:
- Whatever is on
mainis also onstaging, a bit ahead - Whatever is on a preview is what will eventually be on
main - Branches are ordered in time; domains reflect where each environment is in that timeline
The contract the system actually gives you:
- Each branch is a separate pointer to a commit
- Each domain is a separate deployment target
- There is no enforced ordering between them
- The only thing keeping them “in order” is the social convention that people merge in a specific direction
The mismatch is where the pain comes from. Someone hotfixes main directly. Someone merges staging into main without pulling. Someone resets staging from main because “staging was broken.” Each of those actions is individually defensible. The sum, a week later, is that main and staging contain different code, neither is a superset of the other, and “deploy the same thing to prod and staging” stops having a clear meaning.
The workable version
I have come to a small set of rules that make branch-per-domain actually work. They are more restrictive than what most teams do, and they remove some flexibility in exchange for the property that the system does not lie to you.
Rule one: branches are the ordering, domains are the destination. The branch name says where in the pipeline the code is. The domain name says what user it serves. Those are different things, and trying to make one mean the other is where people hurt themselves.
So instead of thinking “staging is ahead of production,” think “staging branch points at whatever commit we want staging.example.com to serve.” That commit may or may not be a descendant of the production commit. Usually it is. Sometimes it is not. The system does not care.
Rule two: fast-forward only, into domain branches. You never merge into main or staging with a merge commit. You rebase. This makes the branch history linear, which makes “is commit X in this branch” a simple question with a yes-or-no answer. With merge commits, the answer is “sort of, through these three merge bubbles.”
Rule three: the deploy pipeline resolves by commit SHA, not branch name. When someone says “deploy commit abc123 to staging”, the pipeline deploys abc123. It does not deploy “the tip of the staging branch.” The branch is a pointer to pick a default SHA for the deploy. The deploy itself is always in terms of SHAs.
This sounds pedantic. It matters because branches move. Between the moment you click “deploy staging” and the moment the deploy script runs, someone could push to that branch, and you would end up deploying a different commit than the one you clicked for. Resolving to a SHA up front removes that race.
What about previews
Preview URLs for feature branches are the cleanest part of this model. Each feature branch gets its own subdomain, automatically, and dies when the branch is deleted.
The implementation that works: a wildcard DNS record (*.preview.example.com → the same server), a nginx config that routes by subdomain to a directory named after the branch slug, and a deploy step that creates that directory when a branch is pushed. The cleanup step runs when the branch is deleted upstream. This is about 80 lines of shell and one nginx map block.
The trap: long-lived “preview” branches. A feature branch that lives six weeks is not a feature, it is a fork. Preview URLs encourage this when they work too well. I now auto-delete preview environments after 14 days of branch inactivity, whether the branch is still around or not. If someone needs to keep one, they can extend it by re-pushing.
The cost of trying to hide the difference
The most common anti-pattern I see is teams trying to present branch-per-domain as if all the domains are just different slices of the same timeline. “Production is at this commit, staging is at this commit, preview is at this commit, but really they’re all heading to the same place.”
They are not.
Production and staging can run the same code or different code. Trying to guarantee they always run the same code leads to one of two bad places: either you only ever deploy to production through staging (which slows everything down and rules out hotfixes), or you cheat on the guarantee in ways the system does not know about.
It is more honest to accept that each domain has its own current commit, and that the job of the deploy system is to show you what each of those commits is, at a glance, and to make it easy to point any of them at any commit you want. What the branches do is record the “default” commit for each domain and document the intent.
The one thing this gets you
Once branches and domains are decoupled in your head, the uncomfortable deploys become routine. Hotfixing production while staging is mid-feature is not a special case anymore, it is just “point main at a different commit.” Rolling staging back to match production is a one-liner. Running two preview environments against the same backend is straightforward.
The pattern is simple. The adjustment is mostly in how you talk about it, not in what the tools do.