Cloud: Releases and Zero-Downtime Deployments

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source and Adobe Commerce 2.4.9 (general availability 2026-05-12) and Mage-OS 3.6.0, PHP 8.5 in the container image, MySQL 8.4 on Amazon RDS, Kubernetes 1.36 or 1.37 on Amazon EKS and Helm 4.3. It was checked on 2026-10-29. It names no prices: the cost drivers it mentions (snapshots, a second database for a rehearsal) are things to measure, not figures to copy.

A Magento release is more than "new code": it can change tables, import configuration into the database, register new store views, make every cached object of the old code wrong for the new one, and change the URLs of the static files the browser holds. The single-server procedure of Upgrades (maintenance mode, Composer, setup:upgrade, compile, deploy, cache, leave maintenance mode) answers all of this with one writable tree and a closed shop. On a cluster the tree is an image that never changes, the shop is a dozen pods that are replaced a few at a time, and the cache, the sessions and the queues outlive every deployment. This page turns the sequence into a release pipeline that keeps the shop open for the routine release and closes it only for the release that cannot be done any other way.

The running example is Pocket Coffee with the names of Cloud: Reference Architecture: cluster pocketcoffee-prod, namespace pocketcoffee, Helm release pocketcoffee, chart pocketcoffee-magento (helpers pc.*), ECR repository pocketcoffee/magento, RDS instance pocketcoffee-prod with the users mg_app and mg_migrate, the modules PocketCoffee_RoastAdmin, PocketCoffee_Pay, PocketCoffee_Skeleton and PocketCoffee_Api, the store views es, en and us, and the workloads pocketcoffee-web, pocketcoffee-varnish, pocketcoffee-cron, pocketcoffee-consumer-<name>, pocketcoffee-migrate and pocketcoffee-warmup, in the account 123456789012 and the region eu-west-1.

Release gate of a Pocket Coffee Magento release: build, snapshot, the migrate Job and the exit code of setup:db:status, the question whether the old code runs on the new schema, the rolling update with or without maintenance mode, and the ways back

What a release changes in a Magento shop

Magento keeps the state of a release in four places, and each has its own trigger and its own failure mode. The release pipeline has to touch each exactly once and in an order that the old pods can survive.

What changes Where it lives How it is applied

Code, generated code and static files

The image (vendor/, generated/, pub/static/, built by setup:di:compile and setup:static-content:deploy)

A new image tag and a rolling update. Nothing to run in the cluster.

Schema and data of the modules

MySQL tables declared by db_schema.xml, the table patch_list of applied data and schema patches, the table setup_module

bin/magento setup:upgrade, once, as the migration user.

Shared configuration

app/etc/config.php (modules, scopes, themes, non-sensitive system configuration) in the image; the database tables store_website, store_group, store and theme

app:config:import, which setup:upgrade runs as its last step.

System-specific and sensitive configuration

app/etc/env.php (the ConfigMap pocketcoffee-env-php, which reads the environment) and the Secret pocketcoffee-magento

A Helm release: the ConfigMap changes with the chart, the pods restart.

Caches and indexes

Valkey (cache types), Varnish (full page cache), OpenSearch and the index tables

cache:clean and a warm-up after the rollout; indexers are the subject of Cloud: Cron, Queue Consumers and Indexers.

Two consequences set the shape of everything below. First, the database is changed before the new pods exist, so the old pods run, for the length of the Job and the rollout, against a schema that the new release already changed: every schema change has to be compatible with the old code or the release needs a maintenance window. Second, the configuration of the new release is imported into a database that the old pods are still using, which is harmless for a new module and visible for a theme or a store view.

What Adobe documents, and what is only for Adobe Commerce on cloud infrastructure

The Adobe documentation has three things that are called deployment, and they are easy to mix up when reading about "zero downtime".

Source What it describes Applies to a self-built image on EKS

Pipeline deployment (configuration guide, "deployment technical details")

The split into a development system, a build system and a production system, with app:config:dump, config.php in source control, env.php outside it, and the production sequence below.

Yes, in full. It is generic and also holds for Mage-OS, which ships the same commands.

Adobe Commerce on cloud infrastructure: deployment process (ece-tools, phases build, deploy and post-deploy)

A managed pipeline that runs these steps for you on Adobe’s infrastructure, with .magento.env.yaml variables.

No. It is a managed service; the variables it names belong to it and have no effect on EKS.

Zero downtime deployment on cloud infrastructure

ece-tools 2002.0.13 or later, static content generated in the build phase, cache cleaning moved to the post-deploy phase with WARM_UP_PAGES listing the pages to preload, SCD_MATRIX and SCD_THREADS; during the deploy phase connections are held for up to 5 minutes and then released.

No. Its ideas (static content in the build, cache work after the rollout, warm-up) are what this page does with an image, a Job and a rollout, but the connection holding is a feature of the cloud platform.

Adobe documents no blue/green procedure for Adobe Commerce or Magento Open Source. A search of the Experience League for "blue/green" finds it only in the documentation of Cloud Manager for Adobe Experience Manager, which is a different product. What exists is the pipeline sequence below, the zero-downtime configuration of the cloud platform, and the Kubernetes rolling update, which is what this page uses. A real blue/green (a second environment with its own database copy and a switch of the Gateway) is discussed in Blue/green and rolling releases as an option that you would build yourself, with the data problem that it brings.

The production sequence in Adobe’s pipeline documentation is, in this order: start maintenance mode, pull code and configuration, stop the queue workers (Adobe Commerce), app:config:import, setup:upgrade --keep-generated if components that change the schema were installed, set system-specific and sensitive values (config:set, config:sensitive:set or environment variables), flush the cache, end maintenance mode. On EKS the steps become: maintenance mode only for a breaking release (Maintenance mode on a cluster); code and config.php arrive in the image through helm upgrade; queue workers are stopped only for a breaking release (The release workflow); the import and the upgrade are the migrate Job; system-specific and sensitive values come from env.php reading the ConfigMap and the Secret, because there is no config:set on a read-only image; the cache is cleaned by the warm-up Job after the rollout (Cache clean, Varnish purge and warm-up); and ending maintenance mode is the last workflow step.

A word on the config:set options, because the question comes up. bin/magento config:set has three lock options in 2.4.9: --lock-env (-e) saves the value in app/etc/env.php and makes the field read-only in the Admin, --lock-config (-c) saves it in app/etc/config.php, to be shared with other installations, and --lock (-l) is deprecated in favour of --lock-env. Adobe’s pipeline page still shows the older --lock. In an image, neither file can be written at run time, so these commands belong on the development system, where their effect is committed (config.php) or becomes an environment variable (env.php); a value that the release must carry is set there, followed by app:config:dump. The precedence that Adobe documents is the reason this works: environment variables first, then env.php, then config.php, then the database, then the default.

