Building Magento setup:upgrade Safely Into Your Deployment Pipeline
AI generated
CI/CD
.yml
GitLab · Magento · setup:upgrade · Database Migrations · Zero Downtime
Building Magento setup:upgrade safely
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.

15 min read setup:upgrade · --keep-generated · backup · expand-contract · rollback Magento 2.4 · MySQL · GitLab CI · zero downtime

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.

11. FAQ: Magento setup:upgrade in the deployment process

1Does setup:upgrade have to run on every deployment?
No. Only for database schema changes, new modules, module updates with schema patches. Pure frontend or code releases without schema changes: skip setup:upgrade.
2What does --keep-generated do?
Skips DI Compile after the migration. The generated code comes from the build job, no regeneration needed on the server. Saves several minutes in production.
3What happens if setup:upgrade fails?
The database can end up inconsistent. current still points at the old release, so the shop keeps running. Restore the database from backup before retrying the deployment.
4What is the expand-contract pattern?
Expand: add new structures, remove nothing, file rollback stays safe. Contract: remove old structures in the next release. Together: zero downtime for DB migrations.
5Is a file rollback possible after setup:upgrade?
Only after expand migrations. The old code ignores the new columns, so a file rollback is safe. After contract migrations: a database restore is necessary.
6When should maintenance mode be used?
Only for unavoidably complex migrations. Enable it right before setup:upgrade, disable it right after cache:flush. As short as possible, never at the start of the deploy job.
7Why --single-transaction for the backup?
Without --single-transaction, mysqldump locks tables, which blocks shop transactions. With the flag: a consistent backup without interrupting live operation.
8Where to store database backups?
Outside releases/, in db-backups/ with its own retention cleanup. Keep for at least 7 days and replicate to external storage.
9Before or after the symlink switch?
Before the symlink switch. The database must match the new code version before the first request reaches the new code. Afterward: switch atomically.
10What if setup:upgrade takes a very long time?
During the migration, current still points at the old release, so the shop keeps running unchanged. Only after successful completion is the symlink switched. No shop outage from long migrations.