Cloud: Autoscaling, Observability, Backups and Security

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 (generally available 2026-05-12) and Mage-OS 3.6.0, PHP 8.5, MySQL 8.4 on Amazon RDS, OpenSearch 3.x, Valkey 9 and RabbitMQ 4.3 as the shared baseline of this series, nginx 1.29, Kubernetes 1.36 and 1.37 on EKS, OpenTofu 1.12 with the AWS provider 6.x, hipages/php-fpm_exporter 2.2.0, and checked on 2026-10-29. The Magento facts were read in the source of the public magento/magento2 repository (branch 2.4-develop: app/etc/di.xml, lib/internal/Magento/Framework/Logger/Handler/Base.php and System.php, lib/internal/Magento/Framework/Encryption/Encryptor.php, app/code/Magento/EncryptionKey/Console/Command/, app/code/Magento/Sales/Console/Command/EncryptionPaymentDataUpdateCommand.php, app/code/Magento/Developer/Console/Command/) and in the Adobe documentation cited after each example. The YAML listings were validated with kubeconform -strict against the Kubernetes 1.36 schemas (custom resources are skipped by -ignore-missing-schemas), the OpenTofu listings with tofu init -backend=false, tofu validate and tofu fmt -check, the shell listings with shellcheck, the PHP and XML listings with php -l and xmllint, and the PHP-FPM pool file with php-fpm -t. Nothing was created in an AWS account and no Magento store was started for this page, so the AWS-side behaviour (WAF, AWS Backup, CloudWatch, Secrets Manager rotation) is as documented, not as observed, and the Magento classes shown are checked for syntax and against the source, not run.

The deployment of Pocket Coffee on EKS is described in Cloud: Reference Architecture and Cloud: EKS Infrastructure and Helm Deployment: the Deployment pocketcoffee-web runs nginx and PHP-FPM as two containers behind the Varnish StatefulSet pocketcoffee-varnish, the database is the RDS instance pocketcoffee-prod, search is the OpenSearch domain pocketcoffee-prod, Valkey holds the cache and the sessions, RabbitMQ is the broker pocketcoffee-prod-mq, media is in the bucket pocketcoffee-prod-media, and the secrets are in Secrets Manager. This page is about the day after: how the shop scales with traffic, how to see what it is doing, how to get it back after a loss, and how to protect and rotate what it depends on. Generic fundamentals are linked rather than repeated: Kubernetes autoscaling, Monitoring and observability on AWS, Amazon RDS, Secrets Manager and KMS. The queue consumers and the cron Job are scaled and monitored on Cloud: Cron, Queue Consumers and Indexers; the release sequence is on Cloud: Releases and Zero-Downtime Deployments. The same operations for PrestaShop, with the same tools, are on the PrestaShop page; this page keeps what is specific to Magento.

What is backed up, what is rebuilt and what is copied to the second region for the Pocket Coffee Magento shop

What there is to operate

Concern Where the signal or the data is What Magento adds

Load

CPU of the php-fpm container, busy PHP-FPM workers, the listen queue, and the hit rate of Varnish

Most page views never reach PHP, so the load on pocketcoffee-web follows the cache misses, the checkout, the Admin and the API, not the visitors

Errors

var/log/system.log, var/log/exception.log, var/log/debug.log, var/report/, the PHP-FPM error log, the nginx access log

Several files per pod, none of them on stdout by default

Database

RDS metrics, the slow query log, Database Insights

Indexers, the cron cron_schedule table and the quote tables give a different profile from the catalog reads

Data to recover

The RDS instance, the media bucket (or EFS), the secret

OpenSearch, Valkey and Varnish are rebuilt, not restored; RabbitMQ holds work in flight only

Access

The Admin URL, the login pages, the REST and GraphQL endpoints, the payment callbacks

The Admin frontName is a configuration value, and 2FA is a module that is enabled by default

Keys

crypt/key, the database passwords, the OpenSearch, Valkey and AMQP credentials

The encryption key is a list of keys that only grows; rotating it does not by itself re-encrypt stored data

Autoscaling the web tier

A Magento pod is a fixed pool of PHP-FPM workers, as for any PHP shop: when every worker is busy, requests wait in the listen queue of the FastCGI socket and then time out at nginx, often before the CPU of the pod is saturated, because the workers wait for the database, for OpenSearch or for a payment call. Two things are specific to Magento and change how the HPA is tuned.

First, Varnish sits in front. With the full page cache at the edge of the cluster (see Cloud: Varnish and Full Page Cache on Kubernetes), the traffic that reaches pocketcoffee-web is the misses, the passes (cart, checkout, customer account) and the API calls. A campaign e-mail raises the visitors tenfold and the load on PHP only as much as the cache is cold for the landing pages; a cache purge after a release does the opposite, and sends the whole catalogue to PHP at once. The HPA therefore has to react quickly to a sudden rise and the minimum has to cover a cold cache. Second, the number of pods is bounded by the database: each running request holds a database connection, so maxReplicas x pm.max_children plus the cron Job, the consumers and the migration Job must fit under max_connections. The arithmetic and RDS Proxy are in Cloud: Managed Database (RDS and Aurora); choose maxReplicas from it, not from the size of the cluster.

The HorizontalPodAutoscaler

The HorizontalPodAutoscaler computes CPU utilisation as a percentage of the CPU request of the containers of the pod and reports unknown if there is no request, so the php-fpm container needs one that reflects real use; the sidecars count in the average too. The listing scales on two metrics: the CPU of the pod and the number of busy workers per pod (phpfpm_active_processes, next section). With two metrics the controller computes a desired number of replicas for each and takes the larger.

hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pocketcoffee-web
  namespace: pocketcoffee
  labels:
    app.kubernetes.io/name: pocketcoffee-magento
    app.kubernetes.io/component: web
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pocketcoffee-web
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
    - type: Pods
      pods:
        metric:
          name: phpfpm_active_processes
        target:
          type: AverageValue
          averageValue: "8"
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
        - type: Pods
          value: 4
          periodSeconds: 60
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 600
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

The numbers follow from the pool of the next section: with pm.max_children = 16, a target of 8 busy workers keeps half of the workers free for a burst while new pods start. Three is the minimum so that the loss of one availability zone leaves two pods and the PodDisruptionBudget pocketcoffee-web has room to drain nodes. Scale up fast and scale down slowly, because a new pod starts with a cold OPcache and, in the layout of this series, an empty var/ (it is an emptyDir), which is the slowest the shop will ever be. The Helm chart owns the HPA as the template hpa.yaml with web.replicas as the initial replica count; leave spec.replicas out of the Deployment when the HPA is enabled, or every helm upgrade resets the count (see Cloud: EKS Infrastructure and Helm Deployment).

The Pods metric needs an adapter that exposes it through the custom.metrics.k8s.io API: the Kubernetes SIGs prometheus-adapter with a rule over phpfpm_active_processes, or KEDA, which replaces the HPA by a ScaledObject (KEDA is already used for the consumers). The rule, the PodMonitor and the trade-off are the same as for WordPress and are in Releases, Upgrades and Autoscaling on Kubernetes. Until the adapter exists, delete the Pods entry and keep the CPU metric: an HPA that lists a metric it cannot read stops scaling with FailedGetPodsMetric.

flowchart LR FPM["php-fpm pool\n/status on port 9000"] --> EXP["php-fpm-exporter\nsidecar, port 9253"] EXP --> PROM["Prometheus\nPodMonitor"] PROM --> ADAPT["prometheus-adapter\ncustom.metrics.k8s.io"] ADAPT --> HPA["HorizontalPodAutoscaler\npocketcoffee-web"] HPA --> DEP["Deployment\npocketcoffee-web"] DEP --> NODES["Nodes\nKarpenter or Auto Mode"]

Nodes

The HPA adds pods; it does not add nodes. Pods that do not fit stay Pending until the node layer reacts: Karpenter, or EKS Auto Mode which runs it for you, creates a node for them and removes empty ones later; both are described in EKS clusters. Three consequences for this shop. First, set the CPU and memory requests of both containers honestly, because the node scheduler packs pods by requests. Second, an Auto Mode node is replaced at most every 21 days and Karpenter consolidates nodes, so pods are evicted regularly: keep the PodDisruptionBudget, a preStop pause so that nginx stops receiving traffic before the container ends, and a terminationGracePeriodSeconds longer than the slowest checkout request (a pod killed in the middle of a payment callback is where graceful termination costs orders; see Scheduling and disruptions). Third, the Varnish pods are memory caches: evicting one empties it, so the PodDisruptionBudget of pocketcoffee-varnish should allow one pod at a time and the node pool that hosts them should not be consolidated aggressively. The consumers (pocketcoffee-consumer-<slug>) scale on queue length with KEDA and need no HPA of their own.

