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 session_tokens in the user meta table

The same: cookies and the database. No PHP sessions, no sticky load balancing

Cron

System cron calling wp-cron.php, or WP-Cron on page loads

One Kubernetes CronJob, one Job at a time, WP-Cron disabled on every web pod

Transients

wp_options rows, or the object cache when a drop-in exists

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_enabled is required for AUTH tokens, and it moves the endpoint to TLS: a plain redis:// 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-policy to allkeys-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_SCHEME set to tls. The client then connects with TLS to the ElastiCache endpoint. The plugin’s change log lists a WP_REDIS_SSL_CONTEXT constant for stream-context options such as peer verification, and describes WP_REDIS_SCHEME only briefly, so confirm the TLS behaviour of your plugin version with wp redis status after 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 to 127.0.0.1 and 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_TIMEOUT and WP_REDIS_READ_TIMEOUT of 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_GRACEFUL off. 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 alloptions array 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 with allkeys-lru are 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_KEY to NONCE_SALT is 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:

  1. Define DISABLE_WP_CRON as true in wp-config.php on every pod, so no page load triggers cron.

  2. 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.

flowchart LR CJ["CronJob lumenpress-cron\nschedule every 5 minutes\nconcurrencyPolicy: Forbid"] Job["Job and Pod\nwp cron event run --due-now\n(at most one at a time)"] W1["web pod 1\nDISABLE_WP_CRON true"] W2["web pod 2\nDISABLE_WP_CRON true"] W3["web pod N\nDISABLE_WP_CRON true"] DB[("RDS: cron option in wp_options")] Valkey[("Valkey: object cache")] CJ -- "creates a Job per schedule,\nskips it while one is running" --> Job Job -- "reads the due events,\nruns their hooks" --> DB Job --> Valkey W1 -. "never starts wp-cron.php" .-> DB W2 -. "never starts wp-cron.php" .-> DB W3 -. "never starts wp-cron.php" .-> DB

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; Replace kills 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 for kubectl logs, delete the rest.

  • No HOME problem. WP-CLI needs a writable home for its cache; the chart sets HOME to /tmp, which is an emptyDir on a read-only root file system.

  • Same image, same environment. The Job uses the web image and the same envFrom so it loads the same wp-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

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 same envFrom as 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. concurrencyPolicy is Allow, or a second scheduler exists: a plugin with its own system cron, a wp-cron.php hit by an uptime monitor with cron still enabled, or a leftover pod that has not picked up DISABLE_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 and WP_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 with kubeconform, the scripts with shellcheck.

  • 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 (timeZone since 1.27).