The gate: setup:db:status

bin/magento setup:db:status asks every registered validator (schema, data, declarative schema, patches) whether the database matches the code. Its exit codes were read from DbStatusCommand.php in the tags 2.4.8, 2.4.9 and Mage-OS 3.6.0, which all define them the same way:

Exit code Meaning What the migrate Job does

0

"All modules are up to date."

Skips setup:upgrade and runs only app:config:import.

2

At least one validator is out of date, or failed: "Run 'setup:upgrade' to update your DB schema and data." The constant is EXIT_CODE_UPGRADE_REQUIRED.

Runs setup:upgrade.

1

The deployment configuration is not available: "the Magento application is not installed." Also what an uncaught exception gives.

Fails the Job: the release is being aimed at the wrong or an empty database.

Two details matter for a Job. A validator that throws (for example because the database is unreachable or the user lacks a privilege) is reported as "Treating as upgrade required due to validation error" and gives 2, so an exit code of 2 can mean a real upgrade or a broken connection; the Job then fails one step later, in setup:upgrade, with the real message, which is why the gate is not a health check. And the command compares the database with the code of the image that runs it, which is exactly the question a release asks, so it must run with the new image (the pre-upgrade hook uses the new image) and never with the one that is currently serving.

The gate is the cheap half of a safe release. Its value is what it prevents: a release that touches no schema skips setup:upgrade altogether, so the most common release (a template, a CSS file, a plugin that adds no table) does not take the locks that setup:upgrade takes and does not clean the shared caches (next section).

setup:upgrade as a pre-upgrade Job

setup:upgrade does more than change tables, and four of the things it does decide how the Job must be built. All four were read from the 2.4.9 sources of UpgradeCommand, Installer and DeploymentConfig\Writer:

  1. It cleans the caches of the running shop. Installer::updateModulesSequence() flushes the configuration cache type and cleans the caches, and the command cleans them once more. The cache backend is the shared Valkey, so the old pods lose their cached configuration while they still serve traffic and rebuild it under load. Skipping setup:upgrade when the gate says 0 avoids this.

  2. It deletes the generated code unless --keep-generated is given. Without the option Installer::cleanupGeneratedFiles() clears generated/ and the compiled static files (the output of the lab on Upgrades lists pub/static/deployed_version.txt among them), and in production mode prints "Please re-run Magento compile command". The code in the image is compiled at build time, and generated/ is baked in and not writable, so the option is not a choice: without it the Job would fail on the read-only filesystem or, if it could write, destroy the compiled code. The option’s own help text discourages it "except when deploying to production", which is this case.

  3. It always rewrites app/etc/config.php. createModulesConfig() calls the configuration writer with the module list on every run, whether or not it changed. On a read-only image this fails with "The deployment config file isn’t writable". The Job therefore runs against a throwaway copy of app/etc (an emptyDir filled by an init container, as the first-install Job of Cloud: EKS Infrastructure and Helm Deployment does), and checks afterwards that the module list that Magento computed equals the one in the image: if they differ, the web pods would run a different set of modules than the one that was just upgraded, and the release stops.

  4. It may rewrite env.php. DbInitStatementsCleanup removes a deprecated SET NAMES utf8 entry from the init_statements of the database connections and saves env.php if it removed anything. The env.php template of this series sets no init_statements, so nothing is written; if you carry an older env.php with that entry, the read-only file makes the Job fail until you remove it.

The Job also needs everything the application needs at boot: the database (as mg_migrate), OpenSearch (setup:upgrade validates the search engine configuration), the Valkey cache, and the environment of the ConfigMap and the Secret. The mg_migrate password reaches the pod from the same Secrets Manager secret as the others (db_migrate_password, which the ExternalSecret writes as MAGENTO_MIGRATE_DB_PASSWORD), and the Job overrides MAGENTO_DB_USER and MAGENTO_DB_PASSWORD because an explicit env entry wins over envFrom. The privileges of the two users are on Cloud: Managed Database (RDS and Aurora); the parameter log_bin_trust_function_creators that triggers need on a database with binary logging is there too.

The script lives in the image, versioned with the code. Here is the script, then the Job.

mg-migrate (copied into the image as /usr/local/bin/mg-migrate)
#!/usr/bin/env bash
# Release step of Pocket Coffee: run by the pre-upgrade Job as the migration database user.
set -euo pipefail

cd /var/www/html
magento() { php -d memory_limit=-1 bin/magento "$@"; }

# 1. The gate: does the database match the code of this image?
#    Exit 0: up to date. Exit 2: setup:upgrade is required (also when a validator fails,
#    for example on an unreachable database, which then fails in step 2). Exit 1: not installed.
set +e
magento setup:db:status --no-ansi
status=$?
set -e
case "${status}" in
  0) echo "Schema and data are up to date for this image." ;;
  2) echo "The database needs an upgrade." ;;
  *) echo "setup:db:status exited with ${status}: Magento is not installed or the command failed." >&2
     exit 1 ;;
esac

# 2. Apply. setup:upgrade imports the configuration itself (it calls app:config:import);
#    when the schema is current only the import is needed.
if [ "${status}" -eq 2 ]; then
  magento setup:upgrade --keep-generated --no-interaction
else
  magento app:config:import --no-interaction
fi

# 3. The database must now be current.
magento setup:db:status --no-ansi

# 4. setup:upgrade rewrites app/etc/config.php (the modules list) in this Job's copy of app/etc.
#    If the list differs from the one baked into the image, the web pods would run a different
#    set of modules than the one just upgraded: stop the release.
php <<'PHP'
<?php
$new = require '/var/www/html/app/etc/config.php';
$old = require '/pristine/config.php';
$a = $new['modules'] ?? [];
$b = $old['modules'] ?? [];
ksort($a);
ksort($b);
if ($a !== $b) {
    fwrite(STDERR, "The modules list of config.php in the image is not what setup:upgrade computes.\n");
    fwrite(STDERR, "Run app:config:dump or module:enable in the build and commit config.php.\n");
    exit(1);
}
echo "config.php of the image matches the modules list.\n";
PHP
echo "Release step finished"

