1. A Bill That Didn't Match the Traffic
A sailing equipment retailer I picked up in early 2025 was paying about £11,400 a month for an Azure estate serving a Magento 2.4.6 store that did roughly 90,000 sessions a week. That is not a huge store. It is a good store — decent conversion, reasonable catalogue, three locales — but it is not a store that should cost eleven grand a month to host.
The interesting part was where the money went. I expected the database. The database was £1,900. The AKS cluster was £2,100. The single largest line item, at just over £3,000, was Azure Files Premium: a 4 TiB provisioned share holding a pub/media directory that contained 38 GB of actual images. They had provisioned four terabytes because the IOPS on Premium Files scale with provisioned capacity, and someone had worked backwards from a required IOPS number to a share size, then never revisited it after moving image delivery to a CDN.
The second largest was outbound data transfer between availability zones. Their Redis was in zone 1, their AKS nodes were spread across zones 1, 2 and 3, and every session read from a node in zone 3 crossed a zone boundary and got billed. Nobody had modelled that. It is not a line item anyone looks at until it is thousands of pounds.
Neither of those is an architecture mistake exactly. They are the residue of a dozen reasonable decisions made in isolation, which is how most cloud estates end up expensive. This article is about running Magento 2 on Azure properly — what the platform actually needs, which Azure services map onto those needs, and where the mapping is worse than the marketing suggests.
I have built this stack four times now. The first time I got most of it wrong.
2. What Magento Actually Needs From a Cloud
Before naming any Azure service, it is worth being precise about the shape of the workload, because Magento is not a stateless twelve-factor app and pretending it is will bite you.
A Magento 2 deployment needs, at minimum: a PHP-FPM runtime with a large opcache and a preloaded class map; a MySQL-compatible database that tolerates a workload dominated by a handful of enormous joins and a very hot catalog_product_entity family of tables; Redis or an equivalent for sessions and for the default cache backend; a search engine speaking the Elasticsearch or OpenSearch API; a writable shared filesystem for generated media and, depending on your deployment model, for generated code; and a place to run cron and the message queue consumers, which are separate long-lived processes and not web traffic.
Three of those are awkward on any container platform. The shared filesystem is awkward because Kubernetes wants pods to be disposable and Magento wants a directory that survives them. The cron and consumer processes are awkward because they are not replicas of the web tier and must not be scaled with it. And the deployment model is awkward because setup:di:compile and setup:static-content:deploy produce build artefacts that need to exist before the first request, which does not fit the "start the container and it serves" model.
Get those three right and the rest is ordinary infrastructure. Get them wrong and you will spend a year fighting the platform. Most of the Magento-on-Kubernetes horror stories I have read trace back to someone mounting the entire application root on a network share so that all pods "see the same files", which converts every PHP include into a network round trip and produces a store that is slower than a single VPS.
3. AKS, App Service, or Plain Virtual Machines
The first real decision, and the one people agonise over longest. My honest position: most Magento stores on Azure should not be on Kubernetes, and a meaningful number of the ones that are would run better and cheaper on a pair of well-configured VMs behind a load balancer.
Azure App Service I would rule out early for Magento. It can be made to work with the Linux container option, but the filesystem semantics, the constrained control over PHP configuration, and the deployment model all fight you. Every project I have seen on App Service has an escalating pile of workarounds. If your entire reason for choosing it is "we don't want to manage servers", a managed VM scale set with a decent image pipeline is less trouble.
Virtual machine scale sets are the boring, correct answer for a store doing under about 250,000 sessions a month with predictable traffic. Two or three D-series instances, a custom image built in a pipeline, an Azure Load Balancer or Application Gateway in front, autoscale on CPU. You give up per-service scaling and gain an environment every engineer already understands. Deployment is a rolling image swap. Nobody has to learn about pod disruption budgets.
AKS earns its complexity when you have genuinely divergent scaling needs — a web tier that spikes hard during promotions, consumers that need to burst for a catalogue reindex, and maybe a headless frontend alongside — or when the organisation already runs Kubernetes and this is one more workload on an existing platform. That second reason is the better one. Adopting Kubernetes for a single Magento store means owning an entire platform for one application.
I will spend most of this article on AKS, because that is where the non-obvious problems live and because it is what people ask for. But I would ask hard whether you need it. The sailing equipment retailer above did not, in the end; we kept AKS because migrating away would have cost more than the savings, not because it was the right original choice.
4. Node Pools, and Why You Want At Least Three
If you are on AKS, do not run one node pool. The default single-pool cluster puts your web pods, your consumers, your cron, and the system components on the same machines, which means a runaway consumer during a reindex evicts web pods and your storefront degrades because someone updated 400 products.
The layout I use:
| Pool | Purpose | Typical SKU | Scaling |
|---|---|---|---|
system | CoreDNS, metrics server, ingress controller | Standard_D2s_v5, 2 nodes | Fixed |
web | PHP-FPM + Nginx pods | Standard_D8s_v5 | Cluster autoscaler, 2–10 |
worker | Consumers, cron, indexers | Standard_E4s_v5 (memory-heavy) | Cluster autoscaler, 1–6 |
spot (optional) | Batch reindex, image processing | Spot Standard_D8s_v5 | 0–8, tolerated evictions |
The worker pool wants memory rather than cores. A Magento full reindex on a 60,000-SKU catalogue will happily take 6 GB of PHP memory per process, and the E-series ratio of 8 GB per vCPU suits that far better than the D-series 4 GB. Putting consumers on the same D-series SKU as the web tier is the most common sizing mistake I see, and it shows up as OOMKilled pods during catalogue updates.
# Web pool: general purpose, autoscaled, zone-redundant.
az aks nodepool add \
--resource-group rg-commerce-prod \
--cluster-name aks-commerce-prod \
--name web \
--node-vm-size Standard_D8s_v5 \
--enable-cluster-autoscaler --min-count 2 --max-count 10 \
--zones 1 2 3 \
--node-taints workload=web:NoSchedule \
--labels workload=web
# Worker pool: memory-optimised. Note it is pinned to ONE zone.
# Consumers talk to Redis and MySQL constantly; keeping them in the
# same zone as the data services removes cross-zone egress charges
# that are invisible until the invoice arrives.
az aks nodepool add \
--resource-group rg-commerce-prod \
--cluster-name aks-commerce-prod \
--name worker \
--node-vm-size Standard_E4s_v5 \
--enable-cluster-autoscaler --min-count 1 --max-count 6 \
--zones 1 \
--node-taints workload=worker:NoSchedule \
--labels workload=worker
The zone pinning on the worker pool is deliberate and is the single change that removed about £700 a month from the sailing equipment retailer's bill. Cross-zone traffic within a region is billed in both directions on Azure. Consumers are chatty — a message queue consumer polling Redis and writing to MySQL generates a continuous low-level stream that adds up. Web pods should stay zone-redundant because availability matters more there; consumers can tolerate a zonal outage by restarting elsewhere, and the autoscaler will do that for you if you allow a second zone as a fallback.
Taints and tolerations, not just labels
Use taints rather than node selectors alone. A node selector says "put this pod here"; a taint says "put nothing else here". Without the taint, the cluster autoscaler will cheerfully schedule web pods onto your expensive memory-optimised worker nodes when the web pool is under pressure, and you will wonder why your consumer throughput collapsed during a traffic spike.
5. The Database, Which Is Where Magento Lives or Dies
Azure Database for MySQL Flexible Server is the right choice, and Single Server is retired, so this is less of a decision than it used to be. What matters is the configuration, and several defaults are wrong for Magento.
Start with the storage. On Flexible Server, IOPS are tied to provisioned storage in the same way as on Files, except you can now buy additional IOPS independently on the Business Critical tier. Magento's checkout path is write-heavy in bursts — quote saves, inventory reservations, order placement — and running out of IOPS during a flash sale looks exactly like an application deadlock. Provision for the burst, not the average.
Then the parameters. The ones I change on every deployment:
# innodb_buffer_pool_size is set by Azure as a fraction of instance memory
# and you cannot override it directly. Choose the SKU for the buffer pool
# you need: target 70-80% of your active dataset in memory. A 120 GB
# Magento database with a 40 GB hot set wants at least 64 GB of RAM.
# These you CAN set, via server parameters:
innodb_flush_log_at_trx_commit = 2 # 1 is safest; 2 loses at most 1s on
# host failure and roughly doubles
# write throughput. Acceptable when
# you have HA replication.
innodb_io_capacity = 4000 # default 200 assumes spinning disks
innodb_io_capacity_max = 8000
max_connections = 1000 # see the pooling note below
wait_timeout = 300 # Magento leaks connections on long
# CLI runs; 28800 is far too generous
interactive_timeout = 300
tmp_table_size = 256M # layered navigation builds big temp
max_heap_table_size = 256M # tables; spilling to disk is slow
sort_buffer_size = 2M # per-connection: do NOT set this high
join_buffer_size = 2M
log_bin_trust_function_creators = ON # Magento's triggers need this for
# MView-based indexing to install
The innodb_flush_log_at_trx_commit = 2 line is the one that gets argued about. The strict reading is that a value of 2 risks losing up to a second of committed transactions if the host dies. In practice, on Flexible Server with zone-redundant HA, the failover mechanism replicates at the storage layer and the exposure is smaller than the naive reading suggests. I set it to 2 on stores where checkout write latency is the bottleneck and to 1 where the client is regulated or nervous. Both are defensible. Anyone who tells you there is one correct answer has not had to explain a slow checkout to a merchandising director.
Connection pooling is not optional
PHP-FPM opens a connection per worker. Ten web pods with 40 PHP-FPM children each is 400 connections before you count consumers, cron, and the admin. Flexible Server's connection limits scale with SKU and the memory cost per connection is real. Put a pooler in front.
Azure offers built-in PgBouncer for PostgreSQL but nothing equivalent for MySQL, so you deploy ProxySQL yourself, either as a sidecar or as a small deployment in the cluster. A sidecar per pod is simpler to reason about and gives you a local Unix socket; a shared deployment gives better pooling ratios but becomes a single point of failure that needs its own HA. I run it as a sidecar for stores under about 20 web pods and as a DaemonSet above that, so each node has one instance and pods connect over the node's loopback.
-- ProxySQL: route Magento's read-heavy queries to a replica.
-- Magento 2 supports split connections natively via env.php, but
-- routing at the proxy means you do not have to teach every module
-- about replicas, and third-party modules routinely ignore the setting.
INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply)
VALUES
-- Anything inside a transaction must go to the primary.
(10, 1, '^SELECT.*FOR UPDATE', 10, 1),
-- Catalogue and CMS reads are safe on a replica.
(20, 1, '^SELECT .* FROM catalog_', 20, 1),
(21, 1, '^SELECT .* FROM cms_', 20, 1),
(22, 1, '^SELECT .* FROM eav_', 20, 1),
-- Everything else defaults to the primary hostgroup.
(99, 1, '.*', 10, 1);
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;
Be careful with read splitting. Magento's own replica support, configured through the slave_connection key in env.php, is honoured by core code and ignored by a good proportion of third-party modules. Replication lag on Flexible Server read replicas is asynchronous and can reach several seconds under write load, which produces the classic bug where a customer saves an address and the next page does not show it. Route reads to replicas for catalogue browsing and never for anything in the checkout or account flow.
6. Redis, Sessions, and Splitting the Cache
Use two separate Redis instances, or at minimum two separate databases with different eviction policies. Sessions and cache have opposite requirements and sharing an instance between them is how you get customers logged out during a cache flush.
Sessions need persistence and must never evict under memory pressure — an evicted session is a customer whose cart vanished. Cache wants aggressive eviction and does not care about durability at all, because a cache miss is a slow page and not a lost sale. Azure Cache for Redis lets you set maxmemory-policy per instance; set noeviction on sessions and allkeys-lru on cache.
<?php
// app/etc/env.php, abbreviated. Two distinct Azure Cache instances.
return [
'session' => [
'save' => 'redis',
'redis' => [
'host' => 'redis-sessions-prod.redis.cache.windows.net',
'port' => '6380',
'password' => getenv('REDIS_SESSION_KEY'),
'timeout' => '2.5',
'persistent_identifier' => '',
'database' => '0',
'compression_threshold' => '2048',
'compression_library' => 'gzip',
'log_level' => '3',
'max_concurrency' => '20',
// Lock handling matters more than anything else here.
// The default 10-minute lock wait turns one slow request
// into a queue of stalled requests for the same session.
'break_after_frontend' => '5',
'break_after_adminhtml' => '30',
'first_lifetime' => '600',
'bot_first_lifetime' => '60',
'bot_lifetime' => '7200',
'disable_locking' => '0',
'min_lifetime' => '60',
'max_lifetime' => '2592000',
'sentinel_master' => '',
],
],
'cache' => [
'frontend' => [
'default' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => 'redis-cache-prod.redis.cache.windows.net',
'port' => '6380',
'password' => getenv('REDIS_CACHE_KEY'),
'database' => '0',
'compress_data' => '1',
'compression_lib' => 'l4z',
],
],
'page_cache' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => 'redis-cache-prod.redis.cache.windows.net',
'port' => '6380',
'password' => getenv('REDIS_CACHE_KEY'),
'database' => '1',
'compress_data' => '0', // FPC entries are served hot;
// decompression costs more
// than the memory saved
],
],
],
],
];
Two Azure-specific notes. Port 6380 is the TLS port and you should be using it; the non-TLS 6379 endpoint can be disabled entirely on the instance and should be. And Azure Cache for Redis in the Standard tier gives you a replica with automatic failover but the failover takes tens of seconds, during which PHP will throw connection exceptions. Set a short connect timeout and make sure your session handler degrades rather than fataling, or a routine maintenance event on Microsoft's side becomes a visible outage.
The break_after_frontend value of 5 seconds is worth dwelling on. Magento's Redis session handler takes a lock per session so concurrent requests for the same session serialise. The default wait is far too long; a single slow AJAX call then blocks every subsequent request from that browser for minutes. Five seconds is aggressive and I have never regretted it.
7. The Shared Media Problem
This is the part of Magento-on-Kubernetes that has no clean answer, and where the sailing equipment retailer's £3,000 Premium Files bill came from.
Magento writes to pub/media at runtime: admin image uploads, generated product image caches, downloadable file assets, generated invoice PDFs. Multiple pods must see the same content. The obvious solution is a ReadWriteMany volume, and on Azure that means Azure Files.
Azure Files comes in two relevant flavours. The standard SMB tier is cheap and slow, with per-operation latency in the single-digit milliseconds that turns a page rendering 40 product images into something noticeably sluggish. The Premium tier is NFS-backed, much faster, and priced on provisioned capacity with IOPS scaling at roughly 1 IOPS per provisioned GiB plus bursting. To get 4,000 IOPS you provision 4 TiB, whether or not you have 4 TiB of data.
My strong preference is to avoid needing much from it at all:
Move image resizing out of Magento. Magento's built-in image cache generates resized variants on first request and writes them to pub/media/catalog/product/cache, which is the directory that generates almost all the IOPS. Replace it with on-the-fly resizing at the CDN or with an image service, and the cache directory stops existing. This one change took the retailer's share from 4 TiB provisioned to 512 GiB and the bill from £3,000 to under £200.
Serve media from Blob Storage, not from the share. The remote storage module writes uploads to blob storage and serves them from there. It is not a perfect implementation and the synchronisation on first setup is slow, but for the read path it removes the share from the critical path entirely.
Never put the application root on a share. I said this earlier and it bears repeating because I have now inherited two projects that did it. PHP resolves includes by stat-ing the filesystem. Over NFS, with realpath_cache cold, a single page load can generate thousands of network round trips. The symptom is a store where every page takes two seconds and the database is idle, and it is baffling until you strace a worker.
# Premium Files, NFS, mounted ONLY at pub/media. Note the mount options:
# the defaults are conservative and the read/write sizes matter a great
# deal for throughput on larger files.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: azurefile-premium-nfs
provisioner: file.csi.azure.com
parameters:
skuName: Premium_LRS
protocol: nfs
mountOptions:
- nconnect=8 # multiple TCP connections per mount; roughly 3x
# throughput on Premium NFS versus the default of 1
- rsize=1048576
- wsize=1048576
- hard
- noatime # Magento never reads atime; writing it on every
# read is pure waste on a network filesystem
- actimeo=30 # cache attributes for 30s. Media files are
# effectively immutable once written.
reclaimPolicy: Retain
allowVolumeExpansion: true
The nconnect=8 option is the single highest-value mount flag here and it is not on by default. It opens multiple TCP connections to the storage endpoint, and on Premium NFS shares it roughly triples achievable throughput for concurrent access. I have measured 118 MB/s with the default single connection and 340 MB/s with nconnect=8 on the same share, same node SKU.
8. Front Door, Application Gateway, or Both
Azure has two products that look like they do the same thing, and the documentation is not helpful about when you need which.
Azure Front Door is a global, anycast, edge-terminated CDN with a WAF and routing rules. It is the right front end for a public storefront. It gives you TLS termination at the edge close to the user, caching, and DDoS absorption at Microsoft's scale rather than yours.
Application Gateway is a regional layer-7 load balancer, also with a WAF option. It does path-based routing, session affinity, and integrates with AKS through the Application Gateway Ingress Controller.
You do not need both. The common pattern of Front Door in front of Application Gateway in front of AKS adds a hop, adds cost, and adds a place for headers to be rewritten in a way that breaks Magento's URL generation. I run Front Door Standard or Premium straight to an internal Nginx ingress controller on the cluster, with the origin locked down by Private Link so nothing but Front Door can reach it.
The two configuration details that matter for Magento:
// Front Door rule set. Magento's full page cache varies on a small,
// known set of things. If you let Front Door cache with default
// query-string handling you will serve one customer's currency to
// everyone, or fragment the cache so badly it never hits.
{
"rules": [
{
"name": "CacheStaticAssets",
"order": 1,
"conditions": [
{ "name": "UrlPath", "parameters": {
"operator": "BeginsWith",
"matchValues": ["/static/", "/media/"] } }
],
"actions": [
{ "name": "RouteConfigurationOverride", "parameters": {
"cacheBehavior": "OverrideAlways",
"cacheDuration": "365.00:00:00",
"queryStringCachingBehavior": "IgnoreQueryString",
"isCompressionEnabled": true } }
]
},
{
"name": "BypassCacheForDynamic",
"order": 2,
"conditions": [
{ "name": "UrlPath", "parameters": {
"operator": "BeginsWith",
"matchValues": ["/checkout", "/customer", "/cart", "/admin"] } }
],
"actions": [
{ "name": "RouteConfigurationOverride", "parameters": {
"cacheBehavior": "BypassCache" } }
]
}
]
}
And the X-Forwarded-Proto handling. Front Door terminates TLS and talks to your origin over HTTPS or HTTP depending on configuration, and Magento decides whether to generate https:// URLs based on what it thinks the request scheme was. Get this wrong and you get mixed content warnings, or an infinite redirect loop where Magento redirects to HTTPS, Front Door forwards as HTTP, and Magento redirects again.
# Nginx in the pod, sitting behind Front Door.
# Front Door sends X-Forwarded-Proto and X-Azure-FDID. Validate the
# latter or anyone who finds your origin can spoof the former.
map $http_x_azure_fdid $fd_allowed {
default 0;
"a1b2c3d4-0000-1111-2222-333344445555" 1;
}
server {
listen 8080;
if ($fd_allowed = 0) {
return 403; # origin is Private Link'd anyway, but defence in depth
}
set $magento_https "off";
if ($http_x_forwarded_proto = "https") {
set $magento_https "on";
}
location ~ ^/(index|get|static|errors/report|errors/404|errors/503|health_check)\.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param HTTPS $magento_https;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
9. Search: Where Azure Genuinely Lets You Down
Magento 2.4 requires Elasticsearch or OpenSearch. Azure does not offer a first-party managed one. Azure AI Search is a different product with a different API and does not speak the protocol Magento needs.
So you have three unappealing options.
Elastic Cloud on Azure, bought through the Azure Marketplace, deployed into the same region. This is what I use most often. It is a genuine managed service, billing goes through your Azure subscription, and it can be reached over Private Link so traffic does not traverse the public internet. It is also the most expensive option by some margin — expect £400 to £900 a month for a production-sized cluster.
OpenSearch in the cluster, self-managed, as a StatefulSet with Premium SSD persistent volumes. Cheapest, and you now own an Elasticsearch cluster, including upgrades, shard rebalancing, and the 2am page when a node runs out of disk. Viable if you already have that expertise. Painful if you do not.
AWS OpenSearch Service across a VPN, which sounds absurd and which I have seen done twice, both times because the organisation had an existing AWS relationship. The cross-cloud latency of 8 to 15 ms is survivable for search but the egress charges and the operational weirdness are not worth it unless you have a strong existing reason.
If you self-host, the configuration that matters most is heap sizing and shard count. Magento creates one index per store view per index type, and a store with four store views and a large catalogue easily ends up with 30 shards it does not need. Over-sharding on a small cluster is the most common cause of Magento search being slow, and the fix is to set the shard count explicitly rather than accepting the default.
# Magento's default index settings create 1 shard and 1 replica per index,
# which is correct. The problem is people copying a template with 5 shards.
# Check what you actually have:
curl -s "https://os.internal:9200/_cat/indices/magento2*?v&h=index,pri,rep,docs.count,store.size"
# A single-node dev cluster with replicas configured will sit yellow
# forever and Magento will report the index as unhealthy. Set replicas
# to 0 there and 1 in production, per environment, not globally.
bin/magento config:set catalog/search/elasticsearch7_index_prefix magento2_prod
10. Deployments, Migrations, and the Processes That Aren't Web Traffic
The build artefact and the read-only root
Magento's build step is the awkward part of containerising it. composer install, setup:di:compile and setup:static-content:deploy together take between four and twenty minutes depending on the number of themes and locales, and they must run before any request is served.
The right answer is to bake everything into the image. Build once, in the pipeline, and produce an immutable image containing the vendor directory, generated code, and deployed static content. Then set the application root read-only in the pod security context, which catches an entire class of problem where something writes to the filesystem at runtime and one pod diverges from the others.
# Multi-stage. The build stage has Composer and dev dependencies;
# the runtime stage has neither.
FROM php:8.3-fpm-alpine AS build
RUN apk add --no-cache git unzip icu-dev libzip-dev oniguruma-dev \
&& docker-php-ext-install -j"$(nproc)" bcmath intl pdo_mysql soap sockets zip opcache
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Copy manifests first so the dependency layer caches across code changes.
COPY composer.json composer.lock auth.json ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev \
&& bin/magento setup:di:compile \
&& bin/magento setup:static-content:deploy en_GB de_DE fr_FR -f --jobs="$(nproc)" \
&& rm -rf var/cache var/page_cache var/session generated/code/Magento/Framework/App/ResourceConnection
FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs libzip oniguruma \
&& docker-php-ext-install -j"$(nproc)" bcmath intl pdo_mysql soap sockets zip opcache
COPY --from=build /app /app
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/
WORKDIR /app
# Everything Magento writes at runtime is a mounted volume or emptyDir.
# If it tries to write anywhere else, we want to know at deploy time.
USER 1000
CMD ["php-fpm", "-F"]
The opcache configuration deserves its own file and its own attention, because it is the highest-leverage PHP tuning on a Magento box and the defaults are far too small.
; opcache.ini — Magento 2.4 with a moderate module set compiles to
; roughly 30,000 files. The default 10,000 max_accelerated_files
; silently evicts, and you get a store that is fast for some requests
; and slow for others with no obvious pattern.
opcache.enable=1
opcache.memory_consumption=768
opcache.max_accelerated_files=60000
opcache.interned_strings_buffer=64
opcache.validate_timestamps=0 ; the image is immutable; never stat
opcache.save_comments=1 ; Magento's DI reads annotations
opcache.enable_file_override=1
opcache.huge_code_pages=1
opcache.jit=tracing
opcache.jit_buffer_size=128M ; measure this. On some catalogues JIT
; gives 8-12% on TTFB; on others it is
; noise. It is not free memory.
; Preloading gives a further 5-10% and costs nothing at runtime.
opcache.preload=/app/generated/code/preload.php
opcache.preload_user=www-data
Setting validate_timestamps=0 is only safe because the image is immutable. If you deploy by rsyncing files onto a running container — and people do — this setting will serve stale code forever and you will lose an afternoon.
Migrations, and the deploy that takes the site down
The build being immutable does not solve the database. setup:upgrade runs schema and data patches, and on a large store some of those patches lock tables. A deployment that rolls out new pods while a schema patch is mid-flight gives you pods running new code against an old schema, which fails in creative ways.
The pattern I use is a Kubernetes Job as a Helm pre-upgrade hook, which runs to completion before any pod rollout begins.
apiVersion: batch/v1
kind: Job
metadata:
name: magento-setup-upgrade
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 0 # a failed migration must NOT be retried blindly
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
nodeSelector:
workload: worker
tolerations:
- key: workload
operator: Equal
value: worker
effect: NoSchedule
containers:
- name: upgrade
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command:
- /bin/sh
- -c
- |
set -e
# Maintenance mode only for the duration of schema changes.
# Magento's own flag file is on a shared volume so all pods
# see it; without that, only this pod thinks it is down.
bin/magento maintenance:enable
bin/magento setup:upgrade --keep-generated
bin/magento maintenance:disable
envFrom:
- secretRef:
name: magento-env
The --keep-generated flag is important. Without it, setup:upgrade wipes the generated code that you carefully compiled at build time, and the first request to each new pod triggers a runtime compile that takes minutes and times out the readiness probe. I have watched a deployment fail for this exact reason and spent longer than I want to admit finding it.
backoffLimit: 0 is a deliberate choice too. A migration that fails halfway should stop and page someone, not retry and make the partial state worse.
Cron and the consumers
Magento's cron is a single entry point that dispatches everything: indexing, cache flushes, currency updates, order status transitions, newsletter sends. Running it in the web pod is wrong — it competes with request handling, it runs N times if you have N pods, and it disappears when the pod is evicted.
Run it as its own Deployment with exactly one replica, on the worker pool, with a recreate strategy so you never have two.
apiVersion: apps/v1
kind: Deployment
metadata:
name: magento-cron
spec:
replicas: 1
strategy:
type: Recreate # NOT RollingUpdate. Two crons is worse than none.
selector:
matchLabels: { app: magento-cron }
template:
metadata:
labels: { app: magento-cron }
spec:
nodeSelector: { workload: worker }
tolerations:
- { key: workload, operator: Equal, value: worker, effect: NoSchedule }
containers:
- name: cron
image: "registry.example.com/magento:1.42.0"
command: ["/bin/sh", "-c"]
args:
- |
while true; do
php bin/magento cron:run --group=default
php bin/magento cron:run --group=index
sleep 60
done
resources:
requests: { memory: "2Gi", cpu: "500m" }
limits: { memory: "6Gi" } # no CPU limit: throttling a
# reindex just makes it longer
Leaving the CPU limit off is a considered choice. CFS throttling on a CPU-limited pod produces long, unpredictable pauses, and for a batch workload that nobody is waiting on synchronously, letting it burst is better than metering it. Set requests so the scheduler places it sensibly and let it use spare capacity.
The message queue consumers are separate again. Magento 2.4 defines around twenty consumers by default and most stores need four or five of them running continuously — async operations, product action attribute updates, inventory reservation cleanup, and the export consumers if you use them. Do not run them all; each is a PHP process holding a database connection.
# List what is actually defined, then run only what you need.
bin/magento queue:consumers:list
# --max-messages forces the process to exit after N messages so the
# container restarts and reclaims leaked memory. PHP long-running
# processes in Magento leak; this is a pragmatic mitigation, not a fix.
bin/magento queue:consumers:start product_action_attribute.update \
--max-messages=5000 --single-thread
The --max-messages flag plus a Kubernetes restart policy of Always is the standard pattern: the consumer processes 5,000 messages, exits cleanly, and the kubelet restarts it with fresh memory. It looks like a hack because it is one, but PHP's memory behaviour in long-lived processes makes it necessary.
11. Autoscaling That Helps Rather Than Thrashes
The horizontal pod autoscaler on CPU is the default and it is mediocre for Magento. PHP-FPM's CPU usage does not rise smoothly with load; it stays low while requests queue for a free worker, then spikes. By the time CPU crosses 70%, your queue is already deep and customers are already waiting.
Scale on the thing that actually indicates saturation: PHP-FPM's active process count as a fraction of pm.max_children. Export it with the PHP-FPM exporter, scrape it with Prometheus, and use KEDA or the custom metrics API to drive the HPA.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: magento-web
spec:
scaleTargetRef:
name: magento-web
minReplicaCount: 3
maxReplicaCount: 20
cooldownPeriod: 300 # scale DOWN slowly. A pod that leaves
# mid-checkout is a lost order.
pollingInterval: 15
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # scale up immediately
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
query: |
avg(phpfpm_active_processes / phpfpm_max_children) * 100
threshold: "60"
Asymmetric scaling behaviour is the point here. Scale up instantly and aggressively; scale down one pod at a time over ten minutes. The cost of an extra pod for ten minutes is pennies. The cost of a scaled-down pod terminating a checkout is a lost order and a support ticket.
Also set a sensible terminationGracePeriodSeconds — 60 seconds at minimum — and make sure PHP-FPM receives SIGQUIT rather than SIGTERM so it finishes in-flight requests before exiting. The default container stop signal will kill it mid-request.
Node scaling lags pod scaling
The cluster autoscaler needs to provision a VM, which on Azure takes 90 to 180 seconds for a D-series node, plus image pull time. If your Magento image is 2.5 GB — and it will be, with static content for four locales — that pull adds another minute unless the image is cached on the node. Two mitigations: keep a small over-provisioning deployment of low-priority pause pods so there is always headroom for one node's worth of real pods to schedule instantly, and use Azure Container Registry with the artifact cache and a geo-replicated registry in the same region as the cluster.
12. Secrets, Identity, and Not Putting Passwords in env.php
Magento's env.php wants a database password, Redis keys, and various API credentials as literal values. Committing that file is obviously wrong; templating it at deploy time from Kubernetes Secrets is the usual answer and is only marginally better, because Kubernetes Secrets are base64, not encrypted, and anyone with namespace read access has them.
On Azure the correct arrangement is workload identity plus Key Vault, with the CSI driver mounting secrets as files and the pod reading them via environment variables that are populated at container start. No credential is ever stored in the cluster.
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: magento-kv
spec:
provider: azure
parameters:
usePodIdentity: "false"
useVMManagedIdentity: "false"
clientID: "00000000-1111-2222-3333-444444444444" # workload identity
keyvaultName: "kv-commerce-prod"
tenantId: "55555555-6666-7777-8888-999999999999"
objects: |
array:
- |
objectName: mysql-password
objectType: secret
- |
objectName: redis-cache-key
objectType: secret
- |
objectName: redis-session-key
objectType: secret
# secretObjects syncs them into a k8s Secret as well, which you need
# for envFrom. If you can read them from files instead, skip this
# block entirely and keep them out of etcd.
secretObjects:
- secretName: magento-env
type: Opaque
data:
- objectName: mysql-password
key: MYSQL_PASSWORD
Better still, drop the password entirely. Azure Database for MySQL Flexible Server supports Microsoft Entra authentication, which means the pod authenticates with its workload identity and receives a short-lived token instead of a static password. It requires a small amount of glue because Magento expects a password string, but a token-refresh sidecar that writes a token to a shared file every 45 minutes handles it, and it removes the highest-value credential from your estate.
The same applies to Blob Storage for media and to the container registry. Every place you have a connection string is a place you have a secret to rotate; every place you use a managed identity is a place you do not.
13. Networking, Private Endpoints, and the Egress Bill
By default, an AKS pod talking to Azure Database for MySQL goes out through the cluster's outbound path, across Microsoft's backbone, to the database's public endpoint. It works, and it means your database has a public endpoint.
Use private endpoints for MySQL, Redis, Blob Storage, and Key Vault, with private DNS zones so the service hostnames resolve to private IPs from inside the VNet. This is not primarily a performance decision — latency improves by a millisecond or two at most — it is that your data services stop being reachable from the internet at all.
The bit people miss is DNS. If your private DNS zone is not linked to the cluster's VNet, the hostname resolves to the public IP and everything still works, silently, over the public path. I check this on every deployment now, because it fails open.
# Confirm the private endpoint is actually being used. If this returns
# a 10.x address you are on the private path; a public IP means the
# DNS zone link is missing and you are paying for internet egress.
kubectl run -it --rm dnscheck --image=busybox --restart=Never -- \
nslookup mysql-commerce-prod.mysql.database.azure.com
# And check where your egress is actually going. This is the query
# that found £700/month of cross-zone traffic on one project.
az monitor metrics list \
--resource "/subscriptions/$SUB/resourceGroups/rg-commerce-prod/providers/Microsoft.Network/natGateways/nat-commerce" \
--metric "ByteCount" \
--interval PT1H \
--start-time 2026-07-01T00:00:00Z
Three cost traps in Azure networking that catch Magento deployments specifically. Cross-zone traffic within a region is billed, and a zone-spread cluster talking to a single-zone database pays it constantly. Egress to the internet from the cluster is billed, and if your Front Door origin is configured over the public path rather than Private Link, every cache miss is billed egress. And NAT Gateway is billed per gigabyte processed as well as per hour, so a chatty integration polling an external API is a line item.
14. Observability Without Buying Everything
Azure Monitor and Container Insights are the default and they are expensive at any real log volume, because ingestion is billed per gigabyte and a Magento cluster in debug mode generates a startling amount. Two things to do immediately: set the data collection rule to exclude stdout from namespaces you do not care about, and turn off Magento's own debug logging in production, which is on by default in some deployment templates and writes every database query to var/log/debug.log.
What is worth instrumenting:
PHP-FPM pool status, exported to Prometheus. Active processes, idle processes, listen queue depth, slow requests. Listen queue depth above zero for any sustained period means you are under-provisioned, and it is a far earlier signal than response time.
Magento's own slow query log, via the MySQL slow query log with long_query_time = 1. Turn it on, ship it, and look at it weekly. Almost every Magento performance problem I have diagnosed on Azure was ultimately a query — usually a third-party module doing an unindexed lookup on catalog_product_entity_varchar in a loop.
Real user monitoring at the edge. Front Door gives you origin latency and cache hit ratio, and the cache hit ratio is the number that most directly predicts your infrastructure cost. A store with a 30% edge hit ratio is running three times the origin capacity it needs.
Queue depth per consumer. The Magento message queue tables are just database tables; a query counting unconsumed messages per topic, run every minute, gives you a leading indicator of consumer failure. This is the same reconciliation logic I described in the SAP Commerce and S/4HANA synchronisation piece and it applies just as well when both ends are Magento's own subsystems.
-- Unconsumed message age per topic. Alert on age, not just count:
-- a queue can be short and completely stuck.
SELECT q.name AS queue,
COUNT(*) AS pending,
TIMESTAMPDIFF(MINUTE, MIN(m.created_at), NOW()) AS oldest_minutes
FROM queue_message_status s
JOIN queue q ON q.id = s.queue_id
JOIN queue_message m ON m.id = s.message_id
WHERE s.status IN (2, 7) -- 2 = new, 7 = retry_required
GROUP BY q.name
HAVING oldest_minutes > 15
ORDER BY oldest_minutes DESC;
15. What Things Actually Cost
Rough monthly figures for UK South, at list price, for a store doing around 400,000 sessions a month. These are the numbers I use for early estimates and they have been within about 20% each time.
| Component | Configuration | Approx. £/month |
|---|---|---|
| AKS web pool | 3–8 × D8s_v5, 1-yr reserved | 780 |
| AKS worker pool | 2 × E4s_v5, 1-yr reserved | 210 |
| AKS system pool | 2 × D2s_v5 | 110 |
| MySQL Flexible Server | Business Critical, 8 vCore, 512 GB, zone-redundant HA | 1,150 |
| MySQL read replica | General Purpose, 4 vCore | 290 |
| Azure Cache for Redis (cache) | Premium P1, 6 GB | 340 |
| Azure Cache for Redis (sessions) | Standard C1, 1 GB | 75 |
| Elastic Cloud | 2 × 4 GB hot nodes | 420 |
| Azure Files Premium | 512 GiB provisioned | 95 |
| Blob Storage (media) | 200 GB hot + transactions | 25 |
| Front Door Premium | Base + 2 TB egress + WAF | 310 |
| Container Registry | Premium, geo-replicated | 40 |
| Log Analytics | ~40 GB/month ingestion | 120 |
| Networking (NAT, private endpoints, egress) | — | 180 |
That is roughly £4,145 a month, and the two lines worth negotiating hardest are the database and Front Door. Reserved instances on the database save 35 to 40% for a one-year commitment and Magento databases do not get smaller, so the commitment is low-risk. Front Door egress is where a badly-configured cache costs you real money; every ten percentage points of edge hit ratio is worth roughly £30 a month at this traffic level, plus the origin capacity you no longer need.
The line that is easiest to get wrong by a factor of five is Log Analytics. Forty gigabytes a month is a well-behaved cluster. A cluster with Magento debug logging on and Container Insights collecting everything will do 400 GB and cost £1,200, and it will creep up rather than jump, so nobody notices.
16. A Migration That Mostly Went Well
The sailing equipment retailer from the opening. Magento 2.4.6, three store views (UK, Germany, France), 34,000 SKUs, roughly 90,000 sessions a week with a hard Black Friday peak at about six times baseline. They came to me because the bill was too high and their Black Friday 2024 had degraded badly under load.
What we changed, in order. First the Premium Files share, from 4 TiB to 512 GiB, after moving image resizing to Front Door rules and a small image transform service. That was three days of work and saved about £2,800 a month, which paid for the rest of the engagement immediately and bought a lot of goodwill for the less obviously valuable work that followed.
Second, the zone topology. Worker pool pinned to zone 1 alongside Redis and the database primary; web pool left zone-redundant. £700 a month.
Third, the autoscaler. They were scaling on CPU at 75%, which meant that during their Black Friday ramp the pods were saturated and queueing for ninety seconds before the first new pod scheduled. We moved to the PHP-FPM saturation metric at 60%, added the over-provisioning pause deployment, and pre-scaled the minimum replica count from 3 to 8 for the promotional window. Their p95 TTFB during the 2025 peak was 340 ms against 2,100 ms the previous year.
Fourth, opcache. Their max_accelerated_files was the default 10,000 against roughly 41,000 compiled files, so the cache was thrashing continuously. Raising it to 60,000 and enabling preloading took p50 TTFB from 290 ms to 180 ms across the board, for a two-line configuration change. This is the least glamorous item on the list and one of the highest-value.
What went wrong. The database migration. We moved from an over-sized Single Server instance to Flexible Server Business Critical with zone-redundant HA, and the cutover was planned as a 40-minute window using replication with a final catch-up. It took four hours and twenty minutes.
The cause was log_bin_trust_function_creators, which was off on the target and which Magento's MView triggers require. The setup:upgrade that ran after cutover failed to install triggers, indexing silently fell back to "update on save" for three indexers, and the site came up with a stale catalogue. We spent two hours diagnosing that before finding it, then had to run a full reindex on a cold buffer pool, which was the other three hours.
Two lessons. The parameter difference between source and target should have been diffed and reconciled before the window — that is a ten-minute check that would have saved four hours. And the rehearsal we did on staging used a database restored from a backup that already had the triggers installed, so the failure could not have appeared. A rehearsal that cannot reproduce the failure mode is not a rehearsal of the thing you care about.
What I would do differently. Build the cost dashboard in week one rather than week six. Not the Azure Cost Management default view, which aggregates at a level too high to be actionable, but a tagged breakdown by component with a weekly delta. The cross-zone egress was visible in the data from day one and we found it because I went looking, not because anything surfaced it.
17. Disaster Recovery, Realistically
Most Magento stores I see have backups and no tested restore, which is a way of having no backups with extra steps.
The honest question is what you are protecting against, because the answers diverge sharply. Zone failure within a region is handled by zone-redundant HA on the database and a zone-spread node pool, costs maybe 30% more on the database line, and is a reasonable default for any store where a day of downtime is expensive. Region failure is a much bigger commitment: a warm standby in a second region with geo-replicated storage, a read replica you can promote, and a Front Door origin group that can fail over. Expect it to add 50 to 70% to your infrastructure bill for a capability you will probably never use.
I would not build cross-region DR for most mid-market stores. I would build it, and have, for a store where an hour of downtime costs more than the annual DR bill. The arithmetic is usually clear once someone actually does it, and the reason it does not get done is that nobody wants to be the person who says "we accept a four-hour recovery time".
What everyone should have regardless: an automated backup with point-in-time restore on the database, a tested restore procedure run at least twice a year against a real target, media in geo-redundant blob storage, and infrastructure defined as code so the cluster itself can be rebuilt from a repository rather than from memory. The last one is the item that most often does not survive contact with reality, because someone made a change in the portal during an incident and never brought it back into Terraform.
# Detect portal drift against your Terraform state. Run it weekly in CI
# and fail the build on a non-empty plan, so drift is caught while
# someone still remembers making the change.
terraform plan -detailed-exitcode -refresh-only
# exit 0 = no drift, 2 = drift detected, 1 = error
18. Things I Got Wrong the First Time
Worth stating plainly, because these are the mistakes that look reasonable in a design document.
I put the whole application root on Azure Files. On my first AKS Magento build, in 2022, I reasoned that all pods needing the same code meant sharing the filesystem. The store served pages in 3.5 seconds with an idle database and an idle CPU. Bake the code into the image; share only what is genuinely written at runtime.
I used one Redis for sessions and cache. Then a cache flush during a deployment evicted sessions and logged out everyone mid-checkout on a Friday afternoon. Separate instances, different eviction policies.
I set CPU limits on the consumer pods. Seemed like good hygiene. It meant a reindex that should have taken twelve minutes took fifty, because CFS throttling stalls the process for the remainder of each 100 ms period once the quota is used. Requests yes, limits no, for batch work.
I trusted the readiness probe on /health_check.php. Magento's default health check returns 200 as soon as PHP responds, which is before the database connection is verified, before the cache is warm, and well before the pod can serve a category page in reasonable time. A pod entered rotation, took forty requests, and every one of them was slow. Write a real readiness endpoint that checks the database, Redis and search.
<?php
// pub/health.php — readiness, not liveness. This should fail if the
// pod cannot serve real traffic, so the service stops sending it any.
require __DIR__ . '/../app/bootstrap.php';
$checks = [];
$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
$om = $bootstrap->getObjectManager();
try {
$om->get(\Magento\Framework\App\ResourceConnection::class)
->getConnection()->fetchOne('SELECT 1');
$checks['db'] = 'ok';
} catch (\Throwable $e) {
http_response_code(503);
$checks['db'] = 'fail: ' . $e->getMessage();
}
try {
$cache = $om->get(\Magento\Framework\App\CacheInterface::class);
$cache->save('1', 'health_probe', [], 10);
$checks['cache'] = $cache->load('health_probe') === '1' ? 'ok' : 'fail';
} catch (\Throwable $e) {
http_response_code(503);
$checks['cache'] = 'fail';
}
header('Content-Type: application/json');
echo json_encode($checks);
Use a separate, cheaper liveness probe — a plain 200 from Nginx — because a liveness probe that checks the database will restart every pod in the cluster when the database has a brief hiccup, turning a two-second blip into a full outage. That is a mistake I have made and it is spectacular.
19. Questions That Come Up
"Should we use Adobe Commerce Cloud instead?" If you are on Adobe Commerce rather than Magento Open Source, Adobe's own cloud offering is AWS-based, opinionated, and removes most of what this article describes. It is also more expensive and considerably less flexible, and the support model frustrates good engineers. I would choose it for a team without infrastructure capability and against it for a team that has one. Being on Azure for the rest of your estate is a legitimate reason to go your own way.
"Can we run MySQL in the cluster instead of Flexible Server?" You can. You should not. The operational burden of running a production MySQL with HA, backups, point-in-time recovery and minor version upgrades is genuinely large, and the managed service is not that expensive by comparison. This is the clearest buy-versus-build call in the entire stack.
"Is Azure worse than AWS for Magento?" Slightly, and only in two places: there is no first-party managed Elasticsearch that Magento can use, and Azure Files Premium is a less pleasant shared filesystem than EFS with less predictable performance characteristics. Everything else is comparable. If your organisation is on Azure, those two gaps are not a reason to move clouds. If you are choosing fresh with no existing commitment, AWS is marginally the smoother path for this specific workload.
"How do we handle Black Friday?" Pre-scale rather than trusting the autoscaler, because node provisioning takes minutes and your traffic ramp will outrun it. Set the minimum replica count high two days before, keep it high through the weekend, and accept the cost. Load-test with a realistic mix — the mistake is testing category pages, which are cached and fast, rather than add-to-cart and checkout, which are neither. And freeze deployments, because the most common Black Friday incident is not load, it is a change.
"Do we need a service mesh?" No. A Magento deployment has maybe six services and none of the traffic patterns that justify a mesh. Adding Istio to this stack is adding a distributed system to debug in exchange for mTLS you could get more simply and observability you already have. I have never regretted not having one here.
"Where does a headless frontend fit?" Straightforwardly, as another deployment on the same cluster or as a separate Static Web App with Front Door routing between them by path. The consideration that catches people out is GraphQL: Magento's GraphQL endpoint is much heavier than its REST equivalent and the full page cache does not help it, so a headless frontend shifts load onto uncached PHP execution. Size the web pool for that or put a persisted-query cache in front. There is more on the trade-offs in the headless commerce performance write-up.
"What about Windows containers?" No. Magento is a Linux application. Every attempt to run it otherwise has ended badly and the performance is not close.
20. What I'd Do First
If you are starting an Azure Magento build, or inheriting one, this is the order I would work in.
First, check opcache. opcache.max_accelerated_files against the actual compiled file count, and opcache.memory_consumption against actual usage. It takes ten minutes and on a store that has never been tuned it is routinely worth 30 to 40% of time-to-first-byte. Nothing else on this list has that ratio of effort to result.
Second, look at where your storage bill comes from. Provisioned versus used on any Premium Files share, and whether the image cache directory is the reason the IOPS requirement exists. This is where the largest single overspend usually hides.
Third, separate the cache and session Redis instances if they are shared, and set the eviction policies deliberately. Cheap, fast, prevents a specific and very bad failure.
Fourth, map your zone topology against your data services and look at the cross-zone egress metric. If your workers are spread across zones and your database is not, you are paying for it continuously.
Fifth, fix the autoscaling signal. CPU is the wrong metric for PHP-FPM; the saturation ratio is the right one, and the asymmetric up-fast-down-slow behaviour matters as much as the metric.
Sixth, and only once the above are done, look at the cluster architecture itself. Node pool separation, taints, and the deployment model. These matter, and they are also the things people start with, which is why so many carefully-architected clusters run slowly and cost too much.
And before any of it, tag everything and build a cost view you actually look at weekly. Not because cost is the most important thing, but because on Azure the bill is the most reliable signal you have that something is misconfigured. The £3,000 file share was not a performance problem or an availability problem. It was a decision that made sense in March and stopped making sense in June, and nothing except the invoice was ever going to tell anyone.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Cloud Migration Guide: Migrating Monolithic E-Commerce to AWS & Azure
Cloud migration architecture strategies.
-
Containerizing Monolithic E-Commerce: Docker & Kubernetes
Kubernetes HPA deployment manifests.