Object Cache, Sessions and Cron in a Cluster
|
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), PHP
8.4, WP-CLI 2.12.0, the Redis Object Cache plugin 3.0.0, Amazon ElastiCache for Valkey (engine versions 8.2, 9.0 and 9.1
are offered; the examples use 9.0) and Kubernetes 1.36 on Amazon EKS, and checked on 2026-10-29. Lumen Press runs the
production site www.lumenpress.example in the EKS cluster lumenpress-prod in eu-west-1.
When a site moves from one server to many identical pods, three kinds of state that used to hide inside the server have to move somewhere shared: cached objects, anything that looks like a login session, and the scheduler. This page treats each one and shows the configuration that the later pages assume. The architecture around them is on Cloud Reference Architecture, the image and chart that carry the configuration are on Deploying on EKS with Helm, and the managed Valkey itself is created in EKS Infrastructure with OpenTofu. The single-server versions of these topics are on Options, Transients and Object Cache, Performance and Caching and WP-Cron and Scheduled Tasks; the arguments for stateless nodes are on Scaling and High Availability.
What changes when there are many pods
| State | On one server | In the cluster |
|---|---|---|
Object cache |
A per-request PHP array, or a local Redis or APCu |
A shared Valkey replication group, reached over TLS, with one key prefix per site |
Login state |
Cookies plus |
The same: cookies and the database. No PHP sessions, no sticky load balancing |
Cron |
System cron calling |
One Kubernetes CronJob, one Job at a time, WP-Cron disabled on every web pod |
Transients |
|
The object cache: they live in memory and can disappear at any time |
For the managed-service mapping of ElastiCache to Memorystore (Google Cloud) and Azure Managed Redis, see Google Cloud, Azure and Alternatives to Kubernetes.
A persistent object cache on ElastiCache for Valkey
WordPress always has an object cache, and a persistent one stores the entries in a server shared by all requests. With
several pods that is not an optimisation but a correctness matter in one respect: a per-request cache is always correct
(every request starts empty), while a cache that is local to a pod is not, because an update handled by pod A leaves a
stale entry in pod B. A shared cache fixes that and also stops every new pod from starting cold. The drop-in mechanism
(wp-content/object-cache.php, wp_using_ext_object_cache()) is explained on
Performance and Caching.
Why Valkey and what to create
ElastiCache offers Valkey, the open-source fork of Redis under the Linux Foundation, as a managed service. It speaks the
Redis protocol, so the Redis Object Cache plugin and its bundled Predis client connect to it unchanged. The plugin’s own
documentation names Redis, not Valkey, so treat "works with Valkey" as an ecosystem fact to test on your own stack: the
connection, wp redis status and a cache hit counter are the checks (see Verifying the three pieces). Lumen Press creates:
-
A replication group, cluster mode disabled. One primary and two replicas in three Availability Zones, with automatic failover and Multi-AZ on. WordPress needs one endpoint, which the primary endpoint of the group provides and which follows a failover. Cluster mode (sharding) adds client complexity the object-cache drop-in does not need: an object cache of a few gigabytes fits on one node.
-
Encryption in transit and at rest.
transit_encryption_enabledis required for AUTH tokens, and it moves the endpoint to TLS: a plainredis://client cannot connect. Check that the client verifies the server certificate against the trust store of the PHP image, and do not disable verification to make a connection work. -
Authentication. ElastiCache supports a single AUTH token on the group, or role-based access control with users and user groups. Lumen Press uses an AUTH token stored in the Secrets Manager secret
lumenpress/prod/valkey; RBAC is the better choice when several applications share a group and each should see only its own keys. -
No persistence. A cache can be rebuilt, so the group has no snapshots, and the parameter group sets
maxmemory-policytoallkeys-lru: a full cache evicts the least recently used key instead of failing writes. -
Private subnets and a security group that allows port 6379 only from the cluster’s node security group.
The OpenTofu for all of this is the elasticache.tf file of
EKS Infrastructure with OpenTofu. Engine versions, the
Valkey 9 features and the extended-support schedule change over time; read the current list in the documentation
before pinning a version.
The general background (Valkey, ElastiCache, eviction policies) is on the Redis reference, and ElastiCache among the AWS caching services on Caching, Graph and Partner Databases on AWS.
The drop-in belongs in the image
wp redis enable copies the plugin’s drop-in to wp-content/object-cache.php. In an image whose code is read-only (the
setup of Deploying on EKS with Helm) that copy fails at run time, and
DISALLOW_FILE_MODS forbids plugin installation anyway. The build therefore downloads the plugin, pins its version and
places the drop-in itself. This is the relevant part of the Lumen Press Dockerfile:
FROM alpine:3.22 AS redis-cache
ARG REDIS_CACHE_VERSION=3.0.0
RUN set -eux; \
apk add --no-cache curl unzip; \
curl -fsSL -o /tmp/redis-cache.zip "https://downloads.wordpress.org/plugin/redis-cache.${REDIS_CACHE_VERSION}.zip"; \
unzip -q /tmp/redis-cache.zip -d /out; \
test -f /out/redis-cache/includes/object-cache.php
# ... in the PHP-FPM stage:
COPY --from=redis-cache /out/redis-cache /var/www/html/wp-content/plugins/redis-cache
RUN cp /var/www/html/wp-content/plugins/redis-cache/includes/object-cache.php /var/www/html/wp-content/object-cache.php
The plugin folder must ship as well as the drop-in. The drop-in loads the Predis client from the plugin folder, and a
drop-in without its plugin makes every request fail with "Error establishing a Redis connection" (the failure mode is
shown on Performance and Caching). The
plugin is activated once, in the database, with wp plugin activate redis-cache; that setting is an active_plugins
option and survives every deployment. An alternative to a downloaded archive is Composer with a package repository for
plugins, described on Composer-Based WordPress.
Configuration from the environment
The plugin reads constants, which wp-config.php defines from environment variables that come from the ConfigMap and the
Secret of the Helm chart. The excerpt below is the object-cache block of the complete wp-config.php on
Deploying on EKS with Helm
(that file was checked with php -l):
if ( null !== lumen_env( 'VALKEY_HOST' ) ) {
define( 'WP_REDIS_HOST', lumen_required( 'VALKEY_HOST' ) );
define( 'WP_REDIS_PORT', (int) lumen_env( 'VALKEY_PORT', '6379' ) );
define( 'WP_REDIS_SCHEME', lumen_env( 'VALKEY_SCHEME', 'tls' ) );
define( 'WP_REDIS_PASSWORD', lumen_required( 'VALKEY_AUTH' ) );
define( 'WP_REDIS_PREFIX', lumen_env( 'WP_REDIS_PREFIX', 'lumenpress:' ) );
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );
define( 'WP_REDIS_GRACEFUL', false );
}
What each constant does and why it has this value:
-
WP_REDIS_SCHEMEset totls. The client then connects with TLS to the ElastiCache endpoint. The plugin’s change log lists aWP_REDIS_SSL_CONTEXTconstant for stream-context options such as peer verification, and describesWP_REDIS_SCHEMEonly briefly, so confirm the TLS behaviour of your plugin version withwp redis statusafter the first deployment rather than trusting a configuration that merely loads. The same constants are present in every container that loads WordPress: the web pods, the cron Job, the migration Job and every interactive WP-CLI container. A WP-CLI container without them falls back to127.0.0.1and fails. -
WP_REDIS_PASSWORD. The AUTH token. An RBAC user is passed as an array of user name and password. -
WP_REDIS_PREFIX. The key prefix. See One prefix per site and environment. -
WP_REDIS_TIMEOUTandWP_REDIS_READ_TIMEOUTof one second. A cache that does not answer in a second is a failed cache; the default is longer, and a slow Valkey would hold PHP-FPM workers. -
WP_REDIS_GRACEFULoff. With the setting on, an unreachable Valkey makes WordPress fall back to the per-request cache and keep serving. The plugin’s documentation warns that this mode can leave stale entries, because writes during the outage never reach the server. Lumen Press keeps it off so that an outage is visible and the readiness probe removes pods that cannot reach the cache; a site that prefers availability over freshness can turn it on and flush the cache after an outage.
One prefix per site and environment
Every key the drop-in writes starts with WP_REDIS_PREFIX, then the blog prefix, then the group and the key, for
example lumenpress:wp:options:alloptions. Two WordPress installations that share a Valkey group and a prefix read each
other’s options and posts, a bug that is hard to trace. The same applies to staging and production if they ever share a
cache; the Lumen Press staging cluster has its own replication group and its own prefix as defence in depth. The plugin
can instead use a numbered database with WP_REDIS_DATABASE, but a prefix is simpler and works in every deployment mode.
A flush is the other prefix-related trap. wp cache flush, and the flush button of the plugin, empty the whole Valkey
database unless WP_REDIS_SELECTIVE_FLUSH is set. On a shared group one site’s deployment script can then empty another
site’s cache. Selective flush limits the flush to the prefix with a slower scan, so production setups that share a group
turn it on and use a separate group for anything they cannot afford to evict.
Sizing and operating the cache
-
Memory. Start from the size of the
alloptionsarray and the number of posts and terms that are read per request, and watch the CloudWatch metrics for evictions, memory use and the cache hit rate. Evictions on a cache withallkeys-lruare normal; a hit rate that falls with traffic means the cache is too small. -
Failover. During a failover the primary endpoint points to the promoted replica after a short pause; WordPress sees connection errors for a few seconds. The one-second timeouts keep the pods responsive, and the dropped entries are rebuilt from the database.
-
Maintenance and upgrades. ElastiCache applies engine patches and minor version changes in the maintenance window; a major or minor engine change is a change to the OpenTofu file with a rolling replacement of replicas.
-
Transients. With the drop-in, transients are stored in the cache with an expiry and not in
wp_options. They can be evicted before they expire, so code must always be able to regenerate them. The cache is never the place to keep data that has no other copy. -
Security. Keep the replication group in private subnets, restrict the security group to the node security group, require TLS and AUTH, and keep the token in Secrets Manager.
Sessions: WordPress has none
WordPress does not use PHP sessions. A logged-in user is identified by the wordpress_logged_in_* cookie, an HMAC
computed with the keys and salts from wp-config.php, plus a token stored in the session_tokens user meta row. Every
request from any pod validates the cookie and looks the token up in the database. This is why the cluster needs no sticky
sessions and no shared session store, and why two settings matter instead:
-
Identical keys and salts on every pod. Pods that disagree about a salt reject each other’s cookies, which looks like "logged out at random". The Secret that carries
AUTH_KEYtoNONCE_SALTis the single source. Rotating the salts logs every user out, so it is a deliberate operation, not a side effect of a redeploy. -
A reachable database. Because the session tokens are in the database, the Valkey cache is not part of the login path, and a Valkey outage does not log anyone out.
The exceptions are plugins that call session_start(), which is common in older shop, form and "visitor tracking"
plugins. WordPress core reports this in Site Health as a PHP sessions test; in a cluster such a plugin writes its sessions
to a file inside one pod, so the next request on another pod loses them. The options are to remove the plugin, to
configure it to use cookies or the database, or to move PHP’s session handler to a shared store. The last option works
with the phpredis extension (session.save_handler = redis and a session.save_path that includes the AUTH token and
TLS scheme) but is not available with the Predis client the plugin bundles; test it before relying on it, and prefer
fixing the plugin. WooCommerce keeps its customer sessions in its own table, which the shop pages cover
(Cloud: Database, HPOS and Sessions).
The cookie validation itself, the HTTPS detection that decides whether the secure cookie variant is used, and the salts are covered on Scaling and High Availability.
Cron: one scheduler, many web pods
WP-Cron runs when a request arrives: the first request after an event is due starts a loopback request to
wp-cron.php. On many pods that has three consequences. Every pod tries to run the due events, the load balancer decides
which pod does the work (so a slow job can occupy a web worker), and the loopback request goes through the public
address, the load balancer and the WAF, where it can fail. The fix is the one used on a single server, applied to the
cluster:
-
Define
DISABLE_WP_CRONastrueinwp-config.phpon every pod, so no page load triggers cron. -
Run the due events from a single scheduler: a Kubernetes CronJob that starts a Pod with WP-CLI.
The scheduler itself has to be a single instance. The Kubernetes controller does that when concurrencyPolicy is
Forbid: if the previous Job is still running when the next schedule fires, the new run is skipped instead of
overlapping. The pattern is the one shown below.
WordPress 6.9 changed how WP-Cron is triggered (see
WP-Cron and Scheduled Tasks); with DISABLE_WP_CRON set none of that
matters, because the only trigger is the CronJob. The CronJob of the Helm chart is:
apiVersion: batch/v1
kind: CronJob
metadata:
name: lumenpress-cron
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 120
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 280
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: wp-cron
image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress:3f9c2ab
command: ["wp", "--path=/var/www/html", "cron", "event", "run", "--due-now"]
envFrom:
- configMapRef:
name: lumenpress-config
- secretRef:
name: lumenpress-secrets
This is the chart’s template reduced to what matters here; the chart on
Deploying on EKS with Helm adds the pod labels, the security context
and the volumes, and its rendered output passed helm lint and kubeconform. The settings that matter:
-
concurrencyPolicy: Forbid. The scheduler guarantee.Allow(the default) starts a second Job while the first still runs, so two pods run the same event;Replacekills the running Job, which is worse for a long import. -
startingDeadlineSeconds: 120. If the controller was down or the cluster could not schedule the Job, a run that is more than two minutes late is skipped instead of running a burst of missed schedules. The next one catches up, because WP-Cron events are dated and run when due. -
activeDeadlineSeconds: 280. The Job is killed before the next schedule fires. A job that regularly needs longer than the interval belongs in a queue (Action Scheduler with its own worker, as the WooCommerce pages describe), not in WP-Cron. -
backoffLimit: 0. Do not retry a failed run immediately; the next schedule runs the events that are still due. -
History limits and
ttlSecondsAfterFinished. Keep a few finished Jobs forkubectl logs, delete the rest. -
No
HOMEproblem. WP-CLI needs a writable home for its cache; the chart setsHOMEto/tmp, which is anemptyDiron a read-only root file system. -
Same image, same environment. The Job uses the web image and the same
envFromso it loads the samewp-config.php, the same salts and the same object cache. A cron run with a different configuration is a classic source of "the job works from my laptop" tickets.
Choosing the interval
wp cron event run --due-now runs the events whose time has passed, and WordPress schedules most of its own events with
intervals of an hour or more. Five minutes is a reasonable default; a site that publishes scheduled posts to the minute
or runs a plugin that needs one-minute resolution uses * * * * * (the Kubernetes minimum). The cost of a short interval
is a Pod start each time, about as long as the image pull from the node cache plus the WordPress bootstrap, which is
a second or two. Because only one Job can run, a one-minute schedule with a five-minute job simply skips four runs.
Multisite
Each site of a Multisite network has its own cron option, so wp cron event run --due-now runs the events of one site:
the one whose URL WP-CLI is given. WP-CLI 2.12.0 has no network-wide option for this command (the help of
wp cron event run lists only the hook and --due-now), so the loop over sites is in the Job. The Job below runs the
sites one after another, keeps going when one of them fails and reports the failure in its exit code:
apiVersion: batch/v1
kind: CronJob
metadata:
name: lumenpress-cron
namespace: lumenpress
spec:
schedule: "*/5 * * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
startingDeadlineSeconds: 120
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 280
template:
spec:
restartPolicy: Never
containers:
- name: wp-cron
image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress:3f9c2ab
command:
- /bin/sh
- -ec
- |
# One Job runs every site of the network, one after the other.
# A failing site does not stop the others; the exit code reports it.
status=0
for url in $(wp --path=/var/www/html site list --field=url); do
wp --path=/var/www/html --url="$url" cron event run --due-now --quiet || status=1
done
exit "$status"
envFrom:
- configMapRef:
name: lumenpress-config
- secretRef:
name: lumenpress-secrets
Source: WP-CLI Commands: wp site list
The manifest passed kubeconform -strict, and the shell body passed shellcheck. wp site list exists only on
Multisite; on a single site it fails, so a shared chart keeps the two variants behind a value. A network with hundreds
of sites outgrows one sequential Job within the interval. Then the loop is split: several CronJobs each handle a
subset of the URLs, or an indexed Kubernetes Job runs in parallel. The scheduler guarantee stays per CronJob, so
each CronJob keeps Forbid, and the sets must not overlap. The object-cache prefix of a network is the same for all
sites, because the drop-in builds the blog prefix itself; the Multisite constants and domain mapping are on
Multisite Networks.
Verifying the three pieces
The following script checks the scheduler. It uses only kubectl and WP-CLI, and was checked with shellcheck:
#!/usr/bin/env bash
# Checks the scheduler: one CronJob, no overlapping Jobs, WP-Cron off on every web pod.
set -euo pipefail
ns=lumenpress
kubectl -n "$ns" get cronjob lumenpress-cron \
-o jsonpath='{.spec.concurrencyPolicy}{" last="}{.status.lastScheduleTime}{" last-ok="}{.status.lastSuccessfulTime}{"\n"}'
kubectl -n "$ns" get jobs -l app.kubernetes.io/component=cron --sort-by=.metadata.creationTimestamp
for pod in $(kubectl -n "$ns" get pods -l app.kubernetes.io/component=web -o name); do
printf '%s: ' "$pod"
kubectl -n "$ns" exec "$pod" -c php-fpm -- wp --path=/var/www/html eval \
'echo DISABLE_WP_CRON ? "cron disabled" : "WP-CRON ENABLED", PHP_EOL;'
done
For the object cache, run these from a pod (or a one-off Job with the same environment):
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html cache type
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html redis status
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html cron event list --fields=hook,next_run_relative
wp cache type is core WP-CLI and prints Redis with the drop-in and Default without it. wp redis status is a
command of the plugin and shows the client, the connection status and the drop-in state. A healthy scheduler shows the
CronJob with last-ok less than one interval old, finished Jobs rather than a growing pile of running ones, and every
web pod reporting "cron disabled". Failure signs: a pod reporting "WP-CRON ENABLED" means a pod runs an image or
ConfigMap from before the change; two Jobs with overlapping times mean concurrencyPolicy is not Forbid; events
whose next_run_relative keeps growing negative mean the CronJob is not running or fails (read its logs with
kubectl logs job/<name>).
Common problems
-
Users are logged out at random. The salts differ between pods or were regenerated, or a plugin writes PHP sessions to a pod-local file. Compare the eight values by checking the Secret, and look for
session_start()in plugins. -
Every page returns 500 after enabling the cache. The drop-in is present but the plugin folder is not, or the Valkey endpoint is unreachable and graceful mode is off. Check the security group, TLS, the AUTH token and the readiness probe output.
-
A WP-CLI command fails with "Connection refused" to 127.0.0.1. The container lacks
VALKEY_HOST; the cron and migration Jobs need the sameenvFromas the web pods. -
Scheduled posts publish late. The interval is five minutes, the CronJob is failing, or the node could not pull the image in time. Read the Job logs and the events of the CronJob.
-
A job runs twice.
concurrencyPolicyisAllow, or a second scheduler exists: a plugin with its own system cron, awp-cron.phphit by an uptime monitor with cron still enabled, or a leftover pod that has not picked upDISABLE_WP_CRON. -
The cache of another site is empty after a flush. The sites share a Valkey group and a flush call used
FLUSHDB; set a prefix per site andWP_REDIS_SELECTIVE_FLUSH, or give each site its own group. -
Memory climbs and then evictions spike. The cache is too small for the working set, or a plugin stores large values in transients. Raise the node size or reduce what is cached, and exclude noisy groups with the plugin’s ignored-groups setting.
Version notes and what was not verified
-
The drop-in is the Redis Object Cache plugin 3.0.0; its documentation names Redis, not Valkey, as the server and describes TLS only briefly. The examples follow the plugin’s constants, but the connection to ElastiCache for Valkey over TLS was not run against a live replication group while writing this page (there is no AWS account in the documentation environment). The checks above are the ones to run first.
-
The Dockerfile excerpt, the CronJob manifests and the scripts were validated locally: the plugin download and the drop-in copy in a real
docker build, the manifests withkubeconform, the scripts withshellcheck. -
ElastiCache engine versions move quickly; the current list is in the user guide, and an engine change is an OpenTofu edit.
-
Kubernetes 1.37 reached EKS on 2026-10-01; CronJob behaviour described here is stable since Kubernetes 1.21 (
timeZonesince 1.27).