Releases, Upgrades and Autoscaling on Kubernetes

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

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

This page was written against WordPress 7.1.3 (7.1.4, the security release of 2026-10-28, is the version to run in production), PHP 8.4, WP-CLI 2.12.0, Helm 4.3 (the --rollback-on-failure flag; Helm 3 calls it --atomic) and Kubernetes 1.36 and 1.37 on Amazon EKS. It was checked on 2026-10-29. The workflow was checked with actionlint, the Helm templates with helm lint and kubeconform against the Kubernetes 1.36 schemas, the shell with shellcheck, the Nginx configuration with nginx -t and the OpenTofu with tofu validate. Nothing was run against a real AWS account or cluster, so every command that touches AWS is shown for you to adapt, and the behaviour of helm against a live cluster is described from the documentation.

The page covers what happens after the cluster of EKS Infrastructure with OpenTofu and the chart of Deploying on EKS with Helm exist: how a commit becomes a running release, how the database schema follows the code, what a rollback can and cannot undo, how the web tier grows and shrinks, how to see what it is doing, and how to get it back after a disaster. The names are the ones of the other pages of the Cloud Deployment section: cluster lumenpress-prod, namespace lumenpress, Helm release lumenpress, chart directory lumenpress-wordpress, Deployment lumenpress-web (Nginx and PHP-FPM containers), CronJob lumenpress-cron, Job lumenpress-migrate and ECR repository lumenpress/wordpress, in the account 123456789012 and the region eu-west-1. Cluster creation, Helm and the HorizontalPodAutoscaler themselves are in Amazon EKS Clusters, Helm and Autoscaling; this page only adds what is specific to WordPress.

The release pipeline

In the tiered setup nothing is edited on a server. A release is a new container image holding WordPress core, the plugins, the themes and the Lumen Press code (see Composer-Based WordPress), a helm upgrade that points the Deployment at it, and, between the two, a database schema upgrade. The sequence is the same for a theme change, a plugin update and a WordPress core update.

sequenceDiagram participant Dev as Developer participant GH as GitHub Actions participant STS as AWS STS (OIDC) participant ECR as Amazon ECR participant K as EKS API server participant Job as Job lumenpress-migrate participant DB as RDS lumenpress-prod participant Web as Deployment lumenpress-web Dev->>GH: merge to main GH->>STS: OIDC token, AssumeRoleWithWebIdentity (build role) STS-->>GH: short-lived credentials GH->>ECR: push lumenpress/wordpress:<git-sha> GH->>STS: OIDC token, assume deploy role (environment production) GH->>DB: create-db-snapshot lumenpress-pre-<git-sha> GH->>K: helm upgrade lumenpress --set image.tag=<git-sha> K->>Job: pre-upgrade hook with the new image Job->>DB: wp core update-db Job-->>K: Complete K->>Web: rolling update, new Pods pass readiness K-->>GH: release deployed, or rolled back on failure

The old Pods keep serving while the Job runs, so the schema upgrade has to be compatible with the code that is still running (see Rollback limits). The first installation of a fresh cluster is the Job lumenpress-install of the chart, not this pipeline.

Authenticating GitHub Actions to AWS with OIDC

A long-lived access key in a GitHub secret is the weakest link of most pipelines. With OpenID Connect, GitHub issues a signed token to each workflow run, AWS validates it against an IAM OIDC identity provider for token.actions.githubusercontent.com, and sts:AssumeRoleWithWebIdentity returns credentials that last an hour at most. The trust policy of the role decides which repositories, branches or environments may assume it through the sub claim. Lumen Press uses two roles: lumenpress-github-ecr-push, which can only push to the ECR repository and is assumed by any run on main (the OIDC provider and this role are created by EKS Infrastructure with OpenTofu), and lumenpress-github-deploy, which can deploy, assumed only by jobs that target the GitHub environment production, where a reviewer can be required.

trust-policy-deploy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:lumen-press/lumenpress-site:environment:production"
        }
      }
    }
  ]
}

