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.
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.
{
"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.
#!/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
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: writeis what lets a job request the OIDC token; it is granted per job, and the workflow-wide default stayscontents: read. -
concurrencywithcancel-in-progress: falseserialises releases. Twohelm upgraderuns against one release collide with "another operation is in progress", and cancelling a run in the middle of a hook leaves the release inpending-upgrade. -
The image is tagged with the commit, never
latest, so thathelm historystates 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-maxrevisions, or a rollback points at a deleted tag. -
The
aws rds wait db-snapshot-availablestep 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-failureis the Helm 4 name of the Helm 3--atomicflag: 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.
{{- 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 }}
Source: Helm Docs: Chart Hooks
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.phpreads 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-wordpressand 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 usespre-installhooks in a separate template (thelumenpress-installJob), created after the Secret, andpre-upgradeis 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-succeededremoves the previous Job before creating the next, and the Job after it succeeds. A failed Job stays, sokubectl logs job/lumenpress-migratestill works after a failure. -
backoffLimit: 0andactiveDeadlineSecondsfail the release at once instead of retrying a half-applied schema change.wp core update-dbis idempotent, so a repeat after a fix is safe. -
wp core verify-checksumscompares 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 unlesswp plugin verify-checksums --allis added, which only works for plugins from the directory. -
The CronJob
lumenpress-croncan 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:
-
composer outdatedor a dependency bot (Dependabot or Renovate understandcomposer.json) proposes the new plugin version, WPackagist or a private repository serving it, as a pull request. -
CI builds the image, runs the tests of Testing and deploys it to
lumenpress-staging, wherewp core update-dbruns in the same Job. -
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-imageusing 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. |
|
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, |
The old code reads the new format; may fail silently. |
Source: Helm Docs: helm rollback
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_HOSTvalue 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(revision0is "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.
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:
volumes:
- name: maintenance
configMap:
name: lumenpress-maintenance
optional: true
#!/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.
[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:
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}
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:
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.
{{- 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 }}
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.
{{- 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.
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_logunset 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_formatwithescape=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 isphpfpm_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.
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 |
Automated backups with point-in-time recovery (retention set above), manual snapshots before releases, copies of snapshots to a second region through AWS Backup |
|
S3 bucket |
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 |
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 |
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 |
|
Valkey |
None: the object cache is disposable |
Starts empty; expect a slow first minutes |
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:
#!/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 upgradefails with "another operation (install/upgrade/rollback) is in progress": a previous run was cancelled and left the releasepending-upgrade. Checkhelm history, thenhelm rollbackto 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-dbreports "WordPress database upgraded successfully" on every release although nothing changed: the command comparesdb_versionto the code, so it only works when core changed; plugin upgrades are separate. -
The HPA shows
<unknown>/12for 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 becausepm.status_pathis not set. -
The HPA scales to
maxReplicasand 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 rollbackreturns the old image but the site stays broken: the schema is not compatible with the old code; restore from the pre-release snapshot.