setup:upgrade in a Deploy
What setup:upgrade actually does, why it wants maintenance mode, how to split schema from data, and why two nodes must not both run it.
bin/magento setup:upgrade [--keep-generated]bin/magento setup:upgrade is the command that makes the database match the code you just deployed. It applies the table differences declared in db_schema.xml, runs schema patches, runs any data patch not yet recorded in the patch_list table, updates the setup_module table, and rewrites the module list in app/etc/config.php.
Why maintenance mode
While it runs, the schema is between two shapes. A request served against a half-migrated table does not always fail cleanly; it can write rows that the finished schema cannot represent. Maintenance mode holds requests out for the duration, which is why the migration window is the only real downtime in a symlink deploy.
Splitting schema from data
Two narrower commands exist. setup:db-schema:upgrade applies schema only, and setup:db-data:upgrade applies data patches only. Additive schema changes can often run before the swap with the site up, leaving just the data step inside the window. Check first with bin/magento setup:db:status, which exits 0 when nothing is pending, so a release with no migration takes no downtime at all.
The deploy that ran it twice
Nothing coordinates two web nodes. If both run setup:upgrade at the same time, both see the same data patch as unapplied and both execute it, and you get every seeded row twice. Run it once, from one node, after that release's code is on disk.
Example
# deploy/migrate.sh <release> - run ONCE, from ONE node, never in parallel
set -e
cd "/var/www/releases/$1"
# exit 0 = database already matches the code; 1 or 2 = something is pending
if bin/magento setup:db:status; then
echo "no migration in this release; skipping the maintenance window"
exit 0
fi
bin/magento maintenance:enable
# --keep-generated: do not delete the generated/ directory CI compiled
bin/magento setup:upgrade --keep-generated
bin/magento maintenance:disable
bin/magento cache:flush
# output of setup:db:status when there is nothing to do:
#
# All modules are up to date.When to use it
- Decide whether a release needs a maintenance window at all, from setup:db:status.
- Move an additive schema change out of the downtime window.
- Make a release's data patch safe to retry after a failed deploy.
- Stop two web nodes from migrating the same database at the same time.
More examples
Schema before the swap, data inside the window
Reach for this when the migration window is the only downtime left and it is still too long. The split is safe only while the schema step is additive: a renamed or dropped column breaks the running old code immediately. Note that neither narrow command rewrites app/etc/config.php, so a release that enables a new module still needs either setup:upgrade or a committed config.php applied with app:config:import.
# deploy/migrate-split.sh <release>
set -e
cd "/var/www/releases/$1"
# Additive only: new tables, new nullable columns, new indexes.
# The old code does not know about them, so it keeps working while this runs.
bin/magento setup:db-schema:upgrade
# Now the short window: swap the code, then move the data.
/usr/local/bin/release.sh "$1"
bin/magento maintenance:enable
bin/magento setup:db-data:upgrade
bin/magento maintenance:disable
bin/magento cache:flushA data patch that survives being run twice
Write patches like this and the double-run accident becomes survivable rather than a data-repair job. insertOnDuplicate needs a unique key on the column you match, declared in db_schema.xml. Note the signatures: getDependencies() is static on the interface and getAliases() is not, which is the one thing people get wrong when writing a patch from memory.
<?php
// app/code/Vendor/Loyalty/Setup/Patch/Data/SeedTiers.php
declare(strict_types=1);
namespace Vendor\Loyalty\Setup\Patch\Data;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class SeedTiers implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup
) {
}
public function apply()
{
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable('vendor_loyalty_tier');
// 'code' carries a unique index, so a second run updates instead of inserting
$connection->insertOnDuplicate(
$table,
[
['code' => 'silver', 'threshold' => 500],
['code' => 'gold', 'threshold' => 2000],
],
['threshold']
);
return $this;
}
public static function getDependencies()
{
return [];
}
public function getAliases()
{
return [];
}
}Keep yourself inside the maintenance window
Useful when a migration is long enough that you want to click through the storefront before letting customers back in. The allowlist is stored in var/.maintenance.ip, so clear it afterwards with --none; an address left in that file is a confusing bug the next time someone reports that maintenance mode is not working for them.
# your own address stays allowed, so you can watch the migration land
bin/magento maintenance:allow-ips 203.0.113.17
bin/magento maintenance:enable
bin/magento maintenance:status
# ... migrate ...
bin/magento maintenance:disable
bin/magento maintenance:allow-ips --none
# Set exempt IP-addresses: 203.0.113.17
# Enabled maintenance mode
# Status: maintenance mode is active
# Disabled maintenance mode
# Set exempt IP-addresses: none
Discussion