Rolling Back
Code rolls back with a symlink; the database does not, so keep schema changes additive and restore a backup before you need to.
ln -sfn $PREVIOUS /var/www/current.tmp && mv -Tf /var/www/current.tmp /var/www/currentRolling back code is easy. Rolling back a database is not. With releases in their own directories, the code rollback is the swap in reverse: point the symlink at the previous release, reload PHP-FPM, flush the cache, restart the workers. It takes seconds, and it works because the previous release is still on disk, complete, with its own vendor/ and generated/.
What does not come back
A data patch that has run is recorded in the patch_list table, and moving the symlink neither removes that record nor undoes the rows it wrote. Declarative schema changes have already been applied. pub/media is shared, so uploads made since the deploy stay. Orders placed in those minutes are real orders.
Deploy so that you can roll back
Make schema changes additive: add a nullable column in one release, write to it in the next, drop it a release later. Then a code rollback is a complete rollback, and the unused column is harmless. The rollbacks that hurt are the ones where a single release both changed the schema and depended on the change.
The backup
The bundled setup:backup and setup:rollback commands were deprecated in 2.3.0; do not build a plan on them. Take a mysqldump --single-transaction before the migration, and restore it into staging on a schedule. A dump nobody has restored is not a backup.
Example
# deploy/rollback.sh - put the previous release back on the symlink
set -e
PREVIOUS=$(readlink -f /var/www/previous) # written by release.sh
test -d "$PREVIOUS"
/usr/local/bin/workers.sh stop
ln -sfn "$PREVIOUS" /var/www/current.tmp
mv -Tf /var/www/current.tmp /var/www/current
systemctl reload php8.4-fpm
/var/www/current/bin/magento cache:flush
/usr/local/bin/workers.sh start
echo "code is back on $PREVIOUS - the database is not"
# Flushed cache types:
# config
# layout
# block_html
# ...
# code is back on /var/www/releases/20261004T1733 - the database is notWhen to use it
- Put the previous release back after a bad deploy, in seconds, with one symlink.
- Plan a release whose schema change can be undone by rolling back the code alone.
- Prove a nightly database dump can actually be restored, before you need it.
- Work out what a rollback will not recover: orders, uploads, applied patches.
More examples
The dump you take before the migration
Run this as its own deploy step so the dump is tied to a release name and you know exactly which code it belongs to. --single-transaction keeps InnoDB consistent without a global lock, which matters because this runs while the site is up. Skipping the session and report_event tables cuts the size a long way and neither is worth restoring. The dump lands off the application disk, because a backup on the volume you are trying to recover is not one.
# deploy/dump.sh <release> - before migrate.sh, from the node that migrates
set -e
OUT="/backups/pre-$1-$(date -u +%Y%m%dT%H%M).sql.gz"
# --single-transaction: consistent snapshot without locking the storefront out
# --no-tablespaces: avoids needing the PROCESS privilege
mysqldump --single-transaction --quick --no-tablespaces \
--ignore-table=magento.session \
--ignore-table=magento.report_event \
-h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" magento | gzip > "$OUT"
ls -lh "$OUT"
# -rw-r--r-- 1 deploy deploy 1.8G /backups/pre-20261005T0910.sql.gzAn additive schema change, so the code rollback is enough
This is the shape to aim for in any release you might have to undo: the column can exist while the old code runs, so rolling the code back needs no database work at all. `bin/magento setup:upgrade` creates the column; adding it does not depend on the whitelist, because Magento consults db_schema_whitelist.json only for destructive operations. Regenerate it anyway with `bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_Loyalty`, since a later release cannot drop or shrink the column unless it is listed in app/code/Vendor/Loyalty/etc/db_schema_whitelist.json.
<!-- app/code/Vendor/Loyalty/etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="sales_order" resource="sales" engine="innodb">
<!-- nullable, with no default behaviour change: old code ignores it -->
<column xsi:type="int" name="loyalty_tier_id" unsigned="true" nullable="true"
comment="Loyalty tier at the time of purchase"/>
<index referenceId="SALES_ORDER_LOYALTY_TIER_ID" indexType="btree">
<column name="loyalty_tier_id"/>
</index>
</table>
</schema>Rehearse the restore on a schedule
The point of the drill is to find the problems that only appear on restore: a dump that was truncated, a privilege the restore user lacks, an indexer that will not rebuild. Run it on a schedule and treat a failure as an incident, because the alternative is discovering it during one. Reindexing afterwards is part of the test; search and category listings come from the indexes, not the tables, and a restored database with stale indexes is not a working site.
# deploy/restore-drill.sh - weekly, into staging, against the newest dump
set -e
DUMP=$(ls -1t /backups/pre-*.sql.gz | head -n1)
echo "restoring $DUMP into staging"
gunzip -c "$DUMP" | mysql -h "$STAGING_HOST" -u "$STAGING_USER" -p"$STAGING_PASS" magento_staging
cd /var/www/staging
bin/magento setup:upgrade --keep-generated
bin/magento indexer:reindex
bin/magento cache:flush
# count one thing you can check against production
mysql -h "$STAGING_HOST" -u "$STAGING_USER" -p"$STAGING_PASS" magento_staging \
-e 'SELECT COUNT(*) AS orders FROM sales_order'
# restoring /backups/pre-20261005T0910.sql.gz
# orders
# 418233
Discussion