What the script does that the commands alone do not:

  • The gate, then the matching command. setup:db:status is run with set +e so that its exit code 2 is data and not a failure; any other code than 0 and 2 stops the Job.

  • --no-interaction. Without it the commands may ask questions, and a Job has nobody to answer. With it the answer is yes, which is worth knowing about app:config:import: see config.php, scopes and themes are deployed, not edited.

  • The second setup:db:status proves that the upgrade did what it claimed. It is cheap and has caught half-applied upgrades.

  • The config.php comparison is the check that the Job’s throwaway copy of app/etc and the file in the image agree on the modules. A module that was enabled on a developer’s machine and not committed shows up here, in the Job, and not as "the module is missing on three of five pods".

templates/migrate-job.yaml
{{- if .Values.migrate.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate
  labels:
    {{- include "pc.labels" . | nindent 4 }}
    app.kubernetes.io/component: migrate
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  activeDeadlineSeconds: {{ .Values.migrate.timeoutSeconds }}
  template:
    metadata:
      labels:
        {{- include "pc.labels" . | nindent 8 }}
        app.kubernetes.io/component: migrate
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      securityContext: {fsGroup: 33, seccompProfile: {type: RuntimeDefault}}
      initContainers:
        - name: copy-etc
          image: {{ include "pc.image" . }}
          command:
            - sh
            - -c
            - cp -a /var/www/html/app/etc/. /work/etc/ && cp /var/www/html/app/etc/config.php /work/pristine/config.php
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: 33, runAsGroup: 33, capabilities: {drop: ["ALL"]}}
          volumeMounts:
            - name: etc
              mountPath: /work/etc
            - name: pristine
              mountPath: /work/pristine
      containers:
        - name: migrate
          image: {{ include "pc.image" . }}
          command: ["/usr/local/bin/mg-migrate"]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: 33, runAsGroup: 33, capabilities: {drop: ["ALL"]}}
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            - name: MAGENTO_DB_USER
              value: {{ .Values.database.migrateUser | quote }}
            - name: MAGENTO_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.secrets.name }}
                  key: MAGENTO_MIGRATE_DB_PASSWORD
          volumeMounts:
            - name: etc
              mountPath: /var/www/html/app/etc
            - name: env-php
              mountPath: /var/www/html/app/etc/env.php
              subPath: env.php
              readOnly: true
            - name: pristine
              mountPath: /pristine
              readOnly: true
            - name: var
              mountPath: /var/www/html/var
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: etc
          emptyDir: {}
        - name: pristine
          emptyDir: {}
        - name: var
          emptyDir: {}
        - name: tmp
          emptyDir: {}
        - name: env-php
          configMap:
            name: {{ .Release.Name }}-env-php
{{- end }}

The values it reads are database.migrateUser: mg_migrate, migrate.enabled: true and migrate.timeoutSeconds: 1800; the helpers pc.*, the ConfigMaps and the ExternalSecret are those of the Helm page. Points about the Job’s pod:

  • backoffLimit: 0. A failed setup:upgrade is not retried automatically. DDL commits implicitly in MySQL, so a failure halfway leaves the schema half-changed, and a second attempt on top of it is a decision for a person who has read the log. The Job stays visible with its logs until the next release deletes it (before-hook-creation); hook-succeeded removes it after a good run.

  • The init container and the four emptyDirs. etc holds a writable copy of app/etc (with env.php mounted from the ConfigMap over it), pristine keeps the untouched config.php for the comparison, var takes the logs, the cache of the Job’s own process and the var/declarative_dumps_csv of --safe-mode. generated/ and pub/static/ stay baked and read-only.

  • Hooks run before the new revision’s resources exist. The pre-upgrade Job reads the ConfigMap pocketcoffee-env-php as it was in the previous revision, because Helm creates the Job before it updates the ConfigMaps of the new revision. A release that adds a key to env.php (a new backend, a new queue) therefore runs its migrate Job with the old env.php. Ship an env.php change one release before the code that needs it, or render the ConfigMap as a hook resource with a lower weight than the Job; do not discover the problem in the Job’s log.

  • First install. On the first release the hook does not run (it is pre-upgrade, not pre-install) and the first-install Job of the Helm page takes its place. The Secret and the ConfigMaps must exist before the first helm upgrade, so install the chart once with migrate.enabled=false.

config.php, scopes and themes are deployed, not edited

app:config:import copies the shared configuration of config.php (and env.php) into the database for three sections. Adobe’s page lists them: scopes (websites, store groups and store views, imported in modes create, update and delete), themes (registered if their source code exists, never removed if missing from config.php) and system configuration, which is not copied at all because Magento reads the system array of the file directly, so the values need no import. Root categories are not imported and have to be assigned in the Admin.

The delete mode is the one that matters for a pipeline. The importer in Processor::execute() asks for confirmation before it applies a section whose importer warns, and the Store importer warns about "These stores will be deleted". The confirmation helper YesNo::execute() returns true without asking when the input is not interactive. A Job always runs non-interactively, so a store view that exists in the database and is missing from config.php is deleted by the release. That is the documented model (the development system owns the scopes, and production receives them), and it has two consequences for Pocket Coffee:

  • The store views es, en and us, their groups and the website are created on the development system, exported with bin/magento app:config:dump and committed. A store view that somebody adds in the production Admin is removed by the next release.

  • A pipeline step compares what the dump of a restored production copy contains with the config.php of the release, and fails the release if a scope would be deleted. The deployment sequence has no such check, so it has to be yours.

The commands for it are bin/magento app:config:dump on the development system (check that app/etc/config.php contains modules, scopes, themes and system) and a commit of the file.

The dump writes the sensitive settings nowhere (Adobe’s page: "It does not write sensitive settings"), so a payment gateway key that the Admin shows on the development system never ends up in git, and on production it comes from the Secret through env.php. The shared and the environment-specific split is also what lets one image run in staging and in production with different ConfigMaps and Secrets.

app:config:import skips a section whose hash did not change ("Nothing to import" when nothing changed); the hash is stored in the database. A release in which nothing changed in config.php therefore does no work in the Job, which is the second reason for the gate.

Maintenance mode on a cluster