What scaling does not fix

  • The database and OpenSearch. Another pod does not make a slow query or a slow search faster; it runs the next one in parallel. If phpfpm_listen_queue grows while the CPU of the pods is low, look at the slow queries (below) and at the search domain before raising maxReplicas.

  • The Admin. A catalog import, a reindex from the Admin or a large export is one long request with a large memory use. Do not size the web pool for it: route the Admin to its own Deployment, as described in the Admin section below, or run the work as a Job.

  • The cache purge. Flushing every cache after a release sends all the traffic to PHP. The warm-up and the order of the release are on Cloud: Releases and Zero-Downtime Deployments.

  • The cron Job. pocketcoffee-cron is a CronJob, not part of the HPA; its runs must not overlap (concurrencyPolicy: Forbid).

PHP-FPM metrics

PHP-FPM publishes the state of its pool on a status page when pm.status_path is set. The page is served by FPM itself over FastCGI, not by nginx, so it is reachable only by a client that speaks FastCGI to 127.0.0.1:9000 inside the pod, which is what the exporter does; nginx has no location that passes /status on, so it is not exposed to the internet. The Magento nginx configuration (the nginx.conf.sample shipped with Magento) passes only the entry points index.php, get.php, static.php, health_check.php and a few others to FPM, which keeps the status page out of reach; keep that list closed when you adapt it.

zz-pocketcoffee.conf (copied to the PHP-FPM pool directory in the image)
[www]
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 16
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 8
pm.max_requests = 500
pm.status_path = /status
ping.path = /ping
ping.response = pong
request_terminate_timeout = 120s
clear_env = no
catch_workers_output = yes

The file is read after the defaults of the official image because of its name (zz-) and was checked with php-fpm -t in php:8.4-fpm (the pool keys are the same on PHP 8.5; they are PHP’s, not Magento’s). clear_env = no matters for the secrets: with the default clear_env = yes the PHP code cannot read the environment variables of the container, and env.php of this series reads everything with getenv(). Size pm.max_children from the memory of a worker under your own traffic (a Magento worker with the compiled code and a warm OPcache is far larger than a blog worker; measure a catalog page, a checkout request and an Admin save separately) and the memory limit of the container, and set pm.max_requests so that a leaky extension is bounded.

The exporter is a sidecar of the pocketcoffee-web pod. The community project hipages/php-fpm_exporter (not an official PHP or Magento project) turns the status page into Prometheus metrics on port 9253. There are two documented ways to collect FPM metrics, and the choice is about who owns the Prometheus stack:

Option Notes

Exporter sidecar and a PodMonitor

One more container per pod, one more scrape target; works with any Prometheus-compatible collector, including the managed ones; the metrics are the ones used by the HPA above.

An APM agent or the CloudWatch agent

No sidecar, but the busy-worker count is not what an APM agent reports by default, so the HPA would scale on something else; useful as a complement, not as the HPA signal.

The metrics that matter are phpfpm_active_processes (the value the HPA scales on), phpfpm_idle_processes, phpfpm_listen_queue (requests waiting for a free worker; anything above zero for more than a few seconds is saturation), phpfpm_max_children_reached (alert on its increase, not its value) and phpfpm_slow_requests (only when request_slowlog_timeout is set). Check the names on the /metrics endpoint of the version you deploy before building a dashboard or an HPA on them. The excerpt of the Deployment shows the pod with the sidecar and the probes:

deployment.yaml (rendered excerpt)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pocketcoffee-web
  namespace: pocketcoffee
  labels:
    app.kubernetes.io/name: pocketcoffee-magento
    app.kubernetes.io/component: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: pocketcoffee-magento
      app.kubernetes.io/component: web
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pocketcoffee-magento
        app.kubernetes.io/component: web
    spec:
      serviceAccountName: pocketcoffee-magento
      terminationGracePeriodSeconds: 90
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: pocketcoffee-magento
              app.kubernetes.io/component: web
      containers:
        - name: php-fpm
          image: "123456789012.dkr.ecr.eu-west-1.amazonaws.com/pocketcoffee/magento:<git-sha>"
          envFrom:
            - secretRef:
                name: pocketcoffee-magento
          ports:
            - name: fastcgi
              containerPort: 9000
          resources:
            requests: {cpu: "1", memory: 2Gi}
            limits: {memory: 3Gi}
          volumeMounts:
            - name: var
              mountPath: /var/www/html/var
        - name: nginx
          image: nginx:1.30
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health_check.php
              port: http
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 20
            failureThreshold: 3
          resources:
            requests: {cpu: 50m, memory: 64Mi}
            limits: {memory: 128Mi}
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 15 && nginx -s quit"]
        - name: php-fpm-exporter
          image: hipages/php-fpm_exporter:2.2.0
          args: ["server", "--phpfpm.scrape-uri=tcp://127.0.0.1:9000/status"]
          ports:
            - name: fpm-metrics
              containerPort: 9253
          resources:
            requests: {cpu: 10m, memory: 32Mi}
            limits: {memory: 64Mi}
      volumes:
        - name: var
          emptyDir:
            sizeLimit: 2Gi
---
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: pocketcoffee-web
  namespace: pocketcoffee
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: pocketcoffee-magento
      app.kubernetes.io/component: web
  podMetricsEndpoints:
    - port: fpm-metrics
      interval: 15s

The readiness probe is /health_check.php of Magento and the liveness probe is a static /healthz served by nginx, on purpose: pub/health_check.php checks the backends (database, cache) and a database outage must take the pods out of the Service, not restart them in a loop; the behaviour of the script is described on Cloud: EKS Infrastructure and Helm Deployment, and the generic rules in Health probes and lifecycle. The PodMonitor is a custom resource of the Prometheus Operator, so kubeconform skips it with -ignore-missing-schemas; the rest was validated against the Kubernetes 1.36 schemas. The real chart also mounts env.php, the nginx and PHP ConfigMaps, the media volume when media.mode is efs, and the rest of the security context. The alerts that earn a page are an increase of phpfpm_max_children_reached over five minutes, a phpfpm_listen_queue above zero for two minutes, a 5xx rate at the load balancer, and a Varnish hit ratio that drops after a release without recovering.

Application monitoring: New Relic, OpenTelemetry and Adobe Observability

Metrics of the pool say that the shop is slow; a trace says why. Three options exist, and Adobe’s statements about each are different.

Option What the sources say Verify

New Relic APM with the PHP agent

Adobe’s documentation describes New Relic monitoring of Commerce through PHP agents and marks the page as applying to Adobe Commerce on cloud infrastructure and to on-premises projects. The Magento source tree contains the module Magento_NewRelicReporting (magento/module-new-relic-reporting, requires PHP 8.3 to 8.5), which reports Magento data such as orders, customers and catalog size to New Relic.

The PHP agent is a PHP extension and a daemon setting, so it belongs in the image (and in the Dockerfile of the build page); read the New Relic documentation for the agent version that supports PHP 8.5 and for running it in containers before choosing it. Adobe’s New Relic pages are written for Adobe Commerce; for Magento Open Source and Mage-OS the module is present in the code, but Adobe’s support statement is not for those editions.

OpenTelemetry

The OpenTelemetry project documents zero-code instrumentation of PHP with a PECL extension and Composer packages, configured with the standard variables such as OTEL_PHP_AUTOLOAD_ENABLED, OTEL_SERVICE_NAME and OTEL_EXPORTER_OTLP_ENDPOINT. A Magento instrumentation package exists (open-telemetry/opentelemetry-auto-magento2, on Packagist at version 0.1.0 when this page was checked, PHP ^8.3).

It is community software, early in its life, and neither Adobe nor Mage-OS document it. Test it on staging with the real traffic, measure the overhead, and treat it as an experiment until the package has releases you trust.

Adobe Commerce Observability

A module (magento/module-out-of-process-observability, Magento_OutOfProcessObservability) that sends logs, metrics and traces through the message queue to three consumers (commerce.observability.log, commerce.observability.metrics, commerce.observability.trace). Adobe lists Adobe Commerce 2.4.5 or later and PHP 8.1 or later as prerequisites, and Magento Open Source as not supported.

Not available on Magento Open Source or, by that statement, on Mage-OS. The consumers are three more consumers to run (see Cloud: Cron, Queue Consumers and Indexers); the page does not name the systems that receive the data.

The vendor-neutral choice for a shop that runs the open-source edition on its own cluster is the combination that needs no Adobe entitlement: the exporter above for the pool, the CloudWatch Observability add-on or your own collector for the logs, and either an APM vendor’s PHP agent or the OpenTelemetry extension for traces. Whichever you choose, install it in the image at build time (a PHP extension is not something to add to a running pod), keep it out of the setup:* commands of the migration Job, and watch the memory: an agent adds to the resident memory of every worker, which changes pm.max_children.