The sub condition is the security boundary: without it any repository on GitHub could assume the role. The build role uses repo:lumen-press/lumenpress-site:ref:refs/heads/main instead of the environment. The permissions of the build role are the ECR push actions on arn:aws:ecr:eu-west-1:123456789012:repository/lumenpress/wordpress plus ecr:GetAuthorizationToken on *. The deploy role needs eks:DescribeCluster on lumenpress-prod, rds:CreateDBSnapshot and rds:DescribeDBSnapshots. It does not need any Kubernetes permission in IAM: what it may do inside the cluster is granted by an EKS access entry, scoped to the namespace.

Granting the deploy role access to the namespace only
#!/usr/bin/env bash
set -euo pipefail

CLUSTER=lumenpress-prod
ROLE_ARN=arn:aws:iam::123456789012:role/lumenpress-github-deploy

aws eks create-access-entry \
  --cluster-name "$CLUSTER" \
  --principal-arn "$ROLE_ARN" \
  --type STANDARD

aws eks associate-access-policy \
  --cluster-name "$CLUSTER" \
  --principal-arn "$ROLE_ARN" \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy \
  --access-scope type=namespace,namespaces=lumenpress

The Admin policy is needed because a chart that creates a ServiceAccount, a Role or a PodDisruptionBudget writes more than the AmazonEKSEditPolicy allows. Scoped to the lumenpress namespace, it cannot touch other namespaces or cluster-wide objects; if the chart installs cluster-scoped objects (CRDs, ClusterRoles), install them from a separate, manually approved pipeline. Access entries and Pod Identity are explained on Amazon EKS Clusters.

The workflow

release.yml (in .github/workflows)
name: release

on:
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * 1"   # weekly rebuild to pick up base image and PHP fixes
  workflow_dispatch:

permissions:
  contents: read

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

