Cleanly separating dev, staging, and prod
Teams that never cleanly separate branches from deployment environments eventually push untested code straight to production by accident. This article shows how Magento teams can map branches unambiguously to dev, staging, and prod, promote code through environments via merge and tag instead of rebase, keep configuration out of the shared history, and lock down branches and pipelines per environment.
Table of Contents
- 1. Why Git branches should map directly to deployment environments
- 2. Promoting code instead of rewriting history: merge and tag progression
- 3. Trunk-based vs. Git Flow for Magento agency teams
- 4. Environment-specific configuration: env.php must never be committed
- 5. Generating app/etc/env.php from environment variables at deploy time
- 6. Release tags and rollback strategy
- 7. Branch protection: required pull request reviews and CI checks per environment
- 8. CI/CD pipeline triggers per branch
- 9. Anti-pattern vs. recommended pattern side by side
- 10. Summary
- 11. FAQ
1. Why Git branches should map directly to deployment environments
Once a Magento team runs more than one environment, dev for daily development, staging for acceptance, prod for the live store, the question becomes which code lands where. The most robust answer is a fixed, unambiguous mapping between branch and environment: a push to develop automatically deploys to dev, a merge into staging deploys to staging, and only main or a release tag on it deploys to prod. This unambiguity removes the most common source of error in multi-environment setups: manually guessing which commit is currently running where.
A well-known real-world reference for this pattern is Adobe Commerce Cloud (formerly Magento Commerce Cloud): it ships out of the box with the branches integration, staging, and production, and every push to one of these branches automatically triggers a deployment to the environment of the same name, each with its own database, search index, and environment variables. Additional branches spin up temporary integration environments on demand. Even without Adobe Commerce Cloud, this principle can be reproduced with self-hosted deploy scripts or GitHub Actions/GitLab CI, as long as the branch-to-environment mapping is documented and consistently followed by the team.
2. Promoting code instead of rewriting history: merge and tag progression
The core principle of multi-environment branching is this: code is promoted through environments, never rewritten. A commit that has been tested on dev must arrive bit-for-bit identical on staging and later on prod, otherwise you effectively test a different version than the one that goes live. That is achieved through fast-forward merges or merge commits from develop into staging into main, followed by a release tag on the final state. What you consistently avoid is cherry-picking individual commits between environment branches and rebasing commits that have already been promoted.
Cherry-picking creates a new commit hash for content that is functionally identical, so Git loses the relationship between the state on dev and the state on prod, and later merges produce unnecessary conflicts. Rebasing a branch that has already been merged into staging retroactively rewrites its history and invalidates commits that were already signed or already tested. The rule is simple: once a commit has reached an environment, it is immutable and is only ever passed along via merge, never reapplied via rebase or cherry-pick.
# Promote code through environments via merge, never via rebase or cherry-pick
$ git checkout develop
$ git pull origin develop
# Feature is done and tested on dev, promote to staging with a merge commit
$ git checkout staging
$ git merge --no-ff develop -m "Promote develop to staging"
$ git push origin staging
# CI runs smoke tests against staging; once approved, promote to production
$ git checkout main
$ git merge --no-ff staging -m "Promote staging to production"
# Tag the exact commit that goes live, never a re-created equivalent
$ git tag -a v2026.07.1 -m "Release 2026.07.1: checkout fix, catalog import"
$ git push origin main --follow-tags
3. Trunk-based vs. Git Flow for Magento agency teams
Trunk-based development relies on short-lived feature branches that get merged into main multiple times a day, combined with feature flags for unfinished functionality. For agency teams juggling several Magento stores in parallel and shipping frequent, small, isolated changes, this significantly reduces merge conflicts and keeps integration continuous. The environment branches develop, staging, and main still exist, but feature branches themselves live only hours to a few days.
Git Flow, with its dedicated release/* and hotfix/* branches, fits better for projects with longer stabilization phases, for example large Magento version upgrades or store migrations where release preparation spans days or weeks while the next version is already being developed in parallel. For most ongoing agency projects with weekly or daily deployments, a simplified variant is more practical: trunk-based development for features, extended with dedicated hotfix/* branches that branch directly off main and, once fixed, get merged both into main and back into develop, so no hotfix gets lost at the next regular release.
4. Environment-specific configuration: env.php must never be committed
app/etc/env.php holds database credentials, the crypt key, cache backend configuration, session storage settings, and MAGE_MODE. Every one of these settings is environment-specific: dev often runs MAGE_MODE=developer with a filesystem cache, prod runs MAGE_MODE=production with Redis or Valkey backends. If env.php were committed, either production credentials would end up in the repository, or the dev configuration would accidentally overwrite the prod settings on every deployment. Both are serious security and operational risks, as covered in more depth in the article on sensitive data in the repository.
The clean separation: env.php belongs in .gitignore consistently and is never committed, not even as a template with placeholders. app/etc/config.php, on the other hand, which only contains enabled modules and scalars without credentials, absolutely belongs in the repository, because it should be identical across environments. Rather than encoding environment differences through branches or committed files, they are resolved through environment variables injected by the deploy pipeline or cloud platform at deploy time, never through the commit history.
5. Generating app/etc/env.php from environment variables at deploy time
Instead of maintaining env.php manually per environment, a deploy hook regenerates the file on every deployment from environment variables stored as secrets in the CI/CD pipeline or directly on the cloud platform. Adobe Commerce Cloud does exactly this automatically via .magento.env.yaml and the build/deploy deploy hooks. On self-hosted setups, a simple shell or PHP script in the deploy process takes over the same job before bin/magento setup:upgrade runs.
It is important that this script is part of the pipeline configuration and can therefore live in the repository, because it does not contain any secrets itself, only references their names. The actual values, database password, Redis host, crypt key, come exclusively from the secret store of the CI/CD platform or the cloud environment. That keeps the history free of sensitive data while every environment still receives a correctly configured env.php without anyone maintaining it by hand.
#!/usr/bin/env bash
# deploy/generate-env-php.sh: build app/etc/env.php from CI/CD secrets,
# never commit the generated file itself
set -euo pipefail
php -r '
$config = [
"db" => [
"connection" => [
"default" => [
"host" => getenv("DB_HOST"),
"dbname" => getenv("DB_NAME"),
"username" => getenv("DB_USER"),
"password" => getenv("DB_PASSWORD"),
],
],
],
"crypt" => ["key" => getenv("MAGE_CRYPT_KEY")],
"cache" => [
"frontend" => [
"default" => [
"backend" => "Cm_Cache_Backend_Redis",
"backend_options" => [
"server" => getenv("REDIS_HOST"),
"port" => getenv("REDIS_PORT"),
],
],
],
],
"session" => ["save" => "redis"],
"MAGE_MODE" => getenv("MAGE_MODE") ?: "production",
];
file_put_contents("app/etc/env.php", "<?php\nreturn " . var_export($config, true) . ";\n");
'
echo "app/etc/env.php generated for environment: ${MAGE_MODE:-production}"
6. Release tags and rollback strategy
Every production release should be marked with an annotated Git tag, for example v2026.07.1, placed directly on the merge commit that landed on main. Annotated tags (git tag -a) additionally store the tagger, date, and a message, unlike lightweight tags that merely point to a commit. These tags serve as the anchor point for rollbacks: instead of reverting a broken commit after the fact or rewriting history, you simply redeploy the previous tag when something goes wrong.
This tag-based rollback strategy is considerably safer than a git revert under time pressure, because the previous state was already fully tested and no new, untested combination of reverts is introduced. The same logic applies to hotfixes: a hotfix branch branches off the current production tag, gets fixed, gets merged, gets tagged again (for example v2026.07.2), and is deployed exclusively through that new tag. That way it always stays traceable exactly which commit state was live at any given point in time, which is invaluable during incident analysis.
# Tag a production release right after promotion to main
$ git checkout main
$ git tag -a v2026.07.1 -m "Release 2026.07.1"
$ git push origin v2026.07.1
# List releases in chronological order for a quick rollback overview
$ git tag --sort=-creatordate | head -5
v2026.07.1
v2026.06.3
v2026.06.2
# Rollback: redeploy the previous known-good tag, do not revert on main
$ git checkout v2026.06.3
$ ./deploy/deploy.sh production
# Hotfix branching from the currently live tag
$ git checkout -b hotfix/checkout-500 v2026.07.1
# ... fix, commit, merge to main ...
$ git tag -a v2026.07.2 -m "Hotfix: checkout 500 error"
$ git push origin main --follow-tags
7. Branch protection: required pull request reviews and CI checks per environment
Without technical enforcement, any branch-to-environment convention is just an agreement that eventually gets broken by accident, usually through a direct push to main under time pressure. Branch protection rules in GitHub or GitLab prevent that structurally: for main and staging you can enforce a required pull request, a minimum number of reviewer approvals, required status checks (PHPStan, PHPCS, unit tests), and a ban on force pushes. For main, it is also worth applying these rules to administrators, so no exception slips through via role assignment.
The strictness of the rules should scale with how critical the environment is: develop can stay comparatively open to allow fast iteration, staging requires at least one review plus a green pipeline, and main additionally requires the branch to be up to date before merging (require branches to be up to date), to avoid silent merge conflicts. This graduated strictness mirrors exactly the risk a bad merge would cause in that particular environment.
# .github/branch-protection.yml (documentation-as-code, applied via API/Terraform)
branches:
develop:
required_pull_request_reviews:
required_approving_review_count: 1
required_status_checks:
strict: false
contexts: ["phpcs", "phpstan-level-5"]
allow_force_pushes: false
staging:
required_pull_request_reviews:
required_approving_review_count: 1
required_status_checks:
strict: true
contexts: ["phpcs", "phpstan-level-5", "unit-tests", "integration-tests"]
allow_force_pushes: false
enforce_admins: false
main:
required_pull_request_reviews:
required_approving_review_count: 2
require_code_owner_reviews: true
required_status_checks:
strict: true
contexts: ["phpcs", "phpstan-level-5", "unit-tests", "integration-tests", "smoke-tests"]
allow_force_pushes: false
enforce_admins: true
restrictions:
users: []
teams: ["release-managers"]
8. CI/CD pipeline triggers per branch
The branch-to-environment mapping only becomes reliable once it is automated in the CI/CD pipeline instead of being deployed manually over SSH. A push to develop triggers a job that deploys to dev and runs the fastest but least strict checks there. A merge into staging additionally triggers integration tests and an automated smoke test against checkout. A new tag on main triggers the production deployment, often behind a manual approval gate before the job actually runs.
This trigger structure makes deployment behavior predictable for the whole team: nobody needs to remember which command runs against which environment, the pipeline decides purely from branch names and tag patterns. That drastically reduces human error and makes every deployment traceable in the CI/CD log, including commit hash, tag, and the person responsible, which saves critical time during rollbacks under pressure.
# .gitlab-ci.yml (excerpt): deploy jobs triggered by branch/tag rules
stages: [test, deploy]
deploy_dev:
stage: deploy
script: ./deploy/deploy.sh dev
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
deploy_staging:
stage: deploy
script:
- ./deploy/deploy.sh staging
- ./deploy/smoke-test.sh https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
deploy_production:
stage: deploy
script: ./deploy/deploy.sh production
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
when: manual
environment:
name: production
url: https://www.example.com
9. Anti-pattern vs. recommended pattern side by side
Most problems in multi-environment branching trace back to a handful of recurring anti-patterns that all share the same core: a shortcut around the intended promotion path, usually chosen under time pressure, that feels faster in the moment but erodes trust in the deployment process over time. The following table lines up the most common of these shortcuts against the more robust alternative.
| Area | Anti-pattern | Recommended pattern | Why it matters |
|---|---|---|---|
| Shipping a hotfix | Cherry-picking the commit straight onto main | Tag-based promotion via merge and a new tag | Keeps commit hashes identical across all environments |
| Environment configuration | Committing env.php with credentials | env.php in .gitignore, generated from env vars at deploy time | No credentials ever enter the Git history |
| Emergency fix under pressure | Direct push to main without review | Protected branch, pull request, and required checks | Prevents unreviewed code from reaching production |
| Already promoted commit | Rebasing it afterward onto staging/main | Fast-forward or --no-ff merge, history stays stable | Prevents invalid commit hashes after deployment |
| Production deployment | Manual SSH deployment by feel | Branch/tag-triggered CI/CD pipeline with an approval gate | Traceable, repeatable deployment |
Consistently moving these five patterns to the right column already eliminates most of the deployment risk in a multi-environment setup. The remaining work is usually just getting the team used to the new discipline, since branch protection and CI/CD triggers then enforce the rules technically instead of merely documenting them.
Mironsoft
Branching strategies, CI/CD pipelines, and deployment workflows for Magento teams
Ready to set up multi-environment branching properly?
We help Magento teams map dev, staging, and prod unambiguously to branches, introduce release tags and rollback processes, and set up branch protection and CI/CD pipelines tailored to each environment.
Branching workshop
Choose and introduce trunk-based, Git Flow, or a hybrid model suited to your team size
CI/CD pipeline setup
Set up branch- and tag-triggered deployments for dev, staging, and prod
Branch protection audit
Review existing repositories for safe merge and review rules
10. Summary
Multi-environment branching only works when branches map unambiguously to environments and code is promoted through those environments exclusively via merge and tag, never via cherry-pick or rebase of commits that have already been promoted. Adobe Commerce Cloud demonstrates a proven, directly transferable reference pattern with its integration, staging, and production branches. Trunk-based development with short-lived feature branches fits most agency teams better than classic Git Flow, provided hotfix branches are added on top.
Environment-specific configuration such as app/etc/env.php should never enter the Git history; it is generated from environment variables at deploy time instead. Release tags mark every production state and enable rollbacks through redeployment rather than risky reverts. Branch protection rules and branch- or tag-triggered CI/CD pipelines enforce all of these rules technically, instead of merely documenting them as a team convention that eventually gets broken under time pressure.
Multi-environment branching for Magento at a glance
Branch mapping
develop to dev, staging to staging, main to prod. Adobe Commerce Cloud as the reference pattern.
Promotion, not rewrite
Merge and tag instead of cherry-pick and rebase. Commit hashes stay identical across all environments.
Keep configuration separate
Never commit env.php, generate it from env vars at deploy time instead of templates in Git.
Protection & CI/CD
Required pull requests, required checks per environment, branch/tag-triggered deployments.