bin/magento maintenance:enable makes Magento answer 503, and --ip=<address> (repeatable) exempts the addresses of the people who test. The mechanism is two files in var/: .maintenance.flag and .maintenance.ip, defined as MaintenanceMode::FLAG_FILENAME, IP_FILENAME and FLAG_DIR = DirectoryList::VAR_DIR. On a single server that is one place. On the cluster of this series var/ is an emptyDir per pod, so the command run in one pod closes that pod and no other, and a pod that starts a minute later is open. The usual remedies do not fit:

  • kubectl exec in every pod. It works for the pods that exist, not for the HPA’s new pods, restarted containers or the pods of the rollout; it is a race, not a switch.

  • A shared var/. A ReadWriteMany volume for var/ would make the flag shared and the cache, the logs and the sessions shared with it. This series keeps var/ per pod on purpose.

The switch that works is outside the application: the nginx container of every web pod returns 503 while a flag file exists, and the flag file comes from a ConfigMap mounted as a directory, which Kubernetes updates in the running pods (after the kubelet’s sync delay, typically a minute, so it is not instantaneous; files mounted with subPath are not updated). Varnish does not cache the answer: the generated VCL refuses to cache responses that are not 200 or 404.

conf.d/maintenance.conf (http context), then the snippet for the server block, before the location that passes requests to PHP-FPM
# conf.d/maintenance.conf of the nginx container of pocketcoffee-web and pocketcoffee-admin.
# The flag is the file "enabled" in the ConfigMap pocketcoffee-maintenance, mounted as a directory.
geo $maintenance_exempt {
    default       0;
    203.0.113.10  1;   # the VPN address of the team (documentation range)
}

# Inside the server block, before the location that passes requests to PHP-FPM.
set $maintenance "";
if (-f /etc/nginx/maintenance/enabled) {
    set $maintenance "on";
}
if ($maintenance_exempt = 1) {
    set $maintenance "";
}
if ($maintenance = "on") {
    return 503;
}

error_page 503 @maintenance;
location @maintenance {
    root /usr/share/nginx/maintenance;
    add_header Cache-Control "no-store" always;
    add_header Retry-After 600 always;
    rewrite ^ /maintenance.html break;
}

The ConfigMap pocketcoffee-maintenance is created by the chart with empty data and mounted at /etc/nginx/maintenance; the page maintenance.html is part of the image at /usr/share/nginx/maintenance/. release-control.sh maintenance-on of the workflow adds the key enabled, maintenance-off removes it. The configuration was checked with nginx -t and exercised in a container (200 without the flag, 503 with it and the maintenance page with Cache-Control: no-store); the geo block uses $remote_addr, so behind the ALB it needs the real client address (set_real_ip_from and the forwarded header) configured as on Cloud: Varnish and Full Page Cache on Kubernetes. Whether Helm’s merge keeps a key that kubectl patch added to a ConfigMap that the chart owns, across helm upgrade, depends on the Helm version and its apply strategy; I did not test it with Helm 4.3.1 against a cluster, so the workflow sets the flag before helm upgrade and checks it afterwards. If it disappears, own the ConfigMap outside the chart.

Cache clean, Varnish purge and warm-up

Three caches see a release, and each needs a different action:

Cache What goes stale Action

Valkey (pocketcoffee-prod-cache): configuration, layout, block, translations, EAV

Entries computed by the old code; setup:upgrade already cleaned it once, before the rollout, and the old pods refilled it.

cache:clean after the rollout.

Varnish: full page cache

Pages rendered by the old code, with the old static URLs and the old layout.

The purge that cache:clean sends to every host in http_cache_hosts.

OPcache and the per-pod realpath cache

Nothing: they live in the new pods and start empty.

The warm-up requests.

Two facts from the sources of 2.4.9 decide the command. CacheCleanCommand::performAction() dispatches the event adminhtml_cache_flush_system when no type is given or full_page is, and CacheFlushCommand dispatches adminhtml_cache_flush_all in the same cases; both are observed by Magento\CacheInvalidate\Observer\FlushAllCacheObserver, which sends a PURGE with the tag pattern .* to the Varnish hosts if the full page cache is Varnish and enabled. So cache:clean from the CLI does purge Varnish, as long as the pod has http_cache_hosts in env.php (from MAGENTO_VARNISH_HOSTS) and the stored configuration system/full_page_cache/caching_application is Varnish (2). A pod without these silently cleans only Valkey. Prefer cache:clean over cache:flush: clean removes the enabled types by tag, flush empties the whole Valkey database, and with Valkey that is every key in it. The caches of this series have their own replication groups for that reason, but a flush is still a larger hammer than the release needs.

The warm-up is a Job after the rollout that requests the key URLs from every Varnish replica, not through the Service: each replica has its own memory-only cache, so a request through the Service warms one replica chosen at random. It uses the replica names of MAGENTO_VARNISH_HOSTS (the headless Service names) and sends the Host header and the offloaded-protocol header (X-Forwarded-Proto: https) that the real traffic carries, because the VCL adds the offloader header to the hash: a page warmed without it would be a different object from the one the customers request. Each URL is requested twice, so that the log shows whether the second answer was a hit (the VCL’s X-Magento-Cache-Debug header), which is the check that the page is cacheable at all.

mg-warmup (copied into the image as /usr/local/bin/mg-warmup)
#!/usr/bin/env bash
# Post-release step of Pocket Coffee: clean the caches, then request the key URLs from every Varnish replica.
set -euo pipefail

cd /var/www/html
: "${MAGENTO_VARNISH_HOSTS:?comma separated host:port list of the Varnish replicas}"
: "${WARMUP_HOST:?public host name of the shop}"
paths_file="${WARMUP_PATHS_FILE:-/etc/pocketcoffee/warmup-paths.txt}"

# cache:clean cleans the enabled cache types in Valkey and, because Varnish is the full page cache,
# sends a PURGE for every object to the hosts in http_cache_hosts.
php -d memory_limit=-1 bin/magento cache:clean --no-ansi

broken=0
IFS=',' read -r -a replicas <<<"${MAGENTO_VARNISH_HOSTS}"
for replica in "${replicas[@]}"; do
  while IFS= read -r path || [ -n "${path}" ]; do
    case "${path}" in ''|'#'*) continue ;; esac
    # First request fills the cache, second shows whether the page is cacheable.
    for _ in 1 2; do
      headers="$(curl --silent --show-error --max-time 60 --output /dev/null --dump-header - \
        --header "Host: ${WARMUP_HOST}" --header "X-Forwarded-Proto: https" \
        "http://${replica}${path}" || true)"
      code="$(printf '%s' "${headers}" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
      code="${code:-000}"
    done
    cache="$(printf '%s' "${headers}" | tr -d '\r' | sed -n 's/^[Xx]-[Mm]agento-[Cc]ache-[Dd]ebug: //p')"
    echo "${replica}${path} -> ${code} ${cache:-no-cache-header}"
    # A 5xx or no answer means the release is broken: fail the hook. A miss is only reported.
    if [ "${code}" = "000" ] || [ "${code}" -ge 500 ]; then
      broken=1
    fi
  done <"${paths_file}"