Logs

Magento writes its logs to files, not to stdout, and the files are in the var/log directory of the pod, which is an emptyDir in this layout, so they are lost with the pod. In app/etc/di.xml of the 2.4 line the main logger Magento\Framework\Logger\Monolog is configured with three handlers named system, debug and syslog. Magento\Framework\Logger\Handler\System writes var/log/system.log at level INFO, hands records that carry an exception to the exception handler (which writes var/log/exception.log), and Magento\Framework\Logger\Handler\Debug writes var/log/debug.log. The base class Magento\Framework\Logger\Handler\Base builds the path as the base directory of the installation followed by a relative file name, and it strips . and .. from the name, so a stream such as php://stderr cannot be set through a handler’s fileName argument. There is no environment variable that changes this. There are two honest options:

  1. Keep the files and ship them. The var volume is shared with a log-shipper sidecar (Fluent Bit with the tail input over var/log/*.log writing to its own stdout), exactly as on the PrestaShop page (see Cloud: Autoscaling, Observability, Backups and Security for PrestaShop). Nothing in Magento changes, and the files remain available to kubectl exec for a short time. The cost is a sidecar and a volume shared by two containers.

  2. Replace the handlers. A small module overrides the handlers of the main logger with a handler that writes to php://stderr, so the container output carries the log and no sidecar is needed. The listing shows the class and the di.xml of such a module; it is a customisation of your own code, not something Adobe documents, and it was checked for syntax and against the source, not run in a Magento store.

app/code/PocketCoffee/Skeleton/Logger/Handler/Stderr.php
<?php

declare(strict_types=1);

namespace PocketCoffee\Skeleton\Logger\Handler;

use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Level;

/**
 * Writes every record that reaches the main Magento logger to the standard error of the container.
 */
class Stderr extends StreamHandler
{
    public function __construct()
    {
        parent::__construct('php://stderr', Level::Info);
        $this->setFormatter(new LineFormatter(null, null, true));
    }
}
app/code/PocketCoffee/Skeleton/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Logger\Monolog">
        <arguments>
            <argument name="handlers" xsi:type="array">
                <item name="system" xsi:type="object">PocketCoffee\Skeleton\Logger\Handler\Stderr</item>
                <item name="debug" xsi:type="object">PocketCoffee\Skeleton\Logger\Handler\Stderr</item>
            </argument>
        </arguments>
    </type>
</config>

The two items replace the system and debug handlers by key (arrays of arguments are merged by item name), so the exception records, which the stock System handler diverts to exception.log, now arrive in the same stream; syslog is left alone. Two caveats. The handler uses the Monolog 3 API (Monolog\Level), which is what composer.json of the 2.4 line requires (monolog/monolog ^3.6); confirm with composer show monolog/monolog in your build. And the debug handler of Magento is conditional on the deployment mode, which this replacement ignores: in production mode a handler at level INFO is the right ceiling, and the shop should never run developer mode in the cluster. Magento also writes var/report/<id> files for uncaught exceptions; they stay on the pod and the error page shows the report id, so keep the log line that contains it.

From the container output to CloudWatch Logs

Once the log is on the container output, collection is the same as for any pod. The Amazon CloudWatch Observability add-on of EKS installs the CloudWatch agent and Fluent Bit and sends the container output of every pod to log groups under /aws/containerinsights/pocketcoffee-prod/ (the application logs in the group application). Whether the add-on is supported on an EKS Auto Mode cluster in your configuration and the IAM role it needs (the managed policy CloudWatchAgentServerPolicy, through EKS Pod Identity or IAM roles for service accounts) are in the AWS documentation; verify both before choosing it over a collector you run yourself.

Installing the add-on and setting the retention of the group
#!/usr/bin/env bash
set -euo pipefail

CLUSTER=pocketcoffee-prod
REGION=eu-west-1

# The role must carry CloudWatchAgentServerPolicy; the ARN comes from the OpenTofu stack.
aws eks create-addon \
  --region "$REGION" \
  --cluster-name "$CLUSTER" \
  --addon-name amazon-cloudwatch-observability \
  --pod-identity-associations "serviceAccount=cloudwatch-agent,roleArn=arn:aws:iam::123456789012:role/pocketcoffee-prod-cloudwatch-agent"

# Log groups are created without an expiry: set one explicitly.
aws logs put-retention-policy \
  --region "$REGION" \
  --log-group-name "/aws/containerinsights/${CLUSTER}/application" \
  --retention-in-days 30

A Logs Insights query over the group finds the failures. The Magento line is plain text ([2026-10-29T17:52:51.316648+00:00] main.CRITICAL: message [] [], the main channel from di.xml), so the query matches on text, and the field names of the Kubernetes metadata depend on the collector version, so look at one event before saving it:

CloudWatch Logs Insights
fields @timestamp, kubernetes.pod_name, log
| filter log like /main\.(ERROR|CRITICAL|ALERT|EMERGENCY)/
| sort @timestamp desc
| limit 50

Alerting on it is a metric filter on the log group with an alarm; the pattern is in Monitoring and observability on AWS.

Slow queries

The database is where a Magento shop spends much of its time, and RDS keeps a log of the statements that took too long. It is off by default: turn it on in the parameter group and export it to CloudWatch Logs, so that it can be searched from the same place as the application logs. The listing is the part of the OpenTofu stack that concerns logging; the instance itself is in Cloud: Managed Database (RDS and Aurora).

slow-query-log.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_db_parameter_group" "magento" {
  name   = "pocketcoffee-magento-mysql84"
  family = "mysql8.4"

  parameter {
    name  = "slow_query_log"
    value = "1"
  }

  parameter {
    name  = "long_query_time"
    value = "1"
  }

  parameter {
    name  = "log_output"
    value = "FILE"
  }
}

# On the aws_db_instance "pocketcoffee-prod" of the database page:
#   parameter_group_name            = aws_db_parameter_group.magento.name
#   enabled_cloudwatch_logs_exports = ["error", "slowquery"]

resource "aws_cloudwatch_log_group" "slowquery" {
  name              = "/aws/rds/instance/pocketcoffee-prod/slowquery"
  retention_in_days = 30
}

The registry page documents the AWS provider that OpenTofu uses unchanged. The parameter group above holds only the logging parameters; the group of the database page (log_bin_trust_function_creators and the other Magento settings) must contain them as well, so merge the two rather than creating two groups with the same name. The log group is created by RDS the first time a log is exported; declaring it first, with the exact name, gives it a retention. On Aurora MySQL the same parameters live in the cluster parameter group of pocketcoffee-prod-aurora. A long_query_time of 1 second is a starting point: lower it for a week during an investigation, then back, because the log costs I/O and storage.

CloudWatch Logs Insights on /aws/rds/instance/pocketcoffee-prod/slowquery
parse @message /Query_time: (?<qt>[0-9.]+)/
| filter ispresent(qt)
| stats count(*) as slow, max(qt) as worst, avg(qt) as mean by bin(1h)
| sort @timestamp desc

Statement-level analysis is the job of CloudWatch Database Insights. It replaced the Performance Insights console experience (end of life 2026-07-31), shows the load of the instance by wait event, SQL statement and user (so the runtime user mg_app, the mg_migrate Job and the Admin are separate sources only because they use separate users, one reason for having them), and in its Advanced mode also execution plans and on-demand analysis. The modes, the retention and their cost drivers are in Amazon RDS; choose the mode per instance and check the current wording on the AWS page before relying on the old Performance Insights flags in automation.

What to expect in the log depends on the shop; which statements appear is a question for your own slow log, not for this page. Two Magento tools exist for finding where a query comes from in the code, and both are for a developer environment, not for the cluster:

  • bin/magento dev:query-log:enable turns on Magento’s own database query logger, which writes to var/debug/db.log (the file logger of the framework; its default threshold is 0.05 seconds and the options of the command are listed by --help). The file is on the pod and is lost with it, and the logger adds work to every query.

  • bin/magento dev:profiler:enable (type html, the default, or csvfile) creates the flag file var/profiler.flag; the HTML output is appended to the pages and the CSV is written to var/log/profiler.csv and overwritten on each request. The environment variable MAGE_PROFILER does the same, and the value 2 adds the dependency graph. Adobe’s page has no production warning, but a profiler flag in a shared var would profile every request of a pod and put timing data in front of customers; in this layout var is per pod, so the flag would apply to that one pod only. Use these on the local environment of Local Development Environments or on a staging replica, and use Database Insights and the slow log in production.

Backups and disaster recovery