env:
  AWS_REGION: eu-west-1
  ECR_REPOSITORY: lumenpress/wordpress
  CLUSTER: lumenpress-prod
  NAMESPACE: lumenpress

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    outputs:
      tag: ${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/lumenpress-github-ecr-push
          aws-region: ${{ env.AWS_REGION }}
      - id: ecr
        uses: aws-actions/amazon-ecr-login@v2
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          pull: true
          tags: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/lumenpress-github-deploy
          aws-region: ${{ env.AWS_REGION }}
      - uses: azure/setup-helm@v4
        with:
          version: v4.3.1
      - name: Snapshot the database before the schema can change
        run: |
          aws rds create-db-snapshot \
            --db-instance-identifier lumenpress-prod \
            --db-snapshot-identifier "lumenpress-pre-${GITHUB_SHA}"
          aws rds wait db-snapshot-available \
            --db-snapshot-identifier "lumenpress-pre-${GITHUB_SHA}"
      - name: Connect to the cluster
        run: aws eks update-kubeconfig --name "$CLUSTER" --region "$AWS_REGION"
      - name: Upgrade the release
        run: |
          helm upgrade --install lumenpress ./lumenpress-wordpress \
            --namespace "$NAMESPACE" \
            --values ./lumenpress-wordpress/values-production.yaml \
            --set "image.tag=${GITHUB_SHA}" \
            --rollback-on-failure \
            --timeout 15m \
            --history-max 10
      - name: Smoke test
        run: |
          curl --fail --silent --show-error --retry 5 --retry-delay 5 \
            https://www.lumenpress.example/wp-login.php --output /dev/null

Points that matter more than the syntax:

  • permissions: id-token: write is what lets a job request the OIDC token; it is granted per job, and the workflow-wide default stays contents: read.

  • concurrency with cancel-in-progress: false serialises releases. Two helm upgrade runs against one release collide with "another operation is in progress", and cancelling a run in the middle of a hook leaves the release in pending-upgrade.

  • The image is tagged with the commit, never latest, so that helm history states exactly what runs and a rollback points at an image that still exists. ECR lifecycle rules must keep at least as many images as --history-max revisions, or a rollback points at a deleted tag.

  • The aws rds wait db-snapshot-available step can take minutes on a large database. The snapshot is the only way back from a failed schema change that already committed, see Rollback limits.

  • The action versions shown (@v4, @v2, @v6) are majors that existed when this page was checked; pin the current major, or a full commit SHA, and let Dependabot update them.

  • --rollback-on-failure is the Helm 4 name of the Helm 3 --atomic flag: if the upgrade fails, Helm rolls back to the last successful revision and returns an error. It implies waiting for the resources to become ready, so the timeout covers the hook Job and the rolling update together.

  • The smoke test checks the application through the load balancer, which proves DNS, TLS, the listener rule and a PHP response; a failing health check on a Pod would already have stopped the rollout.

The weekly schedule trigger matters for WordPress. Because the image is immutable, DISALLOW_FILE_MODS is on and background updates cannot change files, a fix to PHP or to a Debian library reaches production only when the image is rebuilt (see Updates and Auto-Updates).

Database upgrades as a pre-upgrade Job

WordPress stores the schema version in the db_version option and compares it with the version in the code on every admin request. When they differ, the Dashboard shows "Database Update Required" and the update runs as the first administrator who clicks it. With several Pods, a rolling update and a CI pipeline, that is the wrong moment and the wrong actor: the first request after a rollout would take locks on wp_options and wp_posts while real traffic arrives. The same work is wp core update-db, which compares the versions and runs wp_upgrade() once; on a multisite network wp core update-db --network visits every site.

The chart runs it as a Helm hook. A hook is a template with the annotation helm.sh/hook; Helm creates it at the named point of the release lifecycle, waits for it to finish and aborts the release if it fails. pre-upgrade runs after the templates are rendered and before any resource of the new revision is applied.

templates/migrate-job.yaml
{{- if .Values.migrate.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: lumenpress-migrate
  labels:
    app.kubernetes.io/name: lumenpress-wordpress
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/component: migrate
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  activeDeadlineSeconds: {{ .Values.migrate.timeoutSeconds }}
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lumenpress-wordpress
        app.kubernetes.io/component: migrate
    spec:
      restartPolicy: Never
      serviceAccountName: {{ .Values.serviceAccount.name }}
      securityContext:
        runAsNonRoot: true
        runAsUser: 33
        runAsGroup: 33
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: wp-cli
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["sh", "-c"]
          args:
            - |
              set -eu
              wp core is-installed
              wp core update-db
              wp core verify-checksums
          envFrom:
            - secretRef:
                name: lumenpress-wordpress
          env:
            - name: WP_CLI_CACHE_DIR
              value: /tmp/wp-cli-cache
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            requests: {cpu: 100m, memory: 256Mi}
            limits: {memory: 512Mi}
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
{{- end }}

Details of the hook that are easy to get wrong:

  • The Job uses the image of the new release, the code that knows the new schema, and the same environment as the web Pods. It assumes that the image contains WP-CLI (the production image of Deploying on EKS with Helm adds the Phar) and that wp-config.php reads its settings from the environment, so no file has to be mounted.

  • A hook runs before the new revision’s other resources exist. The Secret lumenpress-wordpress and the ServiceAccount must already exist from the previous revision, which is true for every upgrade but not for the first install. That is why the first release uses pre-install hooks in a separate template (the lumenpress-install Job), created after the Secret, and pre-upgrade is used here. A new key added to the Secret in the same release is not visible to the hook.

  • hook-delete-policy: before-hook-creation,hook-succeeded removes the previous Job before creating the next, and the Job after it succeeds. A failed Job stays, so kubectl logs job/lumenpress-migrate still works after a failure.

  • backoffLimit: 0 and activeDeadlineSeconds fail the release at once instead of retrying a half-applied schema change. wp core update-db is idempotent, so a repeat after a fix is safe.

  • wp core verify-checksums compares the core files in the image with the checksums of wordpress.org and fails on a modified file. It is a cheap supply-chain check at release time; it does not cover plugins unless wp plugin verify-checksums --all is added, which only works for plugins from the directory.

  • The CronJob lumenpress-cron can start while the Job runs. Suspending it first (kubectl patch cronjob lumenpress-cron -p '{"spec":{"suspend":true}}') and resuming afterwards is optional, because events scheduled in the meantime run on the next tick, but long-running events that touch tables being altered are a reason to do it for major releases.

WooCommerce and other plugins that ship their own table upgrades (WooCommerce’s wc update, many membership plugins) run through their own WP-CLI commands or admin notices, not through wp core update-db. Add them to the same Job as extra lines after the core update, for example wp wc update, once the plugin is installed in the image.

Plugins and themes through the image

An administrator who clicks "Update" in the Dashboard on one Pod changes only that Pod’s filesystem, which disappears on the next restart and is not visible on the other Pods. In production the Dashboard is therefore read-only (DISALLOW_FILE_MODS, see Updates and Auto-Updates) and every plugin, theme and core change is a commit that builds a new image:

  1. composer outdated or a dependency bot (Dependabot or Renovate understand composer.json) proposes the new plugin version, WPackagist or a private repository serving it, as a pull request.

  2. CI builds the image, runs the tests of Testing and deploys it to lumenpress-staging, where wp core update-db runs in the same Job.

  3. After review the same commit is released to production, which is the image already built: the pipeline can be split so that production re-tags the staging image instead of rebuilding, guaranteeing that what was tested is what runs. ECR accepts a re-tag with aws ecr put-image using the manifest of the existing tag.

A plugin that must be activated or configured by the update (wp plugin activate, wp option update) belongs in the same Job or in an idempotent wp eval-file script, never in a manual step. A plugin that writes to its own directory at run time (page-builder caches, generated CSS) needs an emptyDir or an object-store option; see Media: S3 Offload, EFS and CloudFront for uploads and Object Cache, Sessions and Cron in a Cluster for the other state.

Rollback limits

helm rollback lumenpress <revision> re-applies the manifests of an earlier revision. It restores the image tag, the replica count and the configuration; it does not restore the database, the object store or the Valkey cache.

Change in the release What a rollback does

Template, values, image tag only

Fully reverted: the previous Pods come back, as long as the image still exists in ECR.

wp core update-db ran (minor release)

The previous code runs against the new schema. Minor security releases such as 7.1.4 normally keep the schema compatible, but WordPress offers no supported downgrade path; the safe test is staging.

A plugin added columns or tables

Usually harmless to the old code; a plugin that renames or drops columns breaks the old Pods. The new tables stay.

A plugin deactivated or removed in the release

The old image reactivates it from the options table, with whatever data the new version wrote.

Options migrated (serialised arrays, siteurl)

The old code reads the new format; may fail silently.

Three practices keep a release recoverable:

  • Expand, then contract. A release that needs a destructive change ships in two steps: the first adds the new column or option and writes both, the second, one release later, removes the old. WordPress core follows this itself.

  • Snapshot before the schema can change, as the workflow does. Restoring the snapshot creates a new instance (see Backups and disaster recovery of the tiered setup); the application is then pointed at it by changing the DB_HOST value and redeploying, and the data written since the snapshot is lost. This is the last resort, not the routine rollback.

  • Roll back the image, not the data. With a compatible schema helm rollback lumenpress 0 --wait --timeout 10m (revision 0 is "the previous one") returns to the previous image in one rolling update.

A pre-upgrade hook does not run on helm rollback: Helm has separate pre-rollback hooks, and the migration is not reversed. The rollback does cancel the in-flight rolling update because Helm re-applies the old Deployment, and the old ReplicaSet scales up first.

Maintenance mode

WordPress has a built-in maintenance mode: a .maintenance file in the WordPress directory makes every request answer "Briefly unavailable for scheduled maintenance". WP-CLI toggles it with wp maintenance-mode activate, deactivate and status, and core creates the file itself during a manual update. In Kubernetes the file lives in the filesystem of one Pod: it does not exist on the other replicas, it is lost on restart, and on a read-only root filesystem it cannot be written at all. A rolling update needs no maintenance mode in the first place, because old and new Pods serve together; reserve a maintenance window for a release whose schema change the old code cannot read.

The pattern that works across Pods is a flag outside the Pod filesystem. A ConfigMap mounted into the Nginx container appears as a file whose presence Nginx tests on each request, so creating or deleting the ConfigMap switches every Pod without a restart.

maintenance.conf (included in the server block of the nginx container)
location = /healthz {
    access_log off;
    default_type text/plain;
    return 200 "ok\n";
}

location / {
    if (-f /etc/nginx/maintenance/enabled) {
        return 503;
    }
    try_files $uri $uri/ /index.php?$args;
}

error_page 503 @maintenance;
location @maintenance {
    default_type text/html;
    add_header Retry-After 120 always;
    return 503 "<h1>Lumen Press is being updated</h1><p>Back in a few minutes.</p>\n";
}

location ~ \.php$ {
    if (-f /etc/nginx/maintenance/enabled) {
        return 503;
    }
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass 127.0.0.1:9000;
}

/healthz answers before the check, so the load balancer keeps the Pods in service and shows the maintenance page instead of failing health checks and returning its own 503. The ConfigMap is mounted with optional: true so that Pods start without it:

Mounting the optional maintenance flag (excerpt of the Deployment)
volumes:
  - name: maintenance
    configMap:
      name: lumenpress-maintenance
      optional: true
Switching maintenance mode on and off
#!/usr/bin/env bash
set -euo pipefail

# On: Nginx on every Pod returns 503 once the kubelet refreshes the mounted ConfigMap.
kubectl --namespace lumenpress create configmap lumenpress-maintenance --from-literal=enabled=1

# ... run the release ...

# Off.
kubectl --namespace lumenpress delete configmap lumenpress-maintenance

The kubelet refreshes mounted ConfigMaps periodically, so the switch takes up to about a minute to reach every Pod, and a mount with subPath never refreshes, which is why the whole directory is mounted. The Nginx if is safe here because its only content is return; combining if with other directives in a location is the unsafe use the Nginx documentation warns about.

Autoscaling the web tier

A WordPress Pod is a fixed pool of PHP-FPM workers (pm.max_children). When all workers are busy, further requests wait in the listen queue of the socket and then time out at the proxy, long before the Pod’s CPU is saturated if the requests mostly wait for the database or an external API. CPU is therefore a reasonable first signal for cache-friendly traffic and a poor one for anything I/O bound. The signal that describes saturation directly is the number of busy workers.

CPU-based scaling and the requests it depends on

The HorizontalPodAutoscaler computes utilisation as a percentage of the CPU request of the containers, so the request of php-fpm has to reflect real use, and the sidecars count too (exporters have small requests, which skews the average a little). Without a request the HPA cannot compute CPU utilisation at all and reports unknown. minReplicas is three across three availability zones, so that losing a zone leaves two Pods and a PodDisruptionBudget has room to drain nodes. Scale-up must be fast, because a traffic peak arrives within a minute, and scale-down slow, because a new Pod needs time to warm OPcache and the object cache.

PHP-FPM metrics as a custom metric

PHP-FPM publishes its pool state on a status page when pm.status_path is set. A sidecar exporter translates that page into Prometheus metrics: phpfpm_active_processes, phpfpm_idle_processes, phpfpm_total_processes, phpfpm_listen_queue and phpfpm_max_children_reached are the ones that matter. Two such exporters are widely used: the community project hipages/php-fpm_exporter (not an official WordPress or PHP project) and the nginx-prometheus-exporter published by the Nginx project for stub_status. Check the metric names on the /metrics endpoint of the version you deploy before building a dashboard or an HPA on them; they are the contract between the pieces.

php-fpm pool settings (zz-lumenpress.conf)
[www]
pm = dynamic
pm.max_children = 20
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_slowlog_timeout = 5s
slowlog = /proc/self/fd/2

pm.max_children = 20 has to agree with the database: with ten Pods the application can open 200 connections, which is the number to compare with max_connections of the RDS instance (see Managed Database: RDS and Aurora). The status page is served by FPM over FastCGI, so it must not be reachable through Nginx: the exporter talks to port 9000 inside the Pod. The FPM slow log relies on ptrace, which in a container normally needs the SYS_PTRACE capability that the production Pod drops; it is only practical in staging, and on production the RDS slow query log and an APM agent are the tools.

The sidecars are added through an extraContainers values key; add the three with lines below to the end of the containers list in the Deployment template of the chart from Deploying on EKS with Helm:

values.yaml (extraContainers)
extraContainers:
  - name: php-fpm-exporter
    image: hipages/php-fpm_exporter:<pinned-version>
    args: ["--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}
  - name: nginx-exporter
    image: nginx/nginx-prometheus-exporter:<pinned-version>
    args: ["--nginx.scrape-uri=http://127.0.0.1:8080/stub_status"]
    ports:
      - name: nginx-metrics
        containerPort: 9113
    resources:
      requests: {cpu: 10m, memory: 32Mi}
      limits: {memory: 64Mi}
templates/deployment.yaml (excerpt)
      containers:
        - name: php-fpm
          image: {{ include "lp.phpImage" . }}
          # ... as in the chart on the Deploying page
        - name: nginx
          image: {{ include "lp.nginxImage" . }}
          # ...
        {{- with .Values.extraContainers }}
        {{- toYaml . | nindent 8 }}
        {{- end }}

The Nginx container needs a stub_status location reachable only from inside the Pod:

stub-status.conf
server {
    listen 127.0.0.1:8080;
    access_log off;

    location = /stub_status {
        stub_status;
    }
}

The HPA cannot read Prometheus directly. A metrics adapter exposes selected series through the Kubernetes custom.metrics.k8s.io API. The adapter of the Kubernetes SIGs (prometheus-adapter), configured with rules, and KEDA (see Autoscaling) are the two common choices; the first keeps a plain HPA, the second replaces it with a ScaledObject. Prometheus itself is scraping through a PodMonitor of the Prometheus Operator (or an Amazon Managed Service for Prometheus collector) that selects the web Pods and both exporter ports.

templates/podmonitor.yaml
{{- if .Values.metrics.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: lumenpress-web
  labels:
    app.kubernetes.io/name: lumenpress-wordpress
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: lumenpress-wordpress
      app.kubernetes.io/component: web
  podMetricsEndpoints:
    - port: fpm-metrics
      interval: 15s
    - port: nginx-metrics
      interval: 15s
{{- end }}
prometheus-adapter values (rules)
rules:
  custom:
    - seriesQuery: 'phpfpm_active_processes{namespace!="",pod!=""}'
      resources:
        overrides:
          namespace: {resource: namespace}
          pod: {resource: pod}
      name:
        matches: ^phpfpm_active_processes$
        as: phpfpm_active_processes
      metricsQuery: 'avg_over_time(<<.Series>>{<<.LabelMatchers>>}[1m])'

The rule maps the series labels namespace and pod to Kubernetes resources, so that the API /apis/custom.metrics.k8s.io/v1beta1/namespaces/lumenpress/pods/*/phpfpm_active_processes returns one value per Pod. The one-minute average smooths the sawtooth of short requests. With both metrics in one HPA, the controller computes a desired replica count for each and takes the larger.

templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: lumenpress-web
  labels:
    app.kubernetes.io/name: lumenpress-wordpress
    app.kubernetes.io/instance: {{ .Release.Name }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: lumenpress-web
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.cpuUtilization }}
    {{- if .Values.autoscaling.fpm.enabled }}
    - type: Pods
      pods:
        metric:
          name: phpfpm_active_processes
        target:
          type: AverageValue
          averageValue: {{ .Values.autoscaling.fpm.activeProcessesPerPod | quote }}
    {{- end }}
  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
{{- end }}

With pm.max_children = 20, activeProcessesPerPod: 12 (60 percent of the workers) leaves room for a burst while new Pods start. The values mean: add up to 100 percent or four Pods per minute, whichever is more; remove at most one Pod every two minutes, and only after ten minutes below the target. maxReplicas is bounded by what the rest of the system can carry: the RDS max_connections, the Valkey connection limit and the NAT or load balancer capacity. A Pod that is not ready yet is ignored by the HPA for CPU for a short initial period, so a readiness probe that really waits for PHP-FPM (see the probes of the chart) prevents scaling on the metrics of a Pod that is still starting.

If the metrics adapter is down, the HPA reports a failure to get the Pods metric and, with a CPU metric also defined, keeps scaling on CPU only when scaling up is needed and refuses to scale down. That is a safe failure mode, and the reason to keep the CPU metric as a second signal rather than replacing it.

Node capacity is the other half. New Pods stay Pending until a node exists, which Karpenter or EKS Auto Mode create in about a minute or two (see Amazon EKS Clusters). Overprovisioning with low-priority placeholder Pods hides that delay and is cheaper than a high minReplicas.

Load testing

Capacity numbers (requests per second per Pod, the right pm.max_children, the HPA target) come from measurements, not from defaults. Test the staging cluster, which has the same chart and a smaller instance class, never somebody else’s site and never production at a peak. A cache-hit test and a cache-miss test measure two different systems, so run both: the first with a normal URL set, the second with a query string that bypasses the page cache.

loadtest.mjs
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate',
      startRate: 5,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 400,
      stages: [
        { target: 50, duration: '3m' },
        { target: 50, duration: '10m' },
        { target: 150, duration: '3m' },
        { target: 150, duration: '10m' },
        { target: 0, duration: '2m' },
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<1200'],
  },
};

const pages = ['/', '/books/', '/books/the-lamp-and-the-lens/', '/about/'];

export default function () {
  const path = pages[Math.floor(Math.random() * pages.length)];
  const res = http.get(`https://staging.lumenpress.example${path}`, {
    headers: { 'Cache-Control': 'no-cache' },
  });
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.1);
}

A constant arrival rate models a public site better than a fixed number of virtual users, because real visitors do not slow down when the site does. During the run, watch kubectl get hpa lumenpress-web --watch, the Pod count, the phpfpm_listen_queue series (any sustained value above zero means requests are waiting for a worker), the 95th percentile latency and the RDS DatabaseConnections and CPU. The result to look for is the replica count at which the latency threshold is still met; set maxReplicas above it and activeProcessesPerPod so that scale-up starts well before that point. Checking that the object cache is used during the test (the Redis keyspace_hits) separates a slow database from a cold cache.

Observability

Four sources describe a WordPress release in production:

  • Logs. Nginx writes the access and error logs to stdout and stderr, PHP-FPM writes to stderr, and PHP errors go to php://stderr (log_errors, error_log unset in the image; display_errors = Off). The container runtime captures them and Fluent Bit or the CloudWatch agent ships them to CloudWatch Logs; the setup is on Monitoring and Observability. Structured JSON for the Nginx access log (log_format with escape=json) makes the request time and upstream time searchable.

  • Metrics. nginx_http_requests_total, nginx_connections_active, phpfpm_* from the exporters, plus the kube-state and cAdvisor metrics for restarts, throttling and memory. The RED set (rate, errors, duration) per route comes from the Nginx logs or the load balancer metrics; the saturation signal is phpfpm_listen_queue.

  • Database. The slow query log of RDS is the most useful single tool for a slow WordPress. It is a parameter group setting, and the engine writes the file to the instance; the instance publishes it to CloudWatch Logs.

  • Traces and real user monitoring. Out of scope here; any APM agent for PHP or an OpenTelemetry PHP extension works in the image.

Slow query log and CloudWatch export (OpenTofu)
variable "name" {
  type    = string
  default = "lumenpress-prod"
}

resource "aws_db_parameter_group" "wordpress" {
  name   = "${var.name}-mariadb118"
  family = "mariadb11.8"

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

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

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

resource "aws_db_instance" "wordpress" {
  identifier                      = var.name
  engine                          = "mariadb"
  engine_version                  = "11.8"
  instance_class                  = "db.m7g.large"
  allocated_storage               = 100
  db_name                         = "lumenpress"
  username                        = "wp_admin"
  manage_master_user_password     = true
  parameter_group_name            = aws_db_parameter_group.wordpress.name
  enabled_cloudwatch_logs_exports = ["error", "slowquery"]
  backup_retention_period         = 14
  multi_az                        = true
  deletion_protection             = true
  skip_final_snapshot             = false
  final_snapshot_identifier       = "${var.name}-final"
}

(The registry page documents the AWS provider that OpenTofu uses unchanged.) The snippet only shows the logging and backup arguments; networking, encryption and the rest of the instance are on Managed Database: RDS and Aurora and the OpenTofu page. A long_query_time of 1 second logs every query above that; WordPress with a page cache logs few, and without one it logs the wp_options autoload query and WP_Query meta queries first. Retention in CloudWatch Logs is set per log group (/aws/rds/instance/lumenpress-prod/slowquery) and the group is created by RDS, so set its retention explicitly or it keeps everything. In the console the Performance Insights experience was replaced by CloudWatch Database Insights, which also covers execution plans in its Advanced mode; see the RDS page for the current wording.

Backups and disaster recovery of the tiered setup

The single-server backup of Backups and Disaster Recovery (one dump plus a tarball) becomes a set of independent stores, each with its own recovery point:

Component Backup Recovery

RDS lumenpress-prod

Automated backups with point-in-time recovery (retention set above), manual snapshots before releases, copies of snapshots to a second region through AWS Backup

restore-db-instance-to-point-in-time creates a new instance; the application is repointed

S3 bucket lumenpress-prod-media

Versioning, plus replication or AWS Backup to a bucket in another region; MFA delete is not needed if the deploy roles cannot delete versions

Restore of a prefix from a version or the replica

EFS (only when uploads use it)

AWS Backup, cross-region copy

Restore into a new file system, which the PersistentVolume references

Secrets Manager lumenpress/prod/wordpress

Replicated secret to the DR region; the salts are not secret to the DB dump, but losing them logs everyone out

Promote the replica

ECR lumenpress/wordpress

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

Pull in the DR cluster

Chart, OpenTofu code, state

Git plus the versioned state bucket

tofu apply in the DR region

Valkey

None: the object cache is disposable

Starts empty; expect a slow first minutes

AWS Backup plan with a copy to a second region (OpenTofu)
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 and the EFS file system to protect"
}

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

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

resource "aws_iam_role" "backup" {
  name = "lumenpress-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" {
  role       = aws_iam_role.backup.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
}

resource "aws_backup_plan" "lumenpress" {
  name = "lumenpress-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" "lumenpress" {
  name         = "lumenpress-data"
  iam_role_arn = aws_iam_role.backup.arn
  plan_id      = aws_backup_plan.lumenpress.id
  resources    = var.protected_resource_arns
}

Vaults can also be encrypted with a customer-managed KMS key per region; add kms_key_arn once the keys exist (see Secrets Manager and KMS). A backup that was never restored is not a backup, so the DR drill below is part of the operation, not an extra:

Restoring the database to a point in time for a drill
#!/usr/bin/env bash
set -euo pipefail

aws rds restore-db-instance-to-point-in-time \
  --source-db-instance-identifier lumenpress-prod \
  --target-db-instance-identifier lumenpress-restore-drill \
  --restore-time 2026-10-29T09:30:00Z \
  --db-subnet-group-name lumenpress-db \
  --no-publicly-accessible

aws rds wait db-instance-available --db-instance-identifier lumenpress-restore-drill

# Point a staging release at the restored instance and check the site:
helm upgrade lumenpress ./lumenpress-wordpress \
  --namespace lumenpress-drill \
  --set "wordpress.dbHost=$(aws rds describe-db-instances \
      --db-instance-identifier lumenpress-restore-drill \
      --query 'DBInstances[0].Endpoint.Address' --output text)"

Two things to decide in advance. The RPO of the database is minutes (point-in-time recovery is limited to about the last five minutes), that of the media is the replication lag of S3, and that of the code is zero because it is in Git and ECR. The RTO of a regional disaster is dominated by the steps that are not automatic: applying the OpenTofu stack in the DR region, restoring the database from the copied snapshot, and changing DNS. Keep the DR region as a pilot light (vaults, ECR and secret replicas, and the OpenTofu code exercised in staging) and rehearse the whole sequence at least twice a year. Route 53 health checks and failover records, and the TTL for the cutover, are on Route 53 and Certificates.

Common errors and pitfalls

  • helm upgrade fails with "another operation (install/upgrade/rollback) is in progress": a previous run was cancelled and left the release pending-upgrade. Check helm history, then helm rollback to the last deployed revision.

  • The release hangs until the timeout while the hook Job shows ImagePullBackOff: the node role or the Pod Identity of the ServiceAccount cannot pull from ECR, or the tag does not exist because the build job failed silently.

  • The hook Job fails with "Error establishing a database connection": the Secret is missing a key, or the Job runs under a ServiceAccount without the NetworkPolicy or security group access that the web Pods have.

  • wp core update-db reports "WordPress database upgraded successfully" on every release although nothing changed: the command compares db_version to the code, so it only works when core changed; plugin upgrades are separate.

  • The HPA shows <unknown>/12 for the custom metric: the adapter rule does not match (wrong label names), the PodMonitor does not select the port name, or the exporter cannot reach the FPM status page because pm.status_path is not set.

  • The HPA scales to maxReplicas and stays there: a missing CPU request on a sidecar, or the metric is the pool total instead of the busy workers.

  • Replicas disagree after a release because the salts changed: the new image generated random keys; pass all eight from the Secret (see Containers, Docker and Kubernetes).

  • helm rollback returns the old image but the site stays broken: the schema is not compatible with the old code; restore from the pre-release snapshot.