done
exit "${broken}"
templates/warmup.yaml
{{- if .Values.warmup.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-warmup
  labels:
    {{- include "pc.labels" . | nindent 4 }}
    app.kubernetes.io/component: warmup
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  activeDeadlineSeconds: {{ .Values.warmup.timeoutSeconds }}
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        {{- include "pc.labels" . | nindent 8 }}
        app.kubernetes.io/component: warmup
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pc.serviceAccountName" . }}
      automountServiceAccountToken: false
      securityContext: {fsGroup: 33, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: warmup
          image: {{ include "pc.image" . }}
          command: ["/usr/local/bin/mg-warmup"]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: 33, runAsGroup: 33, capabilities: {drop: ["ALL"]}}
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            - name: WARMUP_HOST
              value: {{ .Values.shop.domain | quote }}
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits: { memory: 1Gi }
          volumeMounts:
            - name: env-php
              mountPath: /var/www/html/app/etc/env.php
              subPath: env.php
              readOnly: true
            - name: paths
              mountPath: /etc/pocketcoffee
              readOnly: true
            - name: var
              mountPath: /var/www/html/var
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: env-php
          configMap:
            name: {{ .Release.Name }}-env-php
        - name: paths
          configMap:
            name: {{ .Release.Name }}-warmup-paths
        - name: var
          emptyDir: {}
        - name: tmp
          emptyDir: {}
{{- end }}

The chart also renders the ConfigMap pocketcoffee-warmup-paths (one path per line, from warmup.paths), which the Job mounts. The Job is a post-install,post-upgrade hook. Helm runs the post-upgrade hooks after the resources of the new revision are updated and, with --wait (which --rollback-on-failure turns on as watcher in Helm 4.3.1), after the pods are ready, so the Varnish replicas and the new web pods exist when the Job starts. The warm-up fails the hook only for a 5xx or no answer, which means a broken release and a rollback; a page that is not cacheable or a miss is reported in the log and is not an error. The paths are values (warmup.paths) because they are the shop’s own: the home page of each store view and the top categories. Which URLs exist depends on the URL rewrites of the catalog (the .html suffix and the store code in the path are settings of the shop), so the ones in the listing are examples; if the store views are served by host name instead of by code in the path, warm each host. Warming checkout, cart or account pages is useless for Varnish (they are not cacheable) and useful only for PHP and OPcache; warm the pages that carry the traffic.

An optional refinement, which Adobe does not document as a release technique, is a different cache id_prefix per release in the cache section of env.php, so that the old and the new pods never read each other’s entries during the rollout, and the old entries expire on their own. It would need the prefix as an environment variable in the template of the Helm page and a decision about the cost of a cold cache after every release; measure before adopting it.

Static content, versions and the rolling update

setup:static-content:deploy runs in the image build (Cloud: Container Image and Pipeline Build) and pub/static/deployed_version.txt is baked with it. The version in that file becomes part of every static URL (/static/version<number>/frontend/...), which is how a new release invalidates the browsers' and the CDN’s copies. Three details from the 2.4.9 sources:

  • The version is the build’s Unix timestamp unless you give one. DeployStaticContent::deploy() uses the option --content-version when it is non-empty and time() otherwise; the option’s help says it is for "running deployment on multiple nodes to ensure that static content version is identical". With one image, the build is that single place.

  • Keep it numeric. The nginx.conf.sample of 2.4.9 strips the version with rewrite ^/static/version\d*/(.*)$ /static/$1 last;, so the version in the URL has to be digits. A git SHA with letters would not match the pattern; I read the file and did not test a non-numeric version. Leave the default or pass a number.

  • In production mode a missing version file is fatal. Version::readValue() throws "Unable to retrieve deployment version of static files from the file system" when pub/static/deployed_version.txt is missing and static content deployment on demand is off. A web image without the file refuses to render pages; an image check in the build is cheap: test -s pub/static/deployed_version.txt.

During a rolling update both versions exist at once. The new pods render pages with /static/version<new>/...; if the browser’s request for that URL lands on an old pod, the nginx rewrite strips the version and the old pod answers with its file under the new URL. For a file that did not change that is correct; for one that did, a CDN or a Varnish that caches the answer by URL keeps the old content under the new version until it expires. Magento’s VCL passes /static/ by default, so Varnish is not the problem, but a CDN in front of /static/ is. The remedies are to keep the window short (maxUnavailable: 0, a fast readiness check), to put the CDN’s static behavior on a short time to live or to invalidate /static/* after the rollout, or to serve the static files from a place that holds every version (S3 with a prefix per version, which is a design of its own). The media host media.pocketcoffee.example is not affected: it serves pub/media, not pub/static.

The rollout settings of the web Deployment (excerpt; the rest of the template is on the Helm page)
spec:
  progressDeadlineSeconds: 600
  minReadySeconds: 20
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: nginx
          readinessProbe:
            httpGet: {path: /health_check.php, port: 8080}
            periodSeconds: 5
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 15 && nginx -s quit"]

maxUnavailable: 0 with maxSurge: 1 replaces a pod only after its successor is ready, minReadySeconds keeps a flapping pod from counting as ready, and the preStop sleep gives the Service and the Varnish backend list time to stop sending requests before nginx quits. The probe path is /health_check.php, which checks the database and the cache backends, so a release whose pods cannot reach Valkey never becomes ready and the rollout stops by itself. A PodDisruptionBudget on pocketcoffee-web must allow at least one pod to be unavailable or the node drains of EKS Auto Mode (which replaces nodes at most every 21 days) will block; see Scheduling and Disruptions.

Schema changes that survive a rolling update

Between the migrate Job and the end of the rollout the old code runs on the new schema. For a pod that is replaced a minute later this is a short window, but it is real traffic: carts, orders and the Admin. The discipline that makes it safe is the expand-and-contract pattern, and Magento’s declarative schema gives it a shape.

Change Old code on the new schema How to ship it

Add a nullable column, a column with a default, a table or an index

Works: the old code never mentions it.

One release. db_schema.xml is enough; additions need no whitelist (a large index on a large table is a lock to rehearse).

Add a NOT NULL column without a default

Fails: the old code’s INSERT leaves it out.

Expand first (nullable or default), backfill with a data patch, tighten in a later release.

Rename a column or table

Fails: the old code uses the old name.

Declare the new name with onCreate="migrateDataFrom(old)", write both in the code for one release, drop the old one in a later release.

Drop a column or table

Fails if any old code still reads it.

Contract in a later release, after no running code uses it; it needs the whitelist (below).

The whitelist and the contract release. A declarative schema drops a column or table that disappeared from db_schema.xml only if it is listed in the module’s etc/db_schema_whitelist.json, which bin/magento setup:db-declaration:generate-whitelist --module-name=PocketCoffee_RoastAdmin generates from the XML as it is at that moment. The sequence that works across releases is: add the element in release N, generate the whitelist and commit it in release N, remove the element from the XML in release N+k and ship; the whitelist file remains committed and the drop happens in N+k’s Job. If the element is removed before the whitelist knew it, it is never dropped. The existing page Declarative Schema and Patches shows these cases with the commands and the outputs; the pipeline consequence is that the contract release is a separate release, labelled breaking in the workflow if the old code could still read the column.

Data patches and rollback. A data patch is recorded by class name in patch_list when it applies, and is not run again. A patch may implement PatchRevertableInterface, but revert() is called only when the module is uninstalled, not by a release rollback; a schema patch is never reverted. So a rolled-back release leaves its applied patches applied: the data they wrote stays, and the old code must tolerate it. Write data patches so that old code can live with their result (add rows, do not rewrite them), and keep a destructive data change out of the release that ships the code that needs it.

--safe-mode and --data-restore. setup:upgrade --safe-mode=1 writes a CSV dump of what a destructive change would remove into var/declarative_dumps_csv/, and --data-restore=1 loads it back when the declaration returns. In the Job var/ is an emptyDir, so the dump disappears with the pod; it is not a substitute for the snapshot. If you want it, copy the directory out in the script before the Job ends (to S3, with the Job’s Pod Identity role extended for it) and treat that as part of the release.

Rollback limits

Rolling back means different things for the three layers of a release, and the answers differ.

Layer What goes back What does not

Code (the image)

helm rollback pocketcoffee <revision> restores the manifests of an earlier revision, including the old image tag and the old ConfigMaps. Helm runs only pre-rollback and post-rollback hooks on a rollback, so the pre-upgrade migrate Job is not started again.

The Valkey cache (it holds entries of the new code), Varnish (pages of the new code until purged) and the static version in the browsers.

Database schema

Nothing: Magento has no downgrade for setup:upgrade. An additive schema runs the old code unchanged.

A column that the old code needs and that the release dropped or renamed. Only a restore brings it back.

Database data

A restored snapshot, as a new instance.

Every order, customer, cart, stock change and price change after the snapshot. The restore is a point in time.

So there are two answers to "can I roll back": a release whose schema change was additive can be rolled back with helm rollback and then cleaned (cache:clean, a warm-up Job again); a release whose schema or data change was not compatible can only be rolled forward with a fix, or restored from the snapshot taken before the Job, with everything that follows from a restore of a shop that took orders in the meantime. The snapshot is not optional for a release that has a schema change: the workflow takes it before anything else can change the database. Restoring creates a new RDS instance with a new endpoint; the running instance is never overwritten. The order for a restore without a maintenance window, as on the PrestaShop sibling page (Cloud: Releases and Upgrades): stop the writers you control (release-control.sh quiesce and maintenance mode on), restore the snapshot into a new instance, point the release at the new endpoint with the previous image tag and migrate.enabled=false, reconcile the orders between the snapshot and the restore against the payment provider and the mails sent, reopen the shop, resume the cron last. helm upgrade with an old tag runs the old image’s migrate Job against the new database, which is worse than helm rollback; use migrate.enabled=false for any redeploy of an older image.

Restoring the pre-release snapshot (placeholders to adapt)
#!/usr/bin/env bash
set -euo pipefail

aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier pocketcoffee-prod-restore \
  --db-snapshot-identifier pocketcoffee-pre-<git-sha> \
  --db-subnet-group-name pocketcoffee-prod \
  --vpc-security-group-ids <security-group-id> \
  --db-parameter-group-name pocketcoffee-magento-mysql84

aws rds wait db-instance-available --db-instance-identifier pocketcoffee-prod-restore

helm upgrade pocketcoffee ./pocketcoffee-magento \
  --namespace pocketcoffee \
  --reuse-values \
  --set image.tag=<previous-git-sha> \
  --set migrate.enabled=false \
  --set database.host=<endpoint-of-pocketcoffee-prod-restore> \
  --rollback-on-failure

For Aurora the commands are the cluster ones (create-db-cluster-snapshot, restore-db-cluster-from-snapshot, then a new instance in the new cluster); the backup side (automated backups, point in time restore, retention) is on Cloud: Autoscaling, Observability, Backups and Security. A restore also does not move the secrets: crypt_key is in Secrets Manager, not in the snapshot, and the restored database works with it as long as it was not rotated in between (Cloud: Migrating from a Single Server). Manual snapshots stay until deleted, so automate their expiry.

Blue/green and rolling releases

The rolling update is the Kubernetes default and gives a zero-downtime release when the schema change is compatible. A blue/green release, two complete stacks and a switch, adds a rehearsal and an instant way back, and costs a good deal:

  • The rehearsal is the valuable part. The green stack runs against a restored copy of the production database, the migrate Job runs against that copy and the smoke tests see a real schema. A staging environment (the same chart and workflow with its own cluster, database and role) restored from the latest snapshot and released first gives the same check at a fraction of the cost; production then releases the SHA that passed there.

  • The switch back lasts until the first order. After that the blue stack’s database lacks the order, so "back" is a reconciliation, not a switch: the database is the part of a shop that is neither blue nor green.

  • Shared services need a plan. The green stack needs its own OpenSearch prefix (MAGENTO_OPENSEARCH_PREFIX), or its reindex would overwrite the live indexes, and its own cache namespace.

For the strategies in general (rolling, recreate, blue/green, canary) see Deployment Strategies. A canary of the web tier through weighted Gateway API routes serves two code versions against one database, which is the same compatibility problem as the rolling update with a longer window.

The release workflow

sequenceDiagram participant Dev as Developer participant GH as GitHub Actions participant ECR as Amazon ECR participant RDS as RDS pocketcoffee-prod participant K as EKS API server participant Job as Job pocketcoffee-migrate participant Web as pocketcoffee-web pods participant Warm as Job pocketcoffee-warmup participant Var as Varnish replicas Dev->>GH: merge to main GH->>ECR: push pocketcoffee/magento:<git-sha> GH->>RDS: snapshot pocketcoffee-pre-<git-sha> opt breaking release GH->>K: maintenance on, suspend cron, pause consumers end GH->>K: helm upgrade pocketcoffee with image.tag K->>Job: pre-upgrade hook, new image, user mg_migrate Job->>RDS: setup:db:status, then setup:upgrade --keep-generated or app:config:import Job-->>K: Complete, or failed and the release rolls back K->>Web: rolling update, maxUnavailable 0 K->>Warm: post-upgrade hook Warm->>Var: cache:clean purges Varnish, then the key URLs on every replica K-->>GH: deployed, or rolled back on failure opt breaking release GH->>K: resume cron and consumers, maintenance off end GH->>Web: smoke test through the public URL

The workflow authenticates to AWS with OpenID Connect, as the WordPress page explains (Authenticating GitHub Actions to AWS with OIDC): no AWS keys are stored in GitHub, the job asks GitHub for a token and exchanges it for the temporary credentials of the role pocketcoffee-prod-github-deploy. The role of the infrastructure page can push images and call eks:DescribeCluster; a release that takes an RDS snapshot needs the extra statement of the policy below. The trust policy names the two subjects that may assume it: runs on the branch main (the build job) and jobs that target the GitHub environment production (the deploy job, which is the one where a reviewer approves). eks:DescribeCluster only lets aws eks update-kubeconfig find the endpoint; what the role may do inside the cluster is decided by an EKS access entry for the role, with an access policy scoped to the namespace pocketcoffee (EKS Auto Mode requires access entries; see EKS Clusters). The two policies, both with the placeholders of this series:

Trust policy of the role pocketcoffee-prod-github-deploy
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"}, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": {"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"}, "StringLike": {"token.actions.githubusercontent.com:sub": ["repo:pocketcoffee-shop/magento:ref:refs/heads/main", "repo:pocketcoffee-shop/magento:environment:production"]}}}
  ]
}
Permissions policy of the role (ECR push, cluster endpoint, snapshot before the release)
{
  "Version": "2012-10-17",
  "Statement": [
    {"Sid": "EcrLogin", "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*"},
    {"Sid": "EcrPush", "Effect": "Allow", "Action": ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:CompleteLayerUpload", "ecr:GetDownloadUrlForLayer", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"], "Resource": "arn:aws:ecr:eu-west-1:123456789012:repository/pocketcoffee/magento"},
    {"Sid": "ClusterEndpoint", "Effect": "Allow", "Action": "eks:DescribeCluster", "Resource": "arn:aws:eks:eu-west-1:123456789012:cluster/pocketcoffee-prod"},
    {"Sid": "SnapshotBeforeRelease", "Effect": "Allow", "Action": ["rds:CreateDBSnapshot", "rds:AddTagsToResource"], "Resource": ["arn:aws:rds:eu-west-1:123456789012:db:pocketcoffee-prod", "arn:aws:rds:eu-west-1:123456789012:snapshot:pocketcoffee-pre-*"]},
    {"Sid": "WaitForSnapshot", "Effect": "Allow", "Action": "rds:DescribeDBSnapshots", "Resource": "*"}
  ]
}

The repository name pocketcoffee-shop/magento is a placeholder; the sub claim has the form repo:<owner>/<repository>:... and the policy should name your repository exactly, not a wildcard for the whole organization. The workflow itself follows; the quiesce and maintenance steps of a breaking release are in a script that the workflow calls.

release-control.sh (in .github/scripts)
#!/usr/bin/env bash
# Release controls of Pocket Coffee, called by .github/workflows/release.yml.
set -euo pipefail

NAMESPACE="${NAMESPACE:-pocketcoffee}"
kc() { kubectl --namespace "${NAMESPACE}" "$@"; }

case "${1:?quiesce, resume, maintenance-on or maintenance-off}" in
  quiesce)
    # Nothing may publish, consume or index while the schema changes.
    kc patch cronjob pocketcoffee-cron --patch '{"spec":{"suspend":true}}'
    for scaledobject in $(kc get scaledobject --output name); do
      kc annotate "${scaledobject}" autoscaling.keda.sh/paused-replicas="0" --overwrite
    done
    # Wait until the consumers are gone, so none of them runs against the old schema.
    kc wait --for=delete pod --selector app.kubernetes.io/component=consumer --timeout=300s
    ;;
  resume)
    for scaledobject in $(kc get scaledobject --output name); do
      kc annotate "${scaledobject}" autoscaling.keda.sh/paused-replicas-
    done
    kc patch cronjob pocketcoffee-cron --patch '{"spec":{"suspend":false}}'
    ;;
  maintenance-on)
    kc patch configmap pocketcoffee-maintenance --type merge --patch '{"data":{"enabled":"1"}}'
    ;;
  maintenance-off)
    kc patch configmap pocketcoffee-maintenance --type merge --patch '{"data":{"enabled":null}}'
    ;;
  *)
    echo "unknown command: $1" >&2
    exit 2
    ;;
esac
release.yml (in .github/workflows)
name: release

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      schema_change:
        description: "compatible: the old code still works on the new schema; breaking: it does not"
        type: choice
        options: [compatible, breaking]
        default: compatible

permissions:
  contents: read

concurrency:
  group: release-production
  cancel-in-progress: false

env:
  AWS_REGION: eu-west-1
  AWS_ROLE: arn:aws:iam::123456789012:role/pocketcoffee-prod-github-deploy
  ECR_REPOSITORY: pocketcoffee/magento
  CLUSTER: pocketcoffee-prod
  NAMESPACE: pocketcoffee
  DB_INSTANCE: pocketcoffee-prod
  SCHEMA_CHANGE: ${{ inputs.schema_change || 'compatible' }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - id: ecr
        uses: aws-actions/amazon-ecr-login@v2
      - uses: docker/setup-buildx-action@v3
      # Builds both targets (<sha> for php-fpm and <sha>-nginx) from docker-bake.hcl; RDS_CA_SHA256 and CONTENT_VERSION are set as on the image page.
      - uses: docker/bake-action@v6
        env:
          COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
          GIT_SHA: ${{ github.sha }}
          REGISTRY: ${{ steps.ecr.outputs.registry }}
        with:
          push: true

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ROLE }}
          aws-region: ${{ env.AWS_REGION }}
      - uses: azure/setup-helm@v4
        with:
          version: v4.3.1
      - name: Snapshot the database before the schema can change
        run: |
          aws rds create-db-snapshot \
            --db-instance-identifier "$DB_INSTANCE" \
            --db-snapshot-identifier "pocketcoffee-pre-${GITHUB_SHA}"
          aws rds wait db-snapshot-available \
            --db-snapshot-identifier "pocketcoffee-pre-${GITHUB_SHA}"
      - name: Connect to the cluster
        run: aws eks update-kubeconfig --name "$CLUSTER" --region "$AWS_REGION"
      - name: Close the shop and stop the background work (breaking release)
        if: ${{ env.SCHEMA_CHANGE == 'breaking' }}
        run: |
          ./.github/scripts/release-control.sh maintenance-on
          ./.github/scripts/release-control.sh quiesce
      - name: Upgrade the release
        run: |
          helm upgrade --install pocketcoffee ./pocketcoffee-magento \
            --namespace "$NAMESPACE" \
            --values ./pocketcoffee-magento/values-production.yaml \
            --set "image.tag=${GITHUB_SHA}" \
            --rollback-on-failure \
            --timeout 45m \
            --history-max 10
      - name: Resume the background work
        if: ${{ env.SCHEMA_CHANGE == 'breaking' }}
        run: ./.github/scripts/release-control.sh resume
      - name: Smoke test through the public URL
        run: |
          for path in / /es/ /en/ /us/; do
            curl --fail --silent --show-error --retry 5 "https://www.pocketcoffee.example${path}" --output /dev/null
          done
      - name: Open the shop again (breaking release)
        if: ${{ env.SCHEMA_CHANGE == 'breaking' }}
        run: ./.github/scripts/release-control.sh maintenance-off

Where it differs from the sibling workflows:

  • schema_change is a human decision: compatible or breaking is the answer to the question in the figure, and compatible is the default of a push to main. A pull request that drops or renames a column, tightens a constraint or removes a store view must be released as breaking, and review is where that is caught.

  • --rollback-on-failure, not --atomic. Helm 4 renamed the flag (with Helm 3 use --atomic). A failed hook Job fails the upgrade and the flag rolls back to the previous revision; the rollback does not undo what the Job already changed in the database, which is why the snapshot step is first.

  • A failed breaking release stays closed on purpose. The later steps (resume, maintenance off) are skipped: the database may be in an unknown state, and the people on call decide between a forward fix, a retry and the snapshot.

  • The timeout of 45 minutes covers both hooks and the rollout; the Jobs' own deadlines are shorter, so a Job fails first with its own message. Time the migrate Job on a restored copy before choosing the numbers.

  • The build uses a build secret (composer_auth) for the Composer keys of repo.magento.com, as the container image page does; its value is a GitHub secret, not part of the file.

  • Pin the action majors that existed when the page was checked, or a commit SHA. Staging is the same file with its own cluster, instance, environment and role; release there first.

The workflow was parsed and passed actionlint; nothing was run on GitHub or against AWS.

Verifying a release

After helm upgrade returns, the checks that go beyond the smoke test run from a pod, so that the environment is production’s:

After helm upgrade returns, the checks that go beyond the smoke test run from a pod, so that the environment is production’s: kubectl exec deployment/pocketcoffee-web -c php-fpm — php bin/magento setup:db:status must end with "All modules are up to date.", module:status --enabled must equal the module list of config.php, config:show web/unsecure/base_url must print https://www.pocketcoffee.example/, and indexer:status must show no "Reindex required" unless the release meant to leave one (the reindex Job is on the cron page). Then check that the pods of the new revision are spread over the zones, that the suspend field of the CronJob pocketcoffee-cron is false and that no ScaledObject still carries the pause annotation, and sign in to the Admin from the allowed address to open System > Tools > Cache Management (no type marked invalidated) and the Index Management grid.

Common errors and pitfalls

  • The migrate Job ends with "The deployment config file isn’t writable". The throwaway copy of app/etc is missing or config.php is read-only: setup:upgrade rewrites config.php on every run. Check the init container and the volume mounts.

  • "Please re-run Magento compile command" in the Job’s log. --keep-generated was left out; the Job tried to clean the compiled code. Fix the script, not the image.

  • A store view disappeared after a release. It was in the database and not in config.php; the non-interactive import applied the deletion (config.php, scopes and themes are deployed, not edited).

  • The static files return 404 on some requests. The page was rendered by a new pod and the file requested from an old one that does not have it (a new file in the release, not in the old image). Keep the rollout short, and keep a CDN’s TTL for /static/ short during the rollout.

Version notes and what was not verified

Verified on 2026-10-29: in the sources of Magento 2.4.9 (and, for DbStatusCommand and UpgradeCommand, 2.4.8 and Mage-OS 3.6.0) DbStatusCommand and its exit codes, UpgradeCommand, Installer (updateModulesSequence, createModulesConfig, cleanupGeneratedFiles), DeploymentConfig\Writer, DbInitStatementsCleanup, ConfigImportCommand, ConfigImport\Processor, YesNo, ConfigSetCommand (the lock options), MaintenanceMode, CacheCleanCommand, CacheFlushCommand, the events.xml of Magento_CacheInvalidate, DeployStaticOptions, DeployStaticContent, Version of the static deployment, pub/health_check.php and nginx.conf.sample; and in the Adobe documentation the deployment technical details, the zero-downtime page of Adobe Commerce on cloud infrastructure, the deployment flow, the page on importing configuration and the one on maintenance mode. The scripts were run through shellcheck and exercised with a stub for php (gate with exit codes 0, 2 and 1, and the second check failing); the PHP snippet passed php -l (PHP 8.5) and was run against sample config.php files with equal, reordered and different module lists; the Jobs and the Deployment fragment were rendered with helm template (Helm 4.3.1), passed helm lint and kubeconform -strict against the Kubernetes 1.36 schemas; the nginx configuration passed nginx -t and was run in a container; the workflow passed actionlint; the two policies parsed as JSON; the diagram passed the Mermaid validator of this repository and the figure xmllint.

Not verified: setup:upgrade or app:config:import against a real installation in a read-only image (the claims about the writes are from the sources), the Jobs on a cluster, External Secrets Operator, KEDA, Varnish purges from a Job (the observers were read, not run), the maintenance ConfigMap across helm upgrade with Helm 4.3.1, a non-numeric static content version, the id_prefix technique, the EKS access entry and its policy, anything against AWS or GitHub, the duration of any step with a production-sized database, and the Aurora variants of the snapshot commands.