A single-server Magento is backed up by a database dump and an archive of the code and pub/media. On EKS the same information is in separate stores with separate recovery points, and only some of them are backed up at all. The question to answer is what is restored, what is rebuilt and whether what is restored is consistent.

Component Backup Recovery

RDS pocketcoffee-prod

Automated backups with point-in-time recovery (retention set on the instance), a manual snapshot before each release, an AWS Backup copy to a second region

restore-db-instance-to-point-in-time or restore-db-instance-from-db-snapshot create a new instance; the chart’s database.host is pointed at it

Media in S3 pocketcoffee-prod-media

Bucket versioning with a lifecycle rule for old versions; optionally AWS Backup (continuous or periodic) with a cross-region copy

Restore an object version, or an AWS Backup restore job into a bucket

Media on EFS pocketcoffee-media (alternative)

AWS Backup, with a cross-region copy

A restore job creates a new file system; the PersistentVolume is repointed to it

OpenSearch pocketcoffee-prod

Hourly automated snapshots kept 14 days by the service; manual snapshots to your own S3 bucket optional

Normally rebuilt: bin/magento indexer:reindex repopulates it from the database; restore a snapshot only to avoid a long reindex

Valkey pocketcoffee-prod-cache and pocketcoffee-prod-session

None, on purpose

Start empty; the cache refills, and every session is lost, so every customer signs in again and carts held only in the session disappear (the quote is in the database)

RabbitMQ pocketcoffee-prod-mq

None of the messages

Recreate the broker and run setup:upgrade (or the migration Job) to declare the topology; work that was in flight is re-triggered from the database state

Secret pocketcoffee/prod/magento

Versions in Secrets Manager, a replica secret in the DR region

Promote the replica; keep every old crypt_key in the list, see the key section

ECR pocketcoffee/magento and pocketcoffee/varnish

Replication to the DR region; the image is rebuildable from Git

Pull in the DR cluster

Chart, infra/ OpenTofu, Dockerfile, app/etc/config.php, state

Git and a versioned state bucket

tofu apply in the DR region

Three of these rows deserve a sentence each, because the AWS documentation is explicit and the consequence for Magento is not obvious. OpenSearch automated snapshots are for cluster recovery only; the service takes them hourly for domains on OpenSearch and retains up to 336 of them for 14 days, in a bucket it manages. If the cluster goes red, automated snapshots stop, and AWS warns that data can be lost if the state is not corrected within two weeks. Because the catalog and search index of Magento is derived from the database, the usual recovery of a lost domain is a new domain and a reindex, which can take hours for a large catalog; that duration, not the data, is what a snapshot would save. ElastiCache for Valkey can take snapshots, and the AWS documentation says they are available for Valkey, but a cache and a session store are not data to restore: a snapshot of the session group from yesterday signs in sessions that have since ended. For RabbitMQ, Amazon MQ documents the broker, its Multi-AZ cluster deployment and maintenance; no backup of queue messages is documented, so plan on the database being the source of truth and verify the broker’s durability settings (quorum queues in a cluster) on the AWS page for your engine version.

The OpenTofu below is a daily AWS Backup plan that protects the RDS instance, the media bucket and, when it is the media mode, the EFS file system with one plan, and copies the recovery points to a vault in eu-central-1. The instance keeps its own automated backups and point-in-time recovery as well (backup_retention_period on the aws_db_instance, 14 days is a common choice): point-in-time recovery has a granularity of minutes, the AWS Backup plan gives the long retention and the second region. AWS Backup requires S3 Versioning to be enabled on a bucket it protects and recommends a lifecycle expiration for the versions, which the second listing sets.

backup.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "dr"
  region = "eu-central-1"
}

variable "protected_resource_arns" {
  type        = list(string)
  description = "ARNs of the RDS instance pocketcoffee-prod, of the bucket pocketcoffee-prod-media and, if used, of the EFS file system pocketcoffee-media"
}

resource "aws_backup_vault" "primary" {
  name = "pocketcoffee-primary"
}

resource "aws_backup_vault" "dr" {
  provider = aws.dr
  name     = "pocketcoffee-dr"
}

resource "aws_iam_role" "backup" {
  name = "pocketcoffee-backup"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "sts:AssumeRole"
      Principal = { Service = "backup.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "backup" {
  for_each = toset([
    "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
    "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Backup",
    "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores",
    "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Restore",
  ])

  role       = aws_iam_role.backup.name
  policy_arn = each.value
}

resource "aws_backup_plan" "magento" {
  name = "pocketcoffee-daily"

  rule {
    rule_name         = "daily-with-dr-copy"
    target_vault_name = aws_backup_vault.primary.name
    schedule          = "cron(0 2 * * ? *)"

    lifecycle {
      delete_after = 35
    }

    copy_action {
      destination_vault_arn = aws_backup_vault.dr.arn

      lifecycle {
        delete_after = 35
      }
    }
  }
}

resource "aws_backup_selection" "magento" {
  name         = "pocketcoffee-data"
  iam_role_arn = aws_iam_role.backup.arn
  plan_id      = aws_backup_plan.magento.id
  resources    = var.protected_resource_arns
}
media-versioning.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

# The bucket itself is created on the media page; this file adds versioning and the expiry of old versions.
variable "media_bucket" {
  type        = string
  description = "Name of the media bucket"
  default     = "pocketcoffee-prod-media"
}

resource "aws_s3_bucket_versioning" "media" {
  bucket = var.media_bucket

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "media" {
  bucket = var.media_bucket

  rule {
    id     = "expire-old-versions"
    status = "Enabled"

    filter {}

    noncurrent_version_expiration {
      noncurrent_days = 35
    }

    expiration {
      expired_object_delete_marker = true
    }
  }

  depends_on = [aws_s3_bucket_versioning.media]
}

Versioning protects the media against what Magento does to it as well as against a mistake: the S3 driver of remote storage overwrites and deletes objects when images are replaced or products are deleted (see Cloud: Media and Remote Storage), and an old version is what brings back a picture that an editor replaced by accident. The expiry of non-current versions after 35 days matches the AWS Backup retention above; without it every replaced image is kept for ever and, per the AWS Backup documentation, every version and delete marker is included in the recovery point.

Before a release and the restore drill

A snapshot before every release is the rollback of the database part of a release; it takes the place of the dump that a single server would make, and it is a step of the release sequence on Cloud: Releases and Zero-Downtime Deployments. The restore drill proves the rest, and it belongs in the calendar (quarterly is a reasonable cadence), not in the incident.

Snapshot before a release, and a point-in-time restore into a drill instance
#!/usr/bin/env bash
set -euo pipefail

REGION=eu-west-1
SNAPSHOT="pocketcoffee-prod-pre-$(date -u +%Y%m%d%H%M)"

# 1. Before the release: a manual snapshot that no retention rule deletes.
aws rds create-db-snapshot \
  --region "$REGION" \
  --db-instance-identifier pocketcoffee-prod \
  --db-snapshot-identifier "$SNAPSHOT"
aws rds wait db-snapshot-available \
  --region "$REGION" \
  --db-snapshot-identifier "$SNAPSHOT"

# 2. The drill: restore the last restorable moment, or a chosen time, into a NEW instance.
aws rds restore-db-instance-to-point-in-time \
  --region "$REGION" \
  --source-db-instance-identifier pocketcoffee-prod \
  --target-db-instance-identifier pocketcoffee-restore-drill \
  --use-latest-restorable-time \
  --db-subnet-group-name pocketcoffee-prod \
  --db-parameter-group-name pocketcoffee-magento-mysql84 \
  --no-publicly-accessible
aws rds wait db-instance-available \
  --region "$REGION" \
  --db-instance-identifier pocketcoffee-restore-drill

aws rds describe-db-instances \
  --region "$REGION" \
  --db-instance-identifier pocketcoffee-restore-drill \
  --query 'DBInstances[0].Endpoint.Address' \
  --output text

The restored instance has the data and the engine settings, not the security groups unless you pass them (--vpc-security-group-ids); give it the same subnet group, security group and parameter group as production. Point a staging release at it (database.host of the chart) and check the shop: the home page, a product, a checkout in the payment provider’s test mode, an Admin login, and the numbers of sales_order and customer_entity against what you expect. Then run bin/magento indexer:reindex against a scratch OpenSearch index prefix, because a restored database with a missing index is the usual surprise of the first drill. A drill that ends at "the instance is available" has not shown that the shop works.

For the media, the common incident is one object, not the whole bucket. S3 versioning makes it a copy of an older version onto the current key:

Bringing back one media file from its previous version
#!/usr/bin/env bash
set -euo pipefail

BUCKET=pocketcoffee-prod-media
KEY="${1:?usage: restore-media.sh catalog/product/a/b/ab-roast.jpg}"

# List the versions of the key, newest first, and take the second one (the one before the current).
VERSION_ID=$(aws s3api list-object-versions \
  --bucket "$BUCKET" \
  --prefix "$KEY" \
  --query 'sort_by(Versions[?Key==`'"$KEY"'`], &LastModified)[-2].VersionId' \
  --output text)

if [ "$VERSION_ID" = "None" ]; then
  echo "no previous version of $KEY" >&2
  exit 1
fi

aws s3api copy-object \
  --bucket "$BUCKET" \
  --key "$KEY" \
  --copy-source "${BUCKET}/${KEY}?versionId=${VERSION_ID}"

If the key was deleted, the current version is a delete marker; removing the marker (aws s3api delete-object with its --version-id) makes the previous version current again, and the script above must be adapted to the situation you find. The resized variants under catalog/product/cache/ are derived files that Magento regenerates (they are cached on the local filesystem of the pods per the remote-storage documentation of Adobe), so a missing resized image is not a restore case. For a whole bucket or a whole file system use an AWS Backup restore job; the S3 restore metadata is read from the recovery point, as in Amazon S3 and the AWS Backup documentation, not typed.

OpenSearch: restore or rebuild

Rebuilding is the default and needs no preparation beyond the reindex Job of the chart (pocketcoffee-reindex, see Cloud: Cron, Queue Consumers and Indexers). The decision is about time: if a full reindex of the catalog takes longer than the shop may be without search, keep a recent snapshot. The automated snapshots of the service live in a repository the service manages and are restored through the _snapshot API of the domain; manual snapshots need your own S3 bucket, an IAM role that OpenSearch Service can assume, and a signed request that passes the role to the domain, all described by AWS. The request bodies are small:

Registering a repository and restoring, as the _snapshot API calls (signed requests)
PUT _snapshot/pocketcoffee-manual
{
  "type": "s3",
  "settings": {
    "bucket": "pocketcoffee-prod-opensearch-snapshots",
    "region": "eu-west-1",
    "role_arn": "arn:aws:iam::123456789012:role/pocketcoffee-prod-opensearch-snapshot"
  }
}

PUT _snapshot/pocketcoffee-manual/before-release-1
POST _snapshot/pocketcoffee-manual/before-release-1/_restore
{
  "indices": "pocketcoffee_*",
  "include_global_state": false
}

The bucket and role names are placeholders (neither is created by this series), the requests must be signed with an IAM identity that may pass the role and call es:ESHttpPut, and with fine-grained access control the mapped user also needs the snapshot permissions; read the AWS page for the exact policy. Restoring into a domain that already has the index names fails until the existing indices are deleted or closed, so the practical restore is into the new domain of a recovery. The indices are named with the prefix pocketcoffee (the Magento opensearch_index_prefix), which is why the restore pattern is pocketcoffee_*.

The two stores must agree

The database stores the paths of the images and the file store holds them, so a database from Monday with the media of Wednesday is harmless (the extra files are unreferenced) and a database of Wednesday with the media of Monday shows broken product pictures for everything uploaded in between. The rules that follow: restore both to the same moment when you can (a daily plan at 02:00 gives a database and a bucket within minutes of each other); if the media are older, the missing originals are gone and the product pages have to be edited again, while the resized variants regenerate; and a point-in-time restore of the database loses the orders placed between the chosen time and the failure. The payment provider still holds the payments: reconcile its list of transactions against sales_order before reopening, and decide for each missing order whether to recreate it or to refund. That is the largest cost of a database recovery for a shop and the reason to keep the RPO of the database in minutes.

Disaster recovery in a second region

The RPO and the RTO of a regional loss differ from those of a single-instance backup. The database is as old as the last copy in the vault of eu-central-1 (daily with this plan, so up to a day; shorten the schedule or add a cross-region read replica if that is too long), the media are as old as their copy, the secret and the image are replicated, the code is in Git, and OpenSearch, Valkey and Varnish start empty. The RTO is dominated by the steps that are not automatic: applying the OpenTofu stack in the DR region, restoring the database and the media, running the migration Job, reindexing, warming the cache, and changing the DNS record of www.pocketcoffee.example (a health-checked failover record in Route 53 does the last step when the primary is down). Write the order down, rehearse it once a year in a scratch account or region, and keep the runbook outside the platform that it recovers.

AWS WAF in front of the shop

A web ACL in front of the load balancer (or of CloudFront) removes a class of traffic before PHP sees it: known bad inputs, SQL injection patterns, bots with a bad reputation and the brute force against the login forms. It does not replace patching Magento and the extensions (the patch cadence and the incidents are on Security); it buys time between an advisory and the rollout of the fix. The pieces that are specific to Magento are these paths:

Path Treatment

/admin_pocketcoffee (the Admin frontName)

Allowed only from the office and VPN addresses, and a rate limit on the POST requests of the login. The name alone is not a defence, the address list is.

/customer/account/loginPost, /customer/account/createpost, /customer/account/forgotpasswordpost

A rate limit per client address on POST: the brute force and the account creation of the storefront.

/rest/ and /graphql

Token endpoints (POST /rest/V1/integration/customer/token and the GraphQL mutation that generates a customer token) attract credential stuffing; a rate limit on POST over the whole prefix is the simple form, a rule on the token paths the precise one. A headless storefront or an app sends all its traffic here, so size the limit from its real rate.

/checkout and the /rest/V1/guest-carts and /rest/V1/carts/mine payment endpoints

A rate limit that a shopper cannot reach and a card-testing script does; the number depends on the shop and is to be tuned.

The payment provider’s callback (the front controller of PocketCoffee_Pay)

Must be reachable from the provider. Excluded from the managed rule groups, whose body inspection rejects some webhook payloads, and protected instead by the signature that the module verifies and the provider’s published source addresses when it has them (add a generous rate-based rule on the path once the real rate is known).

The paths of the storefront controllers are those of the core modules (route customer, controller account, actions loginPost, createpost, forgotpasswordpost); confirm them against the routes of your own installation and your theme, because a PWA or a checkout extension can add its own. The first four rows are what the page can say from the platform; the callback path depends on how PocketCoffee_Pay registers its route (see Checkout and Payments).

waf.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "admin_cidrs" {
  type        = list(string)
  description = "Addresses that may reach the Admin"
  default     = ["203.0.113.0/24"]
}

variable "enforce_managed_rules" {
  type        = bool
  description = "false runs the managed groups in count mode, to read the sampled requests before blocking"
  default     = false
}

locals {
  admin_prefix   = "/admin_pocketcoffee"
  webhook_prefix = "/pocketcoffee_pay/"

  managed_groups = {
    "AWSManagedRulesCommonRuleSet"          = 10
    "AWSManagedRulesKnownBadInputsRuleSet"  = 20
    "AWSManagedRulesSQLiRuleSet"            = 30
    "AWSManagedRulesPHPRuleSet"             = 40
    "AWSManagedRulesAmazonIpReputationList" = 50
  }

}

resource "aws_wafv2_ip_set" "admin" {
  name               = "pocketcoffee-admin-allowed"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = var.admin_cidrs
}

resource "aws_wafv2_web_acl" "shop" {
  name  = "pocketcoffee-prod"
  scope = "REGIONAL"

  default_action {
    allow {}
  }

  # 1. The Admin only from known addresses.
  rule {
    name     = "admin-allow-list"
    priority = 1

    action {
      block {}
    }

    statement {
      and_statement {
        statement {
          byte_match_statement {
            search_string         = local.admin_prefix
            positional_constraint = "STARTS_WITH"

            field_to_match {
              uri_path {}
            }

            text_transformation {
              priority = 0
              type     = "LOWERCASE"
            }
          }
        }

        statement {
          not_statement {
            statement {
              ip_set_reference_statement {
                arn = aws_wafv2_ip_set.admin.arn
              }
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "admin-allow-list"
      sampled_requests_enabled   = true
    }
  }

  # 2. Brute force: at most 100 login or account POSTs per address in five minutes (the Admin and /customer/account/).
  rule {
    name     = "login-rate"
    priority = 2

    action {
      block {}
    }

    statement {
      rate_based_statement {
        limit                 = 100
        aggregate_key_type    = "IP"
        evaluation_window_sec = 300

        scope_down_statement {
          and_statement {
            statement {
              byte_match_statement {
                search_string         = "POST"
                positional_constraint = "EXACTLY"

                field_to_match {
                  method {}
                }

                text_transformation {
                  priority = 0
                  type     = "NONE"
                }
              }
            }

            statement {
              or_statement {
                statement {
                  byte_match_statement {
                    search_string         = local.admin_prefix
                    positional_constraint = "STARTS_WITH"

                    field_to_match {
                      uri_path {}
                    }

                    text_transformation {
                      priority = 0
                      type     = "LOWERCASE"
                    }
                  }
                }

                statement {
                  byte_match_statement {
                    search_string         = "/customer/account/"
                    positional_constraint = "STARTS_WITH"

                    field_to_match {
                      uri_path {}
                    }

                    text_transformation {
                      priority = 0
                      type     = "LOWERCASE"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "login-rate"
      sampled_requests_enabled   = true
    }
  }

  # 3. The checkout: counted, not blocked, until the real rate is known.
  rule {
    name     = "checkout-rate"
    priority = 3

    action {
      count {}
    }

    statement {
      rate_based_statement {
        limit                 = 300
        aggregate_key_type    = "IP"
        evaluation_window_sec = 300

        scope_down_statement {
          and_statement {
            statement {
              byte_match_statement {
                search_string         = "POST"
                positional_constraint = "EXACTLY"

                field_to_match {
                  method {}
                }

                text_transformation {
                  priority = 0
                  type     = "NONE"
                }
              }
            }

            statement {
              or_statement {
                statement {
                  byte_match_statement {
                    search_string         = "/checkout"
                    positional_constraint = "STARTS_WITH"

                    field_to_match {
                      uri_path {}
                    }

                    text_transformation {
                      priority = 0
                      type     = "LOWERCASE"
                    }
                  }
                }

              }
            }
          }
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "checkout-rate"
      sampled_requests_enabled   = true
    }
  }

  # 4. Managed rule groups, everywhere except on the payment callbacks.
  dynamic "rule" {
    for_each = local.managed_groups

    content {
      name     = rule.key
      priority = rule.value

      override_action {
        dynamic "none" {
          for_each = var.enforce_managed_rules ? [1] : []
          content {}
        }
        dynamic "count" {
          for_each = var.enforce_managed_rules ? [] : [1]
          content {}
        }
      }

      statement {
        managed_rule_group_statement {
          name        = rule.key
          vendor_name = "AWS"

          # The Admin and the REST API post HTML and JSON bodies: these two rules are the usual false positives.
          dynamic "rule_action_override" {
            for_each = rule.key == "AWSManagedRulesCommonRuleSet" ? ["SizeRestrictions_BODY", "CrossSiteScripting_BODY"] : []

            content {
              name = rule_action_override.value

              action_to_use {
                count {}
              }
            }
          }

          scope_down_statement {
            not_statement {
              statement {
                byte_match_statement {
                  search_string         = local.webhook_prefix
                  positional_constraint = "STARTS_WITH"

                  field_to_match {
                    uri_path {}
                  }

                  text_transformation {
                    priority = 0
                    type     = "LOWERCASE"
                  }
                }
              }
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = rule.key
        sampled_requests_enabled   = true
      }
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "pocketcoffee-prod"
    sampled_requests_enabled   = true
  }
}

output "web_acl_arn" {
  value = aws_wafv2_web_acl.shop.arn
}

The ACL is the regional web ACL pocketcoffee-prod of this series. It is associated with the load balancer that the Gateway API implementation creates (the annotation or the Gateway configuration depends on the controller; see Load balancers and gateways on AWS) or, with a CloudFront distribution in front of the shop, by a web ACL of scope CLOUDFRONT created in us-east-1 (see CloudFront). Behind CloudFront the client address that the rate rules see is the one of the CloudFront edge unless the rule aggregates on the forwarded address (aggregate_key_type = "FORWARDED_IP" with a forwarded_ip_config), and Magento itself must trust the proxy that sets X-Forwarded-For and X-Forwarded-Proto (the cache and proxy settings are on Cloud: Varnish and Full Page Cache on Kubernetes).

Operate it in this order. Deploy with enforce_managed_rules = false, so that the five groups only count; read the sampled requests for a week of normal traffic, including an Admin session, a checkout, a catalog import and several payment callbacks; add an override or a scope-down for each rule that counted a legitimate request; then set it to true. Two rules of the Common group are already overridden to count in the listing, because the Admin posts HTML (CMS pages, product descriptions) and the REST and GraphQL APIs post large JSON bodies, and that is where a body-inspection rule fires on a legitimate save; the body inspection stays on at the URL, query string and cookie level, and the Admin is behind the address list. The rule names and labels of each group are in the AWS managed rule groups documentation; read the current list rather than trusting this page for names that AWS adds and renames. The rate limits are a starting point to tune against real traffic: a shop behind a corporate proxy or a mobile carrier can put hundreds of customers on one address.

AWS also offers managed rule groups for bots and for credential abuse: Bot Control (AWSManagedRulesBotControlRuleSet), account takeover prevention (AWSManagedRulesATPRuleSet) and account creation fraud prevention (AWSManagedRulesACFPRuleSet). They need a configuration that names the login or sign-up path and the fields of the form, and they work with the WAF client-side token (JavaScript challenge), which a Magento storefront must load; the Magento login form posts login[username] and login[password], so the field identifiers differ from the generic examples. They are worth evaluating for a shop that is under credential stuffing or scraping; the configuration was not tested for this page, and the pricing is on the AWS pages, not here.

The Admin

The Admin is the highest-value target of the shop, and most of the hardening that does not depend on code is about it. The platform-independent hardening (a non-default URI, 2FA settings, session lifetime, lockout, roles, Content Security Policy) is on Security; this section is about how it is realised in the cluster.

A separate Deployment and route

By default the Admin is served by the same pods as the storefront, so a long Admin request competes with shoppers for FPM workers. The chart can run a second Deployment, pocketcoffee-admin, from the same image (admin.enabled), with its own Service and its own route. The frontName is configuration, not code: it is backend/frontName in env.php, which the template of this series reads from MAGENTO_BACKEND_FRONTNAME (value admin_pocketcoffee, in shop.adminFrontName), and the installer takes it as --backend-frontname. Changing it later is a configuration change and a rollout; flush the cache afterwards and verify on staging that the old path answers 404.

admin-route.yaml (rendered)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: pocketcoffee-admin
  namespace: pocketcoffee
  labels:
    app.kubernetes.io/name: pocketcoffee-magento
    app.kubernetes.io/component: admin
spec:
  parentRefs:
    - name: pocketcoffee
      namespace: gateway
  hostnames:
    - www.pocketcoffee.example
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /admin_pocketcoffee
      backendRefs:
        - name: pocketcoffee-admin
          port: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: pocketcoffee-admin
  namespace: pocketcoffee
  labels:
    app.kubernetes.io/name: pocketcoffee-magento
    app.kubernetes.io/component: admin
spec:
  selector:
    app.kubernetes.io/name: pocketcoffee-magento
    app.kubernetes.io/component: admin
  ports:
    - name: http
      port: 8080
      targetPort: http

The route goes to the Admin Service directly and bypasses Varnish, which is the point: the Admin must never be cached, and it should not depend on the cache tier. The longer prefix wins over the / route of the shop whatever the order of the objects. The labels are written out because the selector helper of the web Deployment ends with component: web, and reusing it would send shoppers to the Admin pods. The Admin also loads its static files from /static/ and its media from /media/ on the same host, and those requests go to the web pods, which carry the same baked pub/static. The Admin pods use the same Secret and env.php, two replicas for availability, no HPA, a larger memory limit for imports, and the same probes. If the Admin is disabled (admin.enabled=false) the second route points at pocketcoffee-web.

The address list is the real control. Put it in the WAF (the first rule above), and in addition in the network: a NetworkPolicy on the admin pods that accepts traffic only from the gateway namespace (see Gateway API for the controller side). Magento has its own, narrower tool for maintenance windows, bin/magento maintenance:allow-ips, which belongs to the release sequence and not to this section. Three quieter controls need no code: administrators with strong passwords and the least rights that do the job, former employees removed, and integration tokens limited to the resources they use.

Two-factor authentication

Two-factor authentication for the Admin is the module Magento_TwoFactorAuth; per Adobe’s FAQ it is enabled by default since 2.4.0 and has to be configured before the first Admin login, in the user interface or through a web API. Adobe’s own FAQ strongly advises against disabling it. What follows for a cluster:

  • The module state is a build artifact. Which modules are enabled is recorded in app/etc/config.php, which this series commits and bakes into the image. Disabling Magento_TwoFactorAuth is a change of config.php at build time, so a "no 2FA" staging image is a different image from production, which defeats the purpose of staging. Keep the module enabled everywhere, and give test automation an integration token or a configured test user instead of a switch.

  • Disabling the module is possible from the CLI but is not documented as supported. bin/magento module:disable Magento_TwoFactorAuth is the community shortcut for local development (the local store of Security uses it); on recent 2.4 releases it can refuse because other modules depend on it (a report names Magento_AdminAdobeImsTwoFactorAuth), in which case both are disabled in one command. Adobe has no 2.4.9 statement either way that was found, so the answer to "can it be disabled on 2.4.9" is by the CLI, as for earlier 2.4 releases, unsupported and not for a shop; verify on a scratch store with the dependency list of your version before relying on it for a test environment.

  • With Adobe IMS the native 2FA is off by design. Stores that enable Adobe Identity Management Services authentication have the native 2FA disabled because IMS handles the authentication; that is the supported way to have none (see Security).

  • The provider secrets are configuration. Duo and Authy credentials are configuration values, and obscured configuration values are encrypted with the encryption key, so they are part of what the key section below re-encrypts or keeps decryptable. Where the Google Authenticator secrets of each user are stored is implemented in the magento/security-package repository, which is not the main repository read for this page; verify it there before promising a restore story for them.

  • The Admin token endpoint is gated by 2FA. An integration that asks for POST /rest/V1/integration/admin/token is refused until the user has set up a provider (see API authentication); give integrations integration tokens.

The settings (providers, retry limit, lockout, OTP window) are configuration values of the paths on the security page, so they are set by config:set in the release (and recorded in config.php or env.php for the non-sensitive ones) and not by hand in each environment.

Rotating secrets

Not every secret rotates in the same way, and in Magento the difference is large. The table lists what the shop reads from the Kubernetes Secret pocketcoffee-magento and what happens when it changes.

Secret Where What a change does

Database passwords (mg_app, mg_migrate)

Secrets Manager keys db_app_password and db_migrate_password, read as MAGENTO_DB_PASSWORD and MAGENTO_MIGRATE_DB_PASSWORD

Nothing, if the new password is valid in the database before the pods read it. Pods that keep the old one fail to connect, so rotate with two users that alternate, or restart the pods in the same step.

OpenSearch, Valkey and AMQP credentials

opensearch_password, valkey_cache_auth_token, valkey_session_auth_token, amqp_password

The same pattern: change the service first or accept a window, then roll the pods, the consumers and the cron Job. A changed Valkey token takes effect only on restart.

crypt/key

crypt_key, read as MAGENTO_CRYPT_KEY

Adds a key to a list; it does not remove the old ones. See the next section.

How the pods get a new value

The values reach the pod as environment variables built from the Kubernetes Secret pocketcoffee-magento, which the External Secrets Operator keeps in step with the secret pocketcoffee/prod/magento in Secrets Manager. A change in Secrets Manager updates the Kubernetes Secret within refreshInterval, but a running container never sees a changed environment variable: env.php of this series reads getenv() at request time, but the environment of a PHP-FPM worker is fixed when the container starts, and the compiled configuration and the cache hold values as well. Every rotation therefore ends with a rolling restart of the web, admin, consumer and cron workloads.

externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: pocketcoffee-magento
  namespace: pocketcoffee
spec:
  refreshInterval: 15m
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-secretsmanager
  target:
    name: pocketcoffee-magento
    creationPolicy: Owner
  data:
    - secretKey: MAGENTO_DB_PASSWORD
      remoteRef:
        key: pocketcoffee/prod/magento
        property: db_app_password
    - secretKey: MAGENTO_MIGRATE_DB_PASSWORD
      remoteRef:
        key: pocketcoffee/prod/magento
        property: db_migrate_password
    - secretKey: MAGENTO_CRYPT_KEY
      remoteRef:
        key: pocketcoffee/prod/magento
        property: crypt_key
    - secretKey: MAGENTO_OPENSEARCH_PASSWORD
      remoteRef:
        key: pocketcoffee/prod/magento
        property: opensearch_password
    - secretKey: MAGENTO_VALKEY_CACHE_AUTH_TOKEN
      remoteRef:
        key: pocketcoffee/prod/magento
        property: valkey_cache_auth_token
    - secretKey: MAGENTO_VALKEY_SESSION_AUTH_TOKEN
      remoteRef:
        key: pocketcoffee/prod/magento
        property: valkey_session_auth_token
    - secretKey: MAGENTO_AMQP_PASSWORD
      remoteRef:
        key: pocketcoffee/prod/magento
        property: amqp_password

The names are those of the chart on the Helm page; the listing shows the shape. The Secrets Manager rotation functions for MySQL expect a secret with the structure of a database credential (username, password, engine, host, dbname, port, and for the alternating-users strategy a masterarn), and a secret that also holds the encryption key and the service credentials should not be handed to a function that rewrites it. If you use the managed rotation, give each database user a secret of its own with that structure (for example pocketcoffee/prod/magento-db-app) and point two remoteRef entries at its password property instead of db_app_password; the combined secret then holds what has no rotation function, and the encryption key is changed by hand, as below.

rotation.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "rotation_lambda_arn" {
  type        = string
  description = "ARN of the Secrets Manager rotation function for MySQL with the alternating-users strategy"
}

resource "aws_secretsmanager_secret" "db_app" {
  name = "pocketcoffee/prod/magento-db-app"
}

resource "aws_secretsmanager_secret_rotation" "db_app" {
  secret_id           = aws_secretsmanager_secret.db_app.id
  rotation_lambda_arn = var.rotation_lambda_arn

  rotation_rules {
    automatically_after_days = 30
  }
}

The alternating users strategy keeps two database users (mg_app and a clone) and rotates the password of the one that is not in use, then switches the secret to it; the old password stays valid for one more rotation period. That window is what lets the rolling restart of the pods happen after the secret changed, without a moment in which the pods hold a password the database has already dropped. With the single-user strategy the password changes in the database and in the secret at the same instant, and every pod that has not restarted fails until it does. The function itself is created from the AWS templates for RDS MySQL (it runs in the VPC and needs a network path to Secrets Manager and to the database); this page does not reproduce it. The secret of mg_migrate is read by the migration Job only, so its rotation does not touch the web pods. If RDS Proxy is used, its own Secrets Manager reference has to follow the rotation (see Cloud: Managed Database (RDS and Aurora)).

Rotating the database password and rolling out
#!/usr/bin/env bash
set -euo pipefail

REGION=eu-west-1
NS=pocketcoffee

# 1. Rotate now (outside the schedule) and wait for the new version to become current.
aws secretsmanager rotate-secret \
  --region "$REGION" \
  --secret-id pocketcoffee/prod/magento-db-app

# 2. Force the External Secrets Operator to read it now instead of waiting for refreshInterval.
kubectl --namespace "$NS" annotate externalsecret pocketcoffee-magento \
  "force-sync=$(date +%s)" --overwrite

# 3. Restart every workload that connects to the database: each new pod reads the new environment.
for d in pocketcoffee-web pocketcoffee-admin; do
  kubectl --namespace "$NS" rollout restart "deployment/$d"
done
kubectl --namespace "$NS" get deployments -l app.kubernetes.io/name=pocketcoffee-magento -o name \
  | grep consumer \
  | xargs -r -n1 kubectl --namespace "$NS" rollout restart
for d in pocketcoffee-web pocketcoffee-admin; do
  kubectl --namespace "$NS" rollout status "deployment/$d" --timeout=10m
done

# 4. Check that the shop answers and that the pods connect.
kubectl --namespace "$NS" get pods -l app.kubernetes.io/component=web
curl --fail --silent --show-error --output /dev/null https://www.pocketcoffee.example/

The annotation is the one of the Operator’s documentation for forcing a reconciliation. The CronJob needs no restart: every run starts a new pod that reads the Kubernetes Secret as it is. A controller such as Reloader (stakater/Reloader) can restart the Deployments automatically when the Secret changes; it replaces step 3 and nothing else, and it should be tested on staging, because an unexpected restart in the middle of a campaign is also a rotation.

The encryption key

crypt/key in env.php is what Magento encrypts stored secrets with: the payment gateway credentials and other obscured configuration values in core_config_data, saved card data, and the provider credentials of 2FA. It is not one key but a list, and understanding that list is what makes a rotation safe. From lib/internal/Magento/Framework/Encryption/Encryptor.php: the constructor reads the configured string, splits it on whitespace into keys (preg_split('/\s+/s', ...)), and treats the last key as the active one for new encryption; each encrypted value carries the version of the key it was encrypted with, and decrypt() picks that key, returning an empty string if that index no longer exists. The method exportKeys() joins the list with a newline, and a key must be a 32-character string without whitespace.

What Magento offers, as read in the 2.4-develop source and in Adobe’s documentation:

Command What it does

bin/magento encryption:key:change [--key=<32 characters>]

Appends a key (random if --key is not given) to the list, writes the whole list to crypt/key in app/etc/env.php (it throws Deployment configuration file is not writable. if it cannot write the file) and cleans the cache. It does not re-encrypt stored data. The Admin option for changing the key was removed in 2.4.8; the command is the way.

bin/magento encryption:data:re-encrypt <re-encryptor>...

Re-encrypts the data of the named re-encryptors with the newest key. The two default ones are core_config_data and sales_order_payment, so the documented call is bin/magento encryption:data:re-encrypt core_config_data sales_order_payment; custom columns need a re-encryptor of their own, and bin/magento encryption:data:list-re-encryptors lists what is registered.

bin/magento encryption:payment-data:update

A different job: it re-encrypts saved credit card numbers with the latest cipher (the description of the command in the source), after an upgrade of the cipher, not after a key change. It is not a key rotation command.

Adobe’s guidance after a security incident says that rotating the key makes the new key the primary one for reading and writing sensitive data, that data encrypted earlier is read with the key recorded for it, and that rotation does not necessarily re-encrypt what was already stored; the same pages state that a key change invalidates all customer and admin sessions (integration users excluded), so everyone must sign in again. The documented on-premises procedure is: enable maintenance mode, disable cron, run the key change, flush the cache, enable cron, disable maintenance mode.

The cluster procedure

In this series env.php is a read-only template that reads crypt/key from the environment, so encryption:key:change cannot persist its result: its writer needs a writable app/etc/env.php, and the file in the pod is a read-only ConfigMap mount. What the command does to the configuration is, from the source above, append one key and join the list with a newline. The equivalent in this layout is therefore to do that to the value in Secrets Manager, and the order is the one Adobe documents, adapted to the workloads of the cluster. The rotation never removes an old key from the list; only after every encrypted value has been re-encrypted could the oldest keys be retired, and nothing requires that.

  1. Prepare. Make a manual snapshot of the database (the release script above), suspend the cron CronJob (kubectl patch cronjob pocketcoffee-cron --patch '{"spec":{"suspend":true}}') and scale the consumers to zero or pause their ScaledObjects, so that nothing writes encrypted values with the old key while the new one rolls out. Maintenance mode is a flag file in var/, which is per pod in this layout; how to put the whole fleet in maintenance (a Varnish or route level 503 page) is on the releases page, and a short rolling restart behind the existing readiness probes does not need it for this procedure.

  2. Add the key to the secret. Generate a new 32-character key outside the cluster and append it on a new line to the current value of crypt_key, keeping the old keys. The listing below does that without printing anything.

  3. Roll out and flush. Force the External Secrets refresh, restart the web, admin and consumer workloads (the previous section), and flush the Magento cache once the new pods are up (bin/magento cache:flush from a pod, or the Job of the releases page). The sessions are invalidated; expect customers to sign in again.

  4. Re-encrypt. Run bin/magento encryption:data:re-encrypt core_config_data sales_order_payment (plus the custom re-encryptors listed by encryption:data:list-re-encryptors) once, as a Job from the same image with the same Secret (the shape of the pocketcoffee-reindex Job; it writes to the database only and does not need a writable env.php). Re-encryption is optional by Adobe’s description, but it makes the new key the one that protects the data, which is the reason to rotate after an exposure.

  5. Resume. Resume the cron CronJob and the consumers, verify an order with a saved card and a payment-method configuration screen in the Admin.

Appending a new key to crypt_key without printing it
#!/usr/bin/env bash
set -euo pipefail

REGION=eu-west-1
SECRET_ID=pocketcoffee/prod/magento

# Read the whole JSON secret into a variable; nothing is echoed.
CURRENT=$(aws secretsmanager get-secret-value \
  --region "$REGION" \
  --secret-id "$SECRET_ID" \
  --query SecretString \
  --output text)

# A key is a 32-character string without whitespace: 16 random bytes as 32 hexadecimal characters.
NEW_KEY=$(openssl rand -hex 16)

# Keep every old key and put the new one last: the last key is the active one.
UPDATED=$(printf '%s' "$CURRENT" | jq --arg k "$NEW_KEY" '.crypt_key = (.crypt_key + "\n" + $k)')

aws secretsmanager put-secret-value \
  --region "$REGION" \
  --secret-id "$SECRET_ID" \
  --secret-string "$UPDATED" \
  >/dev/null

unset CURRENT NEW_KEY UPDATED
echo "crypt_key now has $(printf '%s' "$(aws secretsmanager get-secret-value --region "$REGION" --secret-id "$SECRET_ID" --query SecretString --output text)" | jq -r '.crypt_key | split("\n") | length') keys"

The script reads and rewrites the one secret pocketcoffee/prod/magento of this series and prints only a count. The caller needs secretsmanager:GetSecretValue and secretsmanager:PutSecretValue on it, and the secret should have no other writer at the same time. Two warnings that follow from the source. Never replace the list with a single new key: values encrypted with the old keys then decrypt to an empty string, which means lost payment credentials and lost configuration values that nobody can read back. And do not edit the key list of the running pods by hand: the next External Secrets refresh would overwrite it. If the first installation (the Job pocketcoffee-install) generated the original key, that key is the first line of the secret; it is not shown on this page and never appears in a listing. After an incident that exposed the key (the CosmicSting advisory of 2024 is the example on the security page), the rotation is followed by re-encryption, by new integration and payment credentials, and by treating the Admin passwords as exposed.

What was not verified: whether encryption:key:change accepts a list already present in an environment-provided crypt/key when it is run inside a pod with a writable app/etc (the cluster procedure avoids relying on it), the exact option set of the command in 2.4.9 beyond --key (bin/magento encryption:key:change --help lists it), and the behaviour of a fleet during the minutes in which pods with the old and the new list coexist (both lists contain the old keys, so values encrypted by an old pod are readable by a new one, but a value encrypted by a new pod with the new key is unreadable by an old pod until it restarts; the rolling restart bounds that window, and writes during it are the reason for the prepare step).

Checklist

Check Done when

HPA

kubectl get hpa pocketcoffee-web shows both metrics with a value (not unknown), maxReplicas times pm.max_children fits max_connections

FPM

phpfpm_listen_queue and phpfpm_max_children_reached are on a dashboard and alert; the Varnish hit ratio is next to them

Logs

An error in the shop appears in CloudWatch Logs within a minute; the log groups have a retention

Slow log

slowquery is exported, the group has a retention, someone reads it

Backups

A restore drill of the database and of the media was done this quarter, the shop worked on the restored copies, and a reindex into a scratch prefix was timed

DR

The runbook for eu-central-1 exists outside the platform and was rehearsed this year

WAF

The ACL is enforced, not counting; the payment callback path was tested through it with a provider test event

Admin

The path answers 403 from outside the allowed addresses and 404 on /admin; 2FA is enabled on the production image

Secrets

The rotation of the database password ends in a rollout of every workload and was tested on staging

Encryption key

crypt_key is a list that only grows, the re-encryption ran after the last rotation, and nobody replaces the list by one key

What was run and what was not

  • Read in the 2.4-develop source of magento/magento2: app/etc/di.xml (the Magento\Framework\Logger\Monolog handlers), Framework/Logger/Handler/Base.php and System.php, Framework/Encryption/Encryptor.php (key list, setNewKey, exportKeys, validateKey), EncryptionKey/Console/Command/UpdateEncryptionKeyCommand.php, ReEncryptDataCommand.php and ListReEncryptorsCommand.php, Sales/Console/Command/EncryptionPaymentDataUpdateCommand.php, Developer/Console/Command/ (profiler, query log), NewRelicReporting/composer.json, and the root composer.json (monolog/monolog ^3.6).

  • Read in Adobe’s documentation: the encryption key page (CLI, sessions, procedure), the data re-encryption page, the profiler page, the 2FA FAQ and the Observability installation page; in the AWS documentation: OpenSearch Service snapshots, AWS Backup for S3, ElastiCache backups, the AWS managed rule groups list. In the OpenTelemetry and Packagist pages: the PHP zero-code instrumentation and the open-telemetry/opentelemetry-auto-magento2 package.

  • Run: php-fpm -t on the pool file, kubeconform -strict -ignore-missing-schemas on the YAML listings, tofu validate and tofu fmt -check on the OpenTofu listings, shellcheck on the shell listings, php -l on the handler class, xmllint --noout on the di.xml and the SVG.

  • Not run: anything against AWS (WAF, AWS Backup, restore jobs, the CloudWatch add-on, Secrets Manager rotation, OpenSearch snapshots), a Magento store with the replacement logger handler or with the key list taken from the environment, and a key rotation on a live shop. The behaviour of the key list is from the source; the consequences for a fleet follow from the code and were not observed.

  • Not documented by Adobe for these editions and therefore to be verified before relying on it: the support of the New Relic PHP agent and of OpenTelemetry on Magento Open Source and Mage-OS, the supported way of logging to stdout (the replacement handler above is a customisation), whether Magento_TwoFactorAuth can be disabled on 2.4.9, and where the Google Authenticator secrets are stored.