Zero Downtime Magento 2 Deploys on a Single Server
The default way many Magento stores are deployed looks like this: enable maintenance mode, pull the code, run setup:upgrade, setup:di:compile and setup:static-content:deploy, disable maintenance. The store is down for the whole build, which can be several minutes, and if compilation fails halfway the store stays down while someone fixes it.
You do not need Kubernetes or Adobe Commerce Cloud to avoid that. A single server can do zero downtime deploys with a pattern that is older than Magento: build each release in its own folder, and switch a symlink when it is ready. This article describes that pattern for Magento Open Source and Adobe Commerce 2.4.
The folder layout
/var/www/shop/
current -> releases/20260110-1530
releases/
20260109-1000/
20260110-1530/
shared/
app/etc/env.php
pub/media/
pub/sitemap/
var/log/
var/report/
var/export/
var/import/
Each release is a complete Magento code base. Everything that must survive between releases lives in shared and is symlinked into each release: env.php, media, logs, reports and any folder your modules write files to. The web server document root points to /var/www/shop/current/pub.
Caches and sessions should not live on disk inside the release. Put them in Redis or Valkey. Otherwise every switch starts with an empty cache in a new folder and old sessions are lost.
Prepare config.php for a build without a database
setup:di:compile and setup:static-content:deploy can run without touching the live database, but static content deploy needs to know your scopes, themes and locales. Dump that configuration into app/etc/config.php once and commit it:
bin/magento app:config:dump scopes themes i18n
From then on, any change to store views, themes or locales must be dumped again and committed, or the build will deploy static files for the wrong set.
Step 1: build the release
#!/usr/bin/env bash
set -euo pipefail
BASE=/var/www/shop
RELEASE=$BASE/releases/$(date +%Y%m%d-%H%M%S)
REF=${1:-main}
git clone --depth 1 --branch "$REF" [email protected]:shop/magento.git "$RELEASE"
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
ln -sfn $BASE/shared/app/etc/env.php app/etc/env.php
rm -rf pub/media var/log var/report
ln -sfn $BASE/shared/pub/media pub/media
ln -sfn $BASE/shared/var/log var/log
ln -sfn $BASE/shared/var/report var/report
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f --jobs=4 en_US de_DE
All of this happens while the store keeps serving the previous release. If composer install or compilation fails, the script stops and nothing changes for customers. Use the locales your store views and admin users actually need. A missing locale means missing static files in that store.
Make sure the release is in production mode. The mode is stored in env.php as MAGE_MODE, and since env.php is shared, set it once with bin/magento deploy:mode:set production --skip-compilation.
Step 2: decide whether the database needs an upgrade
This is the step that decides between zero downtime and a short maintenance window. Most releases do not change the database. Run from the new release:
set +e
php bin/magento setup:db:status
DB_STATUS=$?
set -e
The command exits with 0 when schema and data are up to date and with 2 when an upgrade is required. Any other code is an error and should stop the deploy.
No database changes
Go straight to the switch. The new code runs against the same database as the old one, which is exactly what it was tested for.
Database changes
Here you need a short maintenance window, because the old code should not write to tables while they change.
if [ "$DB_STATUS" -eq 2 ]; then
php $BASE/current/bin/magento maintenance:enable
php bin/magento setup:upgrade --keep-generated
fi
--keep-generated is essential. Without it, setup:upgrade wipes the generated code you just compiled and the release would run without it. Maintenance is enabled from the old release, because that is the one serving traffic. The maintenance flag lives in var/, and since the new release has its own var, it goes live without the flag once the symlink switches.
The window now lasts as long as setup:upgrade, not as long as the whole build. With schema changes written to be backward compatible, many teams skip maintenance even here, but that is a decision to make per release after reading the dry run output.
Step 3: switch the symlink atomically
ln -sfn removes the old link and creates a new one. For a moment there is no link. The atomic way is to create a temporary link and rename it over the old one:
ln -s "$RELEASE" $BASE/current.tmp
mv -T $BASE/current.tmp $BASE/current
mv -T is GNU coreutils and does a single rename, which is atomic on the same filesystem.
If you enabled maintenance in step 2, the previous release still has the flag in its own var folder. Run maintenance:disable there after the switch. Otherwise a rollback later would bring the store back in maintenance mode.
Step 4: after the switch
cd $BASE/current
php bin/magento cache:flush
sudo systemctl reload php8.3-fpm
supervisorctl restart all
- Flush the cache so configuration and layout cached by the old release are rebuilt.
- Reload PHP-FPM. OPcache keeps compiled files keyed by path, and with symlinks it can keep serving the old release for a while. A reload clears it gracefully, without dropping requests in flight.
- Restart queue consumers and anything else that runs long lived PHP, otherwise they keep the old code in memory.
- Cron uses
current/bin/magento, so the next run picks up the new code automatically.
Nginx and the realpath
The sample Nginx configuration shipped with Magento passes $document_root to PHP. With a symlinked root, use $realpath_root instead, so PHP and OPcache see the resolved release path and do not mix files from two releases:
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
Rollback
If something is wrong after the switch, point current back at the previous release with the same two commands, flush the cache and reload PHP-FPM. That takes seconds because the old release is still on disk, fully compiled.
There is one limit. Rollback is only clean when the release did not change the database, or when the change was additive. Magento does not undo schema changes or data patches when you switch code back. If the new release dropped a column, the old code may break. This is the main reason to make schema changes backward compatible and to keep destructive changes for a later release.
Keep the last three to five releases and delete older ones at the end of each deploy. Each release with compiled code and static files takes real disk space.
Things that commonly go wrong
- Static content mismatch. The deployed version in
pub/static/deployed_version.txtbelongs to each release. If a CDN or Varnish caches HTML from the old release that points to old static paths, those files must still exist. Keeping the previous release on disk and the static signing enabled covers most cases. - Files written into the release. A module that writes to
pub/orvar/outside the shared folders loses its files on the next deploy. Find those paths and add them toshared. - config.php drift. A store view created in production that is not in the committed
config.phpgets no static files. Dump and commit after configuration changes. - Permissions. Build as the same user that runs PHP-FPM, or fix ownership before the switch. Root owned files in
generatedcause errors that only appear on the first request. - Config import. If the release changes settings stored in
config.phpand nosetup:upgraderan, apply them withbin/magento app:config:importafter the switch.
Tools that implement this pattern
You can write the script yourself as shown, which keeps everything visible. Deployer has a Magento 2 recipe that follows the same releases and shared layout, and other tools do something similar. Whatever you use, read what it runs. The principles stay the same: build outside live, touch the database only when needed, switch atomically, keep the previous release ready.
FAQ
Can Magento 2 be deployed without maintenance mode?
Yes, when the release does not change the database. Build the code in a separate folder, compile and deploy static files there, then switch a symlink. Check with setup:db:status whether an upgrade is needed.
What does --keep-generated do in setup:upgrade?
It stops setup:upgrade from deleting the generated folder. Use it when you already ran setup:di:compile during the build, otherwise you would lose the compiled code right before going live.
Do I need a database to run setup:static-content:deploy?
Not if app/etc/config.php contains scopes, themes and i18n settings, which you create with bin/magento app:config:dump scopes themes i18n. That lets you build on the server or in CI without touching production data.
How do I roll back a Magento deploy?
Point the current symlink back to the previous release, flush the cache and reload PHP-FPM. This is safe when the release did not change the schema in a way the old code cannot handle.
I am Rodrigo Biazzi, a Magento Certified Developer working with Magento and Adobe Commerce for more than 12 years. I am available for freelance Magento work, from single fixes to longer projects. If this is the kind of problem you have on your store, get in touch.
Related articles
Magento 2 Backup and Restore: What to Save and How
How to back up and restore a Magento 2 store: database dumps that import cleanly, media, env.php and the encryption key, offsite copies and tested restores.
Magento 2 CI/CD Pipeline with GitHub Actions
Build a Magento 2.4.8 CI/CD pipeline in GitHub Actions: Composer auth, coding standard, PHPStan, unit tests, a DB-free build and an atomic release deploy.
Magento 2 env.php vs config.php Explained
What goes in Magento 2 app/etc/env.php and config.php, how config precedence works, app:config:dump, --lock-env vs --lock-config and keeping secrets out of Git.
Magento 2 RabbitMQ Consumers That Don't Die Silently
Set up RabbitMQ message queues in Magento 2.4 the right way: queue XML files, consumers run by cron or supervisor, max-messages and how to spot a dead consumer.