Tags, pipelines, and branch mapping instead of FTP
Deploying Magento stores through FTP uploads or manual file copying from a developer's own machine destroys any traceability of what code is actually live. This article shows how annotated Git tags serve as an immutable production truth, how CI/CD pipelines automatically react to branch and tag pushes, how a clear branch-to-environment mapping cleanly separates staging, review environments, and production, and why a rollback under pressure is nothing more than redeploying the previous tag.
Table of Contents
- 1. Why Git must be the single source of truth for deployments
- 2. Tag-based release strategy: annotated Git tags as production truth
- 3. Deploy-from-branch pipelines: CI/CD triggers on branches and tag patterns
- 4. Why deploying from a local checkout is risky
- 5. Branch-to-environment mapping for a Magento project
- 6. Feature branches and ephemeral review environments
- 7. Magento-specific deployment steps in the CI/CD pipeline
- 8. Rollback strategy: redeploy instead of reverting under pressure
- 9. Git-based deployment compared to manual methods
- 10. Summary
- 11. FAQ
1. Why Git must be the single source of truth for deployments
In many Magento projects, the deployment reality starts out as pure improvisation. A developer copies changed files via FTP or rsync straight from their own machine to the production server, often under time pressure and without any traceable documentation of which version is actually live. Git exists in such setups as a backup tool, but not as the authoritative source of truth for production code. That's the fundamental mistake: once FTP uploads or manual file synchronization become the official path to the server, there is no longer a reliable answer to the question of which commit is actually deployed right now.
Treating Git as the single source of truth means every deployment takes exactly one Git reference, a commit hash or a tag, as its input, never the local filesystem state of a developer's machine. That may sound like a formality at first, but it's the decisive lever for reproducibility: deploying the same tag twice, regardless of which machine or which CI runner does it, produces exactly the same artifact state. This determinism in the deployment process is practically unattainable with FTP-based workflows, because they always let the invisible, unversioned state of a local hard drive leak into the process.
2. Tag-based release strategy: annotated Git tags as production truth
A branch is a moving pointer. main or develop points to a different commit today than it did an hour ago, as soon as someone pushes. For production deployments, that very mobility is the problem: if production deploys "the current state of main," it becomes impossible to reliably reconstruct which commit was actually live at a given point in time, especially once several merges have landed on main between two deployments. An annotated Git tag like v2.4.1 solves this problem because, unlike a branch, it is never meant to move. A tag permanently marks exactly one commit, complete with tagger, timestamp, and message as its own Git object.
The naming convention follows semantic versioning: MAJOR.MINOR.PATCH, where MAJOR signals incompatible changes, MINOR new, backward-compatible functionality, and PATCH bug fixes. For a Magento project that might mean v2.5.0 for a new checkout feature and v2.5.1 for the hotfix that follows it. The decisive rule: once a tag has been pushed, it is never moved or overwritten, git tag -f is off-limits in production workflows. Only that guarantees that v2.4.1 references exactly the same code today as it will in six months, and that every line in the deployment log maps unambiguously to an immutable code state.
# Create an annotated tag following semantic versioning (MAJOR.MINOR.PATCH)
$ git tag -a v2.5.0 -m "Release 2.5.0: new checkout step, cart API fixes"
# Push the tag explicitly, tags are not pushed by a plain "git push"
$ git push origin v2.5.0
# Optional: sign the tag with GPG for a verifiable release trail
$ git tag -s v2.5.1 -m "Hotfix: fix price rounding in cart totals"
# List existing tags sorted by semantic version, newest first
$ git tag --list "v*" --sort=-v:refname
# Inspect what an annotated tag actually is: a full Git object
$ git cat-file -p v2.5.0
object 9f2a1c4b8e7d3f5a6b9c0d1e2f3a4b5c6d7e8f90
type commit
tag v2.5.0
tagger Jane Deploy <jane@mironsoft.de> 1752313200 +0200
Release 2.5.0: new checkout step, cart API fixes
# Never move a tag once it has been pushed, -f is forbidden in production
$ git tag -d v2.5.0 && git tag -a v2.5.0 -m "..." # do not do this after push
3. Deploy-from-branch pipelines: CI/CD triggers on branches and tag patterns
CI/CD pipelines typically subscribe to two kinds of Git events: push events on specific branches, and push events on tags matching a defined pattern. GitHub Actions and GitLab CI let you declare, in the on: block or under rules:, exactly which job runs on which event, for example on.push.branches: [main, develop] for staging and on.push.tags: ["v*.*.*"] for production. The key effect: nobody manually clicks a deploy button and decides on the spot what gets released. Instead, deployment becomes a deterministic function of a Git event, fully documented in pipeline code and traceable within version control itself.
Within the pipeline, jobs typically follow the same stages: build (install Composer and Node dependencies, compile assets), test (PHPStan, PHPCS, PHPUnit), package (produce a versioned artifact or Docker image tagged with the Git tag), and deploy (transfer the artifact to the target environment and run the Magento CLI commands there). For the production job it's worth adding an environment protection gate, such as manual approval from a second reviewer, before the tag-triggered job actually runs against the live environment, without losing the declarative, event-driven nature of the pipeline.
name: Deploy Magento Store
on:
push:
branches: [main, develop] # any merge triggers a staging deploy
tags: ["v*.*.*"] # only semver tags trigger production
jobs:
deploy-staging:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy branch head to staging
run: ./deploy.sh staging "${{ github.sha }}"
deploy-production:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production # requires manual approval gate
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Deploy immutable tag to production
run: ./deploy.sh production "${{ github.ref_name }}"
4. Why deploying from a local checkout is risky
Deploying directly from a developer's local checkout bypasses every control a pipeline would otherwise enforce. Uncommitted local changes, a quickly tested fix that never made it into a commit, end up in production unnoticed, with nobody but the developer ever having seen that code. Just as critical are build artifacts deliberately excluded from the repository via .gitignore, compiled Tailwind CSS, bundled JavaScript, or the vendor/ folder. These files exist only locally, in whatever version was last built on that one machine, and can land on the production server in a completely different, undocumented state.
On top of that comes the problem of inconsistent toolchain versions: if developer A's machine runs PHP 8.4 with Composer 2.7, while developer B's runs PHP 8.3 with an older Composer version, both produce different, potentially incompatible vendor/ trees for the same composer.lock. And finally, the audit trail is missing entirely: who deployed what code, from which machine, and when? Without a CI/CD log, there's no reliable answer to that question during an incident, which makes root cause analysis considerably harder.
5. Branch-to-environment mapping for a Magento project
A practical mapping for a Magento project usually looks like this: every merge into main or develop automatically triggers a deployment to the staging environment, no manual approval needed, because staging is understood as a constantly shifting, integrative state. Only Git tags matching the pattern v[0-9]+.[0-9]+.[0-9]+ trigger a deployment to production, usually further protected by a manual approval gate. Feature branches, in turn, deploy to ephemeral review or integration environments that are spun up dynamically per branch and torn down automatically after merge or once a deadline expires.
This mapping cleanly separates three distinct levels of trust. Staging always reflects the current integration state and is suited for QA and stakeholder reviews, but it's deliberately volatile, a new merge can change what's visible there at any moment. Production stays completely decoupled from that churn and only moves in explicit, deliberately chosen steps, every new tag is a conscious decision, not an automatic side effect of a merge. This separation prevents an accidentally premature merge into main from automatically triggering a production deployment.
; deploy-mapping.ini: branch/tag to environment mapping for the CI/CD runner
[environment.staging]
trigger = branch
refs = main, develop
auto_deploy = true
approval = none
magento_mode = developer
[environment.production]
trigger = tag
ref_pattern = v[0-9]+.[0-9]+.[0-9]+
auto_deploy = true
approval = required, min_reviewers=1
magento_mode = production
[environment.review]
trigger = branch
refs = feature/*
auto_deploy = true
approval = none
ttl_hours = 72
magento_mode = developer
6. Feature branches and ephemeral review environments
Ephemeral review environments solve a problem that a plain staging environment cannot cover: when several feature branches are developed in parallel, they compete for the same state on a single staging instance and overwrite one another. A dedicated, automatically created environment per branch, often its own container stack with its own subdomain such as feature-1234.review.mironsoft.de, solves this by giving every branch its own isolated Magento stack, including its own database. Reviewers, product owners, or QA can click through the actual functionality live, before any decision on merging into develop is even made.
What makes this practical is consistent teardown automation: as soon as a feature branch is merged or deleted, or a defined time-to-live expires, the associated environment must disappear automatically, otherwise orphaned, never-updated hosts pile up and become a security risk in their own right. Just as important: the configuration of these environments, Docker Compose definitions or Kubernetes manifests, belongs in the Git repository itself, not in a manually maintained server list. Only then does the infrastructure, not just the application code, stay versioned and reproducible through Git.
7. Magento-specific deployment steps in the CI/CD pipeline
Inside a deployment job, the Magento CLI commands must run in a fixed order, because each step builds on the result of the previous one. First composer install --no-dev --optimize-autoloader, to install exactly the production dependencies pinned in composer.lock, without dev packages. Then bin/magento setup:upgrade, which applies the database schema and data patches, followed by setup:di:compile, which builds the generated dependency injection configuration for production mode, a step that must run after the Composer install because it depends on the freshly installed code. Only then can setup:static-content:deploy build the frontend assets for Hyvä and every configured store.
The sequence ends with cache:flush, so no stale cache entries survive from the previous deployment. It's also worth wrapping the entire deployment block in maintenance:enable and maintenance:disable, so customers don't see inconsistent intermediate states during the short window in which schema changes run. It's essential to run all of this on exactly the same PHP and Composer version as the target environment, ideally inside the same Docker image that later runs in production, rather than preparing the steps manually on a developer machine with a different toolchain.
#!/usr/bin/env bash
# deploy.sh: Magento deployment steps run inside the CI/CD job,
# executed against the exact PHP/Composer version used in production
set -euo pipefail
TARGET_REF="$1" # e.g. v2.5.0 (a Git tag) or a branch name
ENVIRONMENT="$2" # staging | production | review
echo "Deploying ${TARGET_REF} to ${ENVIRONMENT}"
git checkout "${TARGET_REF}"
bin/magento maintenance:enable
composer install --no-dev --optimize-autoloader --no-interaction
bin/magento setup:upgrade --keep-generated
bin/magento setup:di:compile
bin/magento setup:static-content:deploy de_DE en_US -f
bin/magento cache:flush
bin/magento maintenance:disable
echo "Deployment of ${TARGET_REF} to ${ENVIRONMENT} finished successfully"
8. Rollback strategy: redeploy instead of reverting under pressure
Because production is only ever deployed from immutable tags, a rollback reduces to a single, clearly defined operation: redeploy the last known-good tag, not write new commits. That's the decisive difference from a team running production straight off main: there, a rollback under pressure often means a frantic git revert in the middle of an incident, commits written under time pressure that themselves still have to run through the entire pipeline, tests and compile steps included, before they can even go live. Redeploying a tag that has already been tested and has already run successfully in production, by contrast, needs no new code change at all, just a rerun of a known, deterministic process.
In practice, a dedicated rollback script that reads the previous production tag from the deployment history and re-triggers the same deploy job is worth having, instead of improvising the rollback manually. One limitation remains: database migrations aren't automatically backward-compatible, a setup:upgrade that adds new columns or tables can't simply be undone by redeploying an older tag. That's why the rollback strategy must include the discipline of writing schema changes that stay compatible with the previous code state, additive rather than destructive migrations, so a rollback at the application level doesn't fail against an incompatible database.
#!/usr/bin/env bash
# rollback.sh: redeploy the previous known-good production tag,
# never edit or force-push tag history under incident pressure
set -euo pipefail
ENVIRONMENT="production"
CURRENT_TAG=$(cat /var/deploy/production/current_tag)
PREVIOUS_TAG=$(git tag --list "v*" --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -n1)
if [ -z "${PREVIOUS_TAG}" ]; then
echo "No previous tag found, manual intervention required" >&2
exit 1
fi
echo "Rolling back production from ${CURRENT_TAG} to ${PREVIOUS_TAG}"
./deploy.sh "${PREVIOUS_TAG}" "${ENVIRONMENT}"
echo "${PREVIOUS_TAG}" > /var/deploy/production/current_tag
echo "Rollback complete: production now runs ${PREVIOUS_TAG}"
9. Git-based deployment compared to manual methods
The table below puts manual FTP or local checkout deployment side by side with Git-tag-based CI/CD deployment, across the criteria that actually determine downtime and recovery speed during an incident.
| Criterion | Manual FTP/local deployment | Git-tag-based CI/CD deployment |
|---|---|---|
| Traceability | No reliable link between server state and commit | Every deploy maps to an exact, immutable tag |
| Rollback speed | Manual restore from backup or ad-hoc fix under time pressure | Redeploy the previous tag in minutes, already-tested code |
| Consistency across environments | Depends on each developer's local PHP/Composer version | Identical toolchain in the CI runner for staging and production |
| Risk of production outages | Uncommitted changes and missing build artifacts can go live | Only tested, fully committed code reaches production |
| Audit trail | No log of who deployed what code and when | Complete pipeline log including tag, timestamp, and actor |
Taken together, the comparison shows that Git-based deployment isn't bureaucratic overhead, it's the precondition for a team being able to react in minutes rather than hours during an incident. Every row in the table ultimately describes the same mechanism: an immutable, versioned reference point replaces a fragile process that depends on individual people.
Mironsoft
Git workflows, CI/CD pipelines, and deployment strategies for PHP and Magento teams
Ready to build a reliable deployment pipeline for your Magento store?
We help development teams introduce tag-based release strategies, deploy-from-branch pipelines, and rollback procedures that free production deployments from FTP uploads and risky local checkouts.
CI/CD pipeline setup
Building GitHub Actions or GitLab CI pipelines for Magento deployments
Tag & branch strategy
Establishing semantic versioning and branch-to-environment mapping
Rollback safety
Tested rollback procedures instead of frantic reverts under pressure
10. Summary
A Git-based deployment strategy replaces improvisation with a deterministic, traceable process. Git becomes the single source of truth for production code, annotated tags following semantic versioning immutably mark which code state is actually live, and CI/CD pipelines automatically react to branch pushes and tag patterns instead of being triggered manually. Deploying from a developer's local machine, with uncommitted changes, missing build artifacts, and inconsistent toolchain versions, disappears entirely.
A clean branch-to-environment mapping clearly separates staging, ephemeral review environments, and production, while the Magento CLI commands, composer install, setup:upgrade, setup:di:compile, setup:static-content:deploy, and cache:flush, run inside the pipeline in a fixed order, on exactly the toolchain also used in production. And because production always points to an immutable tag, a rollback becomes a simple, already-proven operation: redeploy the previous tag instead of writing new commits under time pressure.
Git-Based Deployment Strategy: The Essentials at a Glance
Git as single source of truth
Every deployment references an exact commit or tag, never a local filesystem state.
Tag-based releases
Annotated, semantically versioned tags like v2.4.1 immutably mark what runs in production.
Branch-to-environment mapping
main/develop to staging, tags to production, feature branches to ephemeral review environments.
Rollback via redeploy
The previous, already-tested tag is redeployed instead of writing frantic new commits.