into your deployment pipeline
setup:upgrade is the only command in the Magento deployment process that directly changes the production database. It does not run in the build job, it is not a test, it runs on the live system. A team that does not secure this step is building a deployment process that regularly needs luck when database migrations are involved.
Table of Contents
- 1. What setup:upgrade actually does
- 2. When setup:upgrade is needed, and when it is not
- 3. --keep-generated: the most important flag
- 4. Database backup before the upgrade
- 5. Order of operations in the deploy job: when setup:upgrade runs
- 6. Expand-contract: zero downtime for database changes
- 7. Rollback after setup:upgrade: the limits
- 8. Maintenance mode: when it is actually needed
- 9. Upgrade strategies compared
- 10. Summary
- 11. FAQ
1. What setup:upgrade actually does
setup:upgrade is the Magento command that applies all pending database schema changes and data patches. It reads the current database version of every installed module from the patch_list table, compares it with the target version defined in the module classes, and runs every migration that has not yet been executed, in sequence. After applying the schema changes it also regenerates the generated code, unless --keep-generated is set.
The important part: setup:upgrade changes the production database directly and irreversibly. There is no automatic rollback for database migrations that have already run. If a migration fails halfway through, the database can end up in an inconsistent state. If the new code creates an incompatible database structure, the old code, after a filesystem rollback, may no longer work correctly. That is the core of the challenge: setup:upgrade is the point where code deployment and database state become inseparable.
Despite these risks, setup:upgrade is indispensable. It is the mechanism through which Magento keeps the database at the state the code expects. The task is not to avoid setup:upgrade, but to build it into the deployment process in a way that catches failures before they reach the shop and keeps a rollback possible even after a migration has run.
2. When setup:upgrade is needed, and when it is not
setup:upgrade does not need to run on every deployment. It is only required when the database schema version of one or more modules has changed. That is the case when new modules are installed, existing modules are updated, or data patches are added. For a pure frontend release, new CSS, a new Tailwind theme, changed templates, no database schema is affected and setup:upgrade can be skipped.
How do you know whether setup:upgrade is needed? Magento does not offer a direct command that checks this before execution. A pragmatic approach: running setup:upgrade with --dry-run is not available in Magento 2.4.x. The best method is to compare the versions in the setup_module table against the versions expected by the module classes, which can be scripted as a pre-deploy check. For teams without this automation, the rule is: run setup:upgrade on every deployment, but always with --keep-generated to avoid unnecessary code regeneration.
3. --keep-generated: the most important flag
The --keep-generated flag is the most important argument that must be passed to setup:upgrade in a CI/CD context. Without it, Magento regenerates all generated code after every database migration, which takes several minutes and happens on the production server, not in the build job. With --keep-generated, this step is skipped because the code was already generated correctly in the build stage.
The prerequisite for --keep-generated: the generated code in the artifact must be complete and correct. If new modules have been added, their classes must have been fully generated by DI Compile in the build stage. If --keep-generated is used without the build job including the new generated code, classes will be missing, which leads to PHP errors in production. That is why the order build job, then deploy job, is essential: DI Compile first in the build, then setup:upgrade with --keep-generated in the deploy.
# Deploy stage: setup:upgrade with database backup and safety checks
deploy:production:
stage: deploy
script:
- |
# === Phase 1: Pre-deployment checks and database backup ===
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" bash << 'PRECHECK'
set -euo pipefail
# Verify database connectivity before starting deployment
cd "${DEPLOY_PATH}/current"
php bin/magento db:status 2>/dev/null || {
echo "ERROR: Cannot connect to database. Aborting deployment."
exit 1
}
# Create timestamped backup before any schema changes
BACKUP_FILE="${DEPLOY_PATH}/db-backups/pre-deploy-$(date +%Y%m%d-%H%M%S).sql.gz"
mkdir -p "${DEPLOY_PATH}/db-backups"
mysqldump \
--single-transaction \
--quick \
--routines \
--triggers \
"${DB_NAME}" | gzip > "${BACKUP_FILE}"
echo "Database backup created: ${BACKUP_FILE}"
ls -lh "${BACKUP_FILE}"
PRECHECK
# === Phase 2: Transfer and prepare new release ===
RELEASE_ID="$(date +%Y%m%d-%H%M%S)"
RELEASE="${DEPLOY_PATH}/releases/${RELEASE_ID}"
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p ${RELEASE}"
rsync -az --delete \
--exclude="pub/media" --exclude="var/log" \
--exclude="var/session" --exclude="app/etc/env.php" \
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${RELEASE}/"
# Link shared paths
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" bash -s << LINKS
set -euo pipefail
ln -sfn "${DEPLOY_PATH}/shared/app/etc/env.php" "${RELEASE}/app/etc/env.php"
ln -sfn "${DEPLOY_PATH}/shared/pub/media" "${RELEASE}/pub/media"
ln -sfn "${DEPLOY_PATH}/shared/var/log" "${RELEASE}/var/log"
ln -sfn "${DEPLOY_PATH}/shared/var/session" "${RELEASE}/var/session"
LINKS
# === Phase 3: Run setup:upgrade before activating release ===
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" bash -s << UPGRADE
set -euo pipefail
cd "${RELEASE}"
# Run database migrations, keep the pre-built generated code
php bin/magento setup:upgrade \
--keep-generated \
--no-interaction
# Flush all caches after schema changes
php bin/magento cache:flush
echo "setup:upgrade completed successfully"
UPGRADE
# === Phase 4: Atomic symlink switch ===
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" \
"ln -sfn ${RELEASE} ${DEPLOY_PATH}/current"
echo "Release ${RELEASE_ID} is now live"
4. Database backup before the upgrade
A database backup before setup:upgrade is the only safeguard against a failed migration that leaves the database in an inconsistent state. The backup must be created before running setup:upgrade, not after the deployment. A backup created after the deployment already contains the new schema changes and cannot be used for a full rollback.
The backup process must run with --single-transaction so mysqldump can back up the database consistently without blocking other transactions. Without this flag, mysqldump locks tables during the backup, which causes interruptions in the shop for large databases. The backup directory must live outside the releases/ directory and have its own retention policy. Backup files taken right before a database migration become worthless after the next successful deployment, but should still be kept for at least seven days.
5. Order of operations in the deploy job: when setup:upgrade runs
The correct order of operations in a Magento deploy job is critical. setup:upgrade must run after the artifact has been transferred and after the shared symlinks have been set, since it needs access to vendor/, generated/ and app/etc/env.php. But it must run before the new release is activated through the symlink switch, because the database structure must match the new code version before the first HTTP request reaches the new code.
In practice: the new release directory is prepared, symlinks are set, setup:upgrade has run the database migrations, and only then is the current symlink pointed at the new release. This sequence minimizes the window in which new code and the old database structure would be active at the same time. At the moment of the symlink switch, database and code are in sync.
6. Expand-contract: zero downtime for database changes
The expand-contract pattern is the only method for implementing truly zero downtime database migrations in Magento. The basic idea: database changes are split into two phases. The first phase (expand) adds new columns, tables, or indexes without removing or renaming existing ones. The old code keeps working with the new structure because it simply ignores the new columns. The new code writes to the new columns and optionally reads from both the old and the new ones.
The second phase (contract) removes the old columns that the new code no longer needs. It only runs in a later release, once it is certain that no active code still uses the old columns. At least one stable release without a rollback must sit between expand and contract. This pattern allows a code rollback after phase 1: the old code runs fine against the expanded database structure because it ignores the new columns. After phase 2 (contract), a rollback is no longer possible, since the old code would be missing the columns that were removed.
# Expand-Contract pattern for zero-downtime database migrations
# Phase 1 (Expand) - additive changes only, backward-compatible
# app/code/Vendor/Module/Setup/Patch/Schema/AddNewColumn.php
# This migration adds a new column without removing the old one.
# Old code ignores the new column. New code uses it.
# A rollback of the code is still safe after this migration.
# Example: Adding a nullable column with default value
# ALTER TABLE catalog_product_entity
# ADD COLUMN new_field VARCHAR(255) DEFAULT NULL;
# Phase 2 (Contract) - only after at least one stable release
# app/code/Vendor/Module/Setup/Patch/Schema/RemoveOldColumn.php
# This migration removes the column the old code used to write.
# Only execute after verifying no active code reads the old column.
# After this migration, rollback of the code is NO LONGER SAFE.
# Example: Removing deprecated column
# ALTER TABLE catalog_product_entity
# DROP COLUMN old_field;
# GitLab pipeline check: detect potentially breaking migrations
check:migrations:
stage: test
script:
- |
# Scan for DROP or RENAME statements in new migration patches
# These are Contract phase, they require special approval
DANGEROUS_PATTERNS="DROP COLUMN|DROP TABLE|RENAME COLUMN|RENAME TABLE|MODIFY COLUMN"
if grep -rPi "${DANGEROUS_PATTERNS}" \
app/code/*/Setup/Patch/ 2>/dev/null; then
echo "WARNING: Potentially breaking schema changes detected."
echo "Verify these are Contract-phase migrations with approval."
echo "Rollback may not be possible after these migrations run."
else
echo "No breaking schema changes detected in this release."
fi
7. Rollback after setup:upgrade: the limits
A rollback after a successful setup:upgrade is the hardest problem in the Magento deployment process. A file rollback, pointing the current symlink back at the previous release, is trivial. But if the database migrations included backward-incompatible changes, the old code may no longer work correctly against the new database structure. This is the moment where a database backup becomes indispensable: only a full restore of the database to the state before the migration allows a genuine rollback.
That is why the choice of what kind of database migration to run is an architectural decision with direct consequences for rollback capability. Additive migrations (new columns, new tables, new indexes) are rollback-safe because the old code ignores the new structures. Destructive migrations (renaming columns, dropping columns, renaming tables) are not rollback-safe without a database restore. The expand-contract pattern is the method for splitting destructive migrations into two rollback-safe phases.
8. Maintenance mode: when it is actually needed
Magento's maintenance mode (bin/magento maintenance:enable) shows every visitor a maintenance page and blocks HTTP requests. For zero downtime deployments it should be avoided. The symlink switch with a database structure already prepared is the path that requires no maintenance mode. There are, however, scenarios where a short maintenance window is unavoidable: complex, multi-step database migrations that run for a long time and would leave the shop serving inconsistent data in the meantime, or migrations that rename tables and thereby make them invisible to existing transactions.
When maintenance mode is used, it must stay active for as short a time as possible: enable it right before setup:upgrade, and disable it immediately after the symlink switch and cache:flush. Never enable it at the start of the deploy job, since that would put the shop into maintenance mode for the entire duration of the deployment, which depending on setup time can mean five to fifteen minutes. The IP whitelist feature (maintenance:enable --ip=X.X.X.X) lets the team test the shop internally while maintenance mode is active.
9. Upgrade strategies compared
How setup:upgrade is built into the deployment process has direct consequences for rollback capability, downtime, and risk in the event of a deployment failure.
| Strategy | Rollback capability | Downtime | Recommendation |
|---|---|---|---|
| No backup, upgrade directly | No DB rollback option | Minimal | Never in production |
| Backup before upgrade, then upgrade | DB restore possible | Minimal | Minimum standard |
| Expand-contract + backup | File rollback without DB restore | None (phase 1) | Recommended for all teams |
| Maintenance mode + upgrade | Possible with backup | Downtime during maintenance | Only when unavoidable |
| --keep-generated not set | DI Compile in production | Several minutes | Never in prod without a CI build |
The table makes clear that the expand-contract pattern combined with a backup is the only strategy that delivers zero downtime together with genuine rollback capability. The minimum standard, a backup before the upgrade, is better than no backup at all, but it requires a database restore for the rollback, which depending on database size can take anywhere from minutes to hours. Expand-contract avoids this need entirely by making migrations backward compatible.
10. Summary
Building setup:upgrade safely into the deployment process means: always with --keep-generated, always with a database backup beforehand, always after the artifact transfer and before the symlink switch. The database migrations must follow the expand-contract pattern to allow rollbacks without a database restore. Maintenance mode should only be used when the migration is so complex that it cannot be carried out without a maintenance window.
The key takeaway: setup:upgrade is not an automatic command that can be run uncritically on every deployment. It is an operation with direct consequences for the production database, the backup strategy, and rollback capability. A team that treats setup:upgrade with this level of seriousness and secures it accordingly builds a deployment process that stays controlled and free of surprises, even during complex database migrations.
Magento setup:upgrade, the essentials at a glance
--keep-generated is mandatory
Always run with --keep-generated. The generated code comes from the build job, avoid DI Compile in production. Without the flag: several minutes of code regeneration on the server.
Backup first
mysqldump --single-transaction before setup:upgrade, the only safeguard against inconsistent database states after a failed migration.
Expand-contract
Additive migrations (phase 1) enable file rollbacks without a DB restore. Destructive migrations (phase 2) only in the next release, after stable operation.
Order of operations
Backup, transfer artifact, set shared symlinks, setup:upgrade, cache:flush, symlink switch. Never in a different order.