Cloud: Cache, Sessions and the Back Office in a Cluster

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

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

This page was written against PrestaShop 9.2.0 (the source of the 9.2.0 tag and the official prestashop/prestashop:9.2.0-fpm image with PHP 8.5.12), Amazon ElastiCache for Valkey (engine versions 8.2, 9.0 and 9.1) and for Memcached (1.6.22 is the newest version listed), Kubernetes 1.36 and 1.37 on EKS, nginx 1.29 (1.29.8 in the tests) and Helm 4.3, and checked on 2026-10-29. Every PrestaShop statement was read in the 9.2.0 source and every container fact on the official image; the ElastiCache facts come from the AWS documentation. Nothing was deployed in an AWS account, so no pod talked to a real ElastiCache endpoint, and the PHP extensions redis and memcached were not compiled here (see Version notes and what was not verified).

A PrestaShop shop on one server keeps a lot of state on that server’s disk: three compiled kernels, compiled templates, combined stylesheets, module caches, and in the back office the very files of the modules. On several pods the same shop has several disks, and a button that works on a single server, Clear cache, starts to lie. This page lists every cache and every piece of login state of PrestaShop 9.2.0, says which of them must become shared and which must stay per pod, and gives the configuration for the shop of the running example, Pocket Coffee on the cluster pocketcoffee-prod, built in Cloud: EKS Infrastructure and Helm Deployment. It also explains why changes made in the back office (modules, themes, updates) must not be done on a running cluster, and how to restrict who can reach the back office at all.

The single-server tuning of the same caches is on Performance and Caching and is not repeated; the architecture around this page is in Cloud: Reference Architecture, the writable paths and the shared volume in Cloud: Shared Media and Writable Paths, and the release sequence that replaces back-office changes in Cloud: Releases and Upgrades. The WordPress counterpart of this page, with a persistent object cache and no PHP sessions, is Object Cache, Sessions and Cron in a Cluster.

Clearing the cache in the back office empties the Symfony, Smarty and combined-asset files of the one pod that answered, while the other pod keeps its files and the database, Memcached and Valkey are shared

Every cache and every piece of login state

PrestaShop 9.2.0 has eleven things that behave like a cache or like a session. The column "Per pod" says what happens if nothing is done: each pod then has its own copy, built from its own requests.

What Where it lives Per pod? What to do in a cluster

Symfony container and routes (three kernels: admin, front, admin-api)

var/cache/prod/<app>/, built on demand or by cache:warmup

Yes

A per-pod emptyDir, filled by an init container with the pod’s own parameters (Building the cache per pod).

Smarty compiled templates and cached module output

var/cache/prod/smarty/{compile,cache}

Yes

Per pod; switch Multi-front optimizations on (Smarty: the multi-front switch).

Module-owned caches (for example the main menu JSON of ps_mainmenu)

Files under var/cache/prod/<module>/

Yes

Per pod; not covered by any flush marker. A restart is the clear (Building the cache per pod).

Class index

var/cache/prod/class_index.php

Yes

Per pod, rebuilt on the first request.

Downloaded module and Addons lists

config/xml/*.xml, written at run time

Yes

Per pod and disposable; see the writable paths page.

Combined and compressed CSS and JavaScript (CCC)

themes/<theme>/assets/cache/

Yes

Disable it, or share that one directory (Combined and compressed assets).

SQL cache of the Db class

APCu (per pod), Memcached or XCache; table ps_memcached_servers

APCu yes, Memcached no

Memcached on ElastiCache, or off (The SQL cache: Memcached or nothing).

Doctrine metadata, query and result cache

A Symfony cache pool: array, APCu or Memcached

Array and APCu yes

Leave on the default; read the caveat in The SQL cache: Memcached or nothing.

OPcache and realpath cache

The PHP process of each pod

Yes

Nothing to share; build the image so that files never change under a running pod.

PHP session (flash messages, CSRF tokens of the Symfony back office)

PHP’s session handler, files by default

Yes

Valkey (PHP sessions in Valkey).

Login state and cart of customers and employees

The encrypted PrestaShop-<hash> cookie plus rows in ps_employee_session and ps_customer_session

No (in the browser and the database)

Identical keys on every pod (The cookie and its three keys).

Two groups come out of this. The derived files are cheap to rebuild and can stay per pod if nothing ever expects them to change on all pods at once. The state (sessions, the cookie keys, the database) must be shared, and it is what makes a pod disposable. Everything else on this page is the consequence of that split.

The "Clear cache" button reaches one pod

The button Advanced Parameters > Performance > Clear cache calls clearCacheAction of PerformanceController, which runs a chain of six clearers (prestashop.core.cache.clearer.cache_clearer_chain in src/PrestaShopBundle/Resources/config/services/core/cache.yml):

  1. the Symfony cache of every kernel (SymfonyCacheClearer), run at the end of the request;

  2. the Smarty compiled and cached files (SmartyCacheClearer);

  3. the XML files in config/xml/ (XmlCacheClearer);

  4. the files of the theme in themes/<theme>/assets/cache/ and themes/<theme>/cache/, plus the two version counters PS_CCCJS_VERSION and PS_CCCCSS_VERSION in the database, each raised by one (MediaCacheClearer, Media::clearCache());

  5. the class index (ClassIndexCacheClearer);

  6. the Doctrine cache (DoctrineCacheClearer).

Items 1 to 3 and 5 are file operations on the disk of the pod that happens to answer the request. The load balancer chose that pod, not the operator. The other pods keep their old compiled container, their old compiled templates and their old module caches, and they go on serving them. Only the database change of item 4 is visible to all of them, and it is visible only through the file names (see Combined and compressed assets). The same is true of every other action that clears the cache as a side effect: saving a module’s configuration, installing a module, changing a currency or a language, and importing data.

The button does not touch sessions, so nobody is logged out by it, in a cluster or not.

Pocket Coffee settles this with three rules.

  1. Nobody clears the cache of a production cluster from the back office. The action needs the delete right on the Performance tab (is_granted('delete', request.get('_legacy_controller')) in clearCacheAction), so remove that right from every profile except the one or two SuperAdmin accounts, and tell those accounts not to use it. A SuperAdmin is not restricted by permissions, which is why the second line of defence, in Why the back office must not change code, matters.

  2. The cache of a pod is built when the pod starts, from that pod’s own environment, and thrown away when the pod ends. A new image is a new set of caches.

  3. "Clear the cache" in a runbook means a rolling restart: kubectl rollout restart deployment/pocketcoffee-web. Every pod starts with a freshly built cache, one after the other, with no downtime, because that is what a rolling update does. A content change that depends on a file cache (the main menu is the example the performance page documents) becomes visible on all pods at the end of that restart.

Building the cache per pod

The obvious plan is to run bin/console cache:warmup --env=prod while the image is built, so that pods start with a ready cache. It does not work, and the reason is in the configuration files. app/config/config.yml imports set_parameters.php, which runs while the Symfony container is compiled and calls $container->setParameter() for every key of app/config/parameters.php. doctrine.yml then refers to %database_host%, %database_name%, %database_user% and %database_password%. A compiled container contains the values of its parameters, so a cache warmed during docker build contains the database host that existed during docker build (a placeholder, or nothing), and the cached container is used as it is in production mode. This was derived from reading the 9.2.0 source; a running container was not available for the experiment (see Version notes and what was not verified), so confirm it once on your own image by warming with one value and reading the cached container for it.

The fix is to build the cache when the pod starts, with the pod’s own environment, in an init container of the same image that writes into the emptyDir mounted at var/cache. The Deployment of the web pods in Cloud: EKS Infrastructure and Helm Deployment does exactly this, and the admin Deployment in [admin-deployment] repeats it. The command is the one of the init container:

for app in admin front; do
  php bin/console cache:warmup --env=prod --app-id="$app"
done

--app-id is a global option of PrestaShop’s console: without it the command uses the admin kernel, and the kernels front and admin-api each have their own cache directory (getCacheDir() in app/AppKernel.php). A shop that uses the Admin API adds admin-api to the loop. The cost is a few seconds at the start of each pod and one writable path per pod, var/cache, sized in the chart as a 1Gi emptyDir; the Smarty templates are not warmed by this command and are compiled by the first requests that use them, so the readiness probe of the Deployment should load a real page, not only answer from nginx, before the pod joins the load balancer.

Smarty: the multi-front switch

PrestaShop already has a mechanism for several front servers with local caches, and it is a setting that is off after the installation: Advanced Parameters > Performance > Smarty > Multi-front optimizations (PS_SMARTY_LOCAL, default 0 in install-dev/data/xml/configuration.xml). When it is on, config/smarty.config.inc.php builds a SmartyCustom object instead of the plain Smarty. Its clearCompiledTemplate() and clearAllCache() no longer delete files; they write a timestamp into the table ps_smarty_last_flush, and fetch() and createTemplate() compare that timestamp with the time of the pod’s own last_flush file and clear the pod’s local copy when the database is newer. The cache of module output is tracked the same way in ps_smarty_lazy_cache. So a Smarty clear on pod A reaches pod B on its next request.

What it does not cover is everything that is not Smarty: the Symfony container, the module-owned files such as the main menu JSON, the class index and the combined assets. It costs one extra query per request for the flush marker. Pocket Coffee switches it on in the release Job, once, as a configuration value in the database:

php bin/console prestashop:config set PS_SMARTY_LOCAL --value=1 --env=prod

The official page advises against this switch for a single server, and the devdocs do not describe a cluster test of it. The code was read, not load-tested with several pods: switch it on in staging with two pods and check that a module that caches its output (the performance page lists six that do) shows a change on both.

The SQL cache: Memcached or nothing

The SQL cache of the Db class stores the results of SELECT queries and invalidates them by table. In 9.2.0 it has exactly four backends, the classes of classes/cache/: CacheApc, CacheMemcache (extension memcache), CacheMemcached (extension memcached) and CacheXcache. There is no Redis or Valkey backend in the core, which is why the Valkey group of this page serves sessions and not the SQL cache. For several pods the shared choice is CacheMemcached against an ElastiCache for Memcached cluster. APCu is faster but per pod, so a write on pod A does not invalidate the entries on pod B, and the performance page already warns about that.

Decide with a measurement. A hit on a network cache still costs a round trip to the cache node, and a PrestaShop home page runs hundreds of queries. On a fast RDS instance the round trip to Memcached can cost about as much as the query it saves, so the cache pays for expensive queries and not for cheap ones. The performance page measured the effect of APCu in memory (about 76 ms down to 39 ms on the warm home page); nobody measured Memcached over the network here. Test with the real shop, a realistic catalogue and one pod before and after, and leave the switch off if the median does not move. The chart of the Helm page exposes it as the value shop.cacheEnabled, which is false by default, so turning it on or off is a release, not a code change.

When it is on, three things have to be in place.

  1. The extension memcached in the PHP image, which the official image does not contain: php -m on prestashop/prestashop:9.2.0-fpm lists only Zend OPcache from this family, no redis, memcached or apcu. It is installed from PECL in the image build, with the development packages of libmemcached and zlib, and enabled with docker-php-ext-enable; check that the release you pick supports your PHP version. The image is the subject of Cloud: Production Container Image, which owns that Dockerfile.

  2. Two keys in parameters.php: ps_caching is CacheMemcached and ps_cache_enable is a real PHP boolean true (a string such as "1" is not enough: set_parameters.php tests true === $parameters['parameters']['ps_cache_enable'] before it selects a Symfony cache driver).

  3. The server list in the table ps_memcached_servers (columns ip, port, weight). The list is stored in the database, not in a file, and the back-office form that edits it (Performance > Caching > Add server) writes only that table, so the release Job fills it from the cluster’s endpoints:

-- Run once by the release Job, after the first install: the SQL cache servers of the shop.
-- The table is ps_memcached_servers (prefix + memcached_servers); "ip" accepts a host name.
DELETE FROM ps_memcached_servers;
INSERT INTO ps_memcached_servers (ip, port, weight) VALUES
  ('pocketcoffee-prod-memcached.abc123.0001.euw1.cache.amazonaws.com', 11211, 1),
  ('pocketcoffee-prod-memcached.abc123.0002.euw1.cache.amazonaws.com', 11211, 1);

The host names are placeholders for the node endpoints of the cluster pocketcoffee-prod-memcached. PrestaShop adds each row with Memcached::addServer() and does nothing else: it has no ElastiCache Auto Discovery. The AWS documentation says Auto Discovery needs a client library that supports it, so the rows list the node endpoints, one per node, and a change in the number of nodes means a change in the table. CacheMemcached sets the key prefix to the table prefix (ps_) and, when igbinary is available, the serializer to igbinary.

Four limits of that integration are not hidden by the managed service.

  • No TLS and no authentication. The class sets no SASL credentials and no TLS option. ElastiCache for Memcached supports in-transit encryption from engine 1.6.12, but nothing in CacheMemcached uses it, and whether the PECL memcached build in your image can do TLS was not tested. Keep the cluster in the private subnets with a security group that allows port 11211 only from the node security group, and remember that query results are in it.

  • No replication. A lost node loses its share of the cache; PrestaShop rebuilds the entries from the database. The cluster is a cache, never a place for state.

  • The Doctrine pool is another cache. With ps_cache_enable true and ps_caching CacheMemcached, set_parameters.php sets the Symfony cache pool to cache.adapter.memcached for the Doctrine metadata, query and result cache (config_prod.yml). No service of app/ or src/ sets a DSN for it, and Symfony’s default provider for that adapter is memcached://localhost, so each pod would look for a Memcached on itself. The effect (a disabled cache, or errors in the log) was not tested; look at the logs of the first pod after switching the SQL cache on, and be ready to leave the legacy cache off.

  • The back-office form writes parameters.php. Saving Performance > Caching sets ps_cache_enable and ps_caching through PhpParameters, which rewrites the file; see The parameters file must not be rewritten for why that must never happen on a pod.

PHP sessions in Valkey

What uses a PHP session

The WordPress pages of this site say there are no PHP sessions in WordPress. PrestaShop 9.2.0 is different: framework.session.handler_id: ~ in app/config/config.yml selects PHP’s native session handler for the Symfony kernels, so the pages of the back office that run under Symfony keep their flash messages and their CSRF tokens in the session, and the front controller starts a PHP session for the notifications that survive a redirect (session_start() in classes/controller/FrontController.php). Who is logged in is a different matter and does not use $_SESSION (see The cookie and its three keys). With the default handler (session.save_handler = files in the image’s php.ini, and session.gc_maxlifetime = 1440) the file is on the disk of one pod, so a form that was displayed by pod A and posted to pod B fails its CSRF check and a flash message disappears: the symptom is a back office that says "Invalid token" for no reason.

There are two ways out. Sticky routing (the load balancer sends the same browser to the same pod) hides the problem until that pod is replaced, which is every release; it is a stopgap for a small shop, not a design. A shared session store removes the problem, and Valkey on ElastiCache is the store of this page: the second group of the cluster, pocketcoffee-prod-valkey, separate from the Memcached cluster, because the two have opposite rules. A cache may lose and evict entries; sessions must not be evicted silently. The group of Cloud: EKS Infrastructure and Helm Deployment therefore uses maxmemory-policy noeviction and no snapshots, and has TLS and an AUTH token; size it with a margin and raise an alarm on its memory before it is full, because a full group refuses new sessions and nobody can open the back office. The creation of the group and its secret pocketcoffee/prod/valkey are in that page and in ElastiCache and other data services, and the WordPress page on the same service explains the choice of engine and of cluster mode.

The PHP-FPM pool

The official image has no redis extension (the same php -m check as above) and uses files. The image build installs phpredis from PECL (again: owned by the container-image page), and the pool file that the chart mounts into every pod sets the handler:

; zz-pocketcoffee.conf, mounted in /usr/local/etc/php-fpm.d/ after the image's own pool files.
[www]
clear_env = no

; PHP sessions in Valkey, with TLS and the AUTH token (cluster mode disabled, the primary endpoint).
; PHP-FPM expands ${NAME} from the environment of the pod when it starts the pool.
php_admin_value[session.save_handler] = redis
php_admin_value[session.save_path] = "tls://${VALKEY_HOST}:${VALKEY_PORT}?auth=${VALKEY_AUTH}&prefix=pocketcoffee:sess:&timeout=1.5&read_timeout=2.5&persistent=1"
php_admin_value[redis.session.locking_enabled] = 1
php_admin_value[redis.session.lock_expire] = 30
php_admin_value[redis.session.lock_wait_time] = 20000
php_admin_value[redis.session.lock_retries] = 500

What each line does, and what was checked:

  • tls:// and not rediss://. In phpredis, the scheme of an item of session.save_path is the transport of the PHP stream: the extension builds the address as scheme://host from the URL it parses (redis_session.c), so tls:// opens a TLS stream and a rediss:// URL, the scheme used by clients such as Predis or Symfony’s Redis adapter, is not a stream transport. phpredis documents tcp:// and unix:// for the session path; tls:// is the transport it documents for connect() and the session code passes it through, but a session over TLS to a real ElastiCache endpoint was not run here.

  • auth= with the token. auth takes a string (the token of an AUTH-token group, which is what the group of the Helm page uses) or an array of one or two elements (a user name and a password, written auth[]= twice, for a group with role-based access control, which this series does not use). A value that contains &, #, % or + has to be URL-encoded, so generate the secret from letters and digits only.

  • ${VALKEY_HOST} and its siblings come from the environment of the pod; the Secret pocketcoffee/prod/valkey supplies VALKEY_HOST, VALKEY_PORT and VALKEY_AUTH (the AUTH token), and the ExternalSecret of the chart puts them into the pod environment. The expansion of ${NAME} in a pool file was tested with the official php:8.4-fpm image, a pool file with these lines, environment variables set on the container and a FastCGI request for ini_get('session.save_path'): the pool returned the string with the values substituted. clear_env = no is already the setting of the image’s docker.conf; it is repeated so that the next reader sees that parameters.php, which reads getenv(), depends on it.

  • persistent=1, timeout and read_timeout. The README says the default connection timeout is very high (86400 seconds) and the default read timeout is 0, "meaning PHP can hang indefinitely" if Valkey stops answering, and recommends a low value in production. A hung session read holds a PHP-FPM worker, so the values above keep a failure to a couple of seconds.

  • prefix. Keys are prefix followed by the session id. The prefix identifies the shop if one group ever serves another one.

  • Locking. The back office fires several AJAX requests in parallel (the page loads its toolbar, the notifications and its widgets together), and without a lock two requests that both write the session lose one of the writes. phpredis supports session locking only for a single master with replicas, which is the cluster-mode-disabled group of this page; lock_expire (seconds), lock_wait_time (microseconds) and lock_retries bound the waiting. The cost is that one request of a browser waits for another. The values are a starting point and were not load-tested.

php_admin_value is used instead of php_value so that no script can change the handler at run time. The same lines work in a php.ini file of the image (session.save_handler = redis, and so on); there PHP expands ${NAME} from the environment too, which was tested as well.

What happens when Valkey is down was not tested. Expect the back office to fail at session_start(); the front office starts a session only to carry notifications, so test whether a catalogue page still renders with the group blocked (a security group rule is enough for the test) before you rely on it. Because the login is in the cookie and the database, a Valkey failover or a restart of the group does not log anybody out; it empties the CSRF tokens and the flash messages, and the next request creates them again.

The login state of customers and employees is not in the PHP session. It is in an encrypted cookie that PrestaShop’s Cookie class writes (PrestaShop- followed by a hash, one for the front office and one for the back office), plus two tables: ps_employee_session and ps_customer_session hold the session id and the token that the cookie carries. A request therefore carries its own login to whichever pod receives it, provided that every pod can read the cookie. Three values from app/config/parameters.php decide that:

Parameter Used for

new_cookie_key

_NEW_COOKIE_KEY_: the key of the encryption of the cookie (PhpEncryption, a Defuse key). The installer writes it. If parameters.php has no such key, config/bootstrap.php creates a random one in every PHP process, so that a pod without it cannot decrypt anything it did not write in the same request.

cookie_iv

_COOKIE_IV_: the salt of the checksum inside the cookie (hash('sha256', $this->_salt . $content) in Cookie::update()). When the checksum does not match, the class calls logout().

cookie_key

_COOKIE_KEY_: the static salt of the legacy password hash and of several tokens (the recover-cart link, the statistics tokens, the search-index token). New passwords are bcrypt; a customer whose password is still the old md5(cookie_key . password) hash can log in only with the same cookie_key.

So the three values must be identical on every pod, on every release, for the life of the shop. A pod that has other values sends the browser back to the login page when it handles a request (the checksum or the decryption fails) and the next pod that has the old values accepts it again, which is a symptom that looks random. A new random value at every release logs everybody out; a changed cookie_key also breaks the legacy password hashes. The same reasoning applies to secret (the Symfony secret) and to the API keys of the Admin API; Cloud: Autoscaling, Observability, Backups and Security covers rotation, which is a planned operation and never a side effect of a deployment.

How the three values get there depends on whether the shop already exists.

  • A shop that is migrated copies the three values (and secret, and the API keys) from the old server’s app/config/parameters.php into the Secrets Manager secret pocketcoffee/prod/prestashop; see Cloud: Migrating from a Single Server.

  • A shop that is created in the cluster generates them once, before the first pod starts. The first-install Job writes a parameters.php inside its own container, which is gone when the Job ends, so letting it create the values and not storing them means that every web pod starts with different ones. The script below creates the three values with the commands the installer uses (Defuse\Crypto\Key::createNewRandomKey() through PhpEncryption), from the shop’s own image, and writes them to a file readable only by the owner:

#!/usr/bin/env bash
# Creates the three cookie values of a NEW shop, once, and writes them to a JSON file that is merged into
# the Secrets Manager secret afterwards. Never run it against a shop that already has customers:
# the values of an existing shop are copied from its app/config/parameters.php instead.
set -euo pipefail
umask 077

image="${1:?usage: new-cookie-keys.sh <shop image> [output file]}"
out="${2:-cookie-keys.json}"

cookie_key="$(openssl rand -hex 32)"
cookie_iv="$(openssl rand -hex 16)"
# The same call the installer makes: a Defuse key in its ASCII-safe form, taken from the shop's own vendor folder.
new_cookie_key="$(docker run --rm "${image}" php -r \
  'require "/var/www/html/vendor/autoload.php"; echo Defuse\Crypto\Key::createNewRandomKey()->saveToAsciiSafeString();')"

jq -n --arg k "${cookie_key}" --arg iv "${cookie_iv}" --arg n "${new_cookie_key}" \
  '{PS_COOKIE_KEY: $k, PS_COOKIE_IV: $iv, PS_NEW_COOKIE_KEY: $n}' > "${out}"
echo "wrote ${out}: keys $(jq -r 'keys | join(", ")' "${out}")"

The script was run against the official image: it wrote the three keys with lengths 64, 32 and 136 characters (the Defuse key starts with def), and shellcheck passes. Merge the file into the secret with aws secretsmanager put-secret-value (reading the current JSON first, so that the other keys stay; the database passwords are not in this secret, they are in pocketcoffee/prod/prestashop-db-app and -db-migrate), delete the file, and never print its content in a CI log. The pod reads the values through the Secret created by the External Secrets Operator, and parameters.php takes them from the environment; the excerpt below shows the keys that matter for this page, the whole file is in the Helm page:

<?php
/**
 * The keys of app/config/parameters.php that this page is about. The rest of the file is built in
 * "Cloud: EKS Infrastructure and Helm Deployment"; every value is read from the environment of the pod.
 */
return [
    'parameters' => [
        // The same three values on every pod, in every release, for the whole life of the shop.
        'cookie_key' => getenv('PS_COOKIE_KEY'),
        'cookie_iv' => getenv('PS_COOKIE_IV'),
        'new_cookie_key' => getenv('PS_NEW_COOKIE_KEY'),

        // The SQL cache: off until a measurement shows it helps, then Memcached on ElastiCache.
        // The Symfony cache pool switches on a real boolean, not on the string "1".
        'ps_caching' => 'CacheMemcached',
        'ps_cache_enable' => filter_var(getenv('PS_CACHE_ENABLE') ?: '0', FILTER_VALIDATE_BOOLEAN),
    ],
];

Both listings pass php -l on PHP 8.5. After the first release with more than one pod, compare the three values of all pods; this small script prints one group per distinct fingerprint and never prints a value:

#!/usr/bin/env bash
# Prints a short fingerprint of the three cookie values of every web and admin pod, grouped.
# One group with the full number of pods is correct; two groups mean two pods disagree.
set -euo pipefail

namespace="${1:-pocketcoffee}"

kubectl -n "${namespace}" get pods -l app.kubernetes.io/name=pocketcoffee-prestashop -o name \
  | while read -r pod; do
      printf '%s ' "${pod}"
      # The single quotes are intended: the variables must expand inside the pod, not here.
      # shellcheck disable=SC2016
      kubectl -n "${namespace}" exec "${pod}" -c php-fpm -- sh -c \
        'printf "%s|%s|%s" "${PS_COOKIE_KEY}" "${PS_COOKIE_IV}" "${PS_NEW_COOKIE_KEY}" | sha256sum | cut -c1-12'
    done \
  | sort -k2 \
  | uniq -c -f1

It passes shellcheck; its logic was run with a stand-in for kubectl (two pods with one fingerprint, a third with another, printed as two groups). It was not run against a cluster.

Combined and compressed assets

CCC joins the stylesheets and the scripts of the theme into a few files. The mechanism (classes/assets/CccReducer.php) is this: the file name is theme- followed by six characters of sha1() of the list of source file paths and the value of PS_CCCCSS_VERSION (PS_CCCJS_VERSION for scripts), and the file is written to themes/<theme>/assets/cache/ the first time a request needs it, by the pod that serves that request. The HTML refers to that name. On one server that is harmless. On several pods:

  1. Pod A renders a page, writes theme-3f9a2c4.css on its disk and sends HTML that refers to it.

  2. The browser asks the load balancer for that file, and it lands on pod B, which has not rendered a page with that file list yet. Pod B answers 404, and the page is shown without styles, until pod B has rendered one such page itself.

The names are the same on every pod only if the source paths and the version counter are, which is true for pods of one image, so the files are equal and only their presence differs. The Clear cache button deletes the files of one pod and raises the counters, so after it the names change for all pods and the same window opens again.

There are three ways to close it, in order of simplicity:

Option How Cost

Switch CCC off (what Pocket Coffee does)

php bin/console prestashop:config set PS_CSS_THEME_CACHE --value=0 --env=prod and the same for PS_JS_THEME_CACHE, in the release Job. The theme’s files are then referenced one by one and exist in the image.

More requests per page. Over HTTP/2 or HTTP/3 and with the CDN in front of /themes/, the difference is small; measure it on the product page.

Share the directory

Mount themes/<theme>/assets/cache from the shared volume pocketcoffee-shared, so a file written by one pod is on all of them. The directory holds CSS and JavaScript, never PHP, which is the rule for what may live on EFS; the index.php of that directory has to be put back in the image or not needed, because the mount hides it.

One more shared path and one more writer to think about. The directory is part of the list in Cloud: Shared Media and Writable Paths.

Build it in the image

Not possible as such: the file names contain a version number that lives in the database, and a build has no database. Generating the files in the release Job on to the shared volume is the previous option with an extra step.

Complexity without a gain over the first two.

The media servers settings (PS_MEDIA_SERVER_1 to 3) interact: the front controller only applies a media server to assets when CCC is off for that file type, so a CDN host for the theme’s assets and CCC are alternatives, as the performance page says for a single server.

Why the back office must not change code

The back office is a tool for a shop that has one disk. It can upload a module as a ZIP, install it, upgrade it, switch it on and off, import a theme, enable a theme, and start the Update Assistant that replaces core files. Every one of those actions changes files on the pod that handles the request and rows in the shared database. On a cluster that combination is the worst possible one.

For a module installed from the back office, in the 9.2.0 code, the sequence is:

  1. ModuleController::importModuleAction receives the ZIP and ZipSourceHandler extracts it into the modules/ directory of that pod ($zip->extractTo($this->modulePath)).

  2. The module manager installs the module: it creates the module’s tables, registers its hooks and its configuration in the shared database, and clears the cache of that pod at the end of the request.

  3. Pod A now has the module’s files, a compiled container that knows its services and routes, and the database says "installed". The other pods have the database row and none of the rest: no files, an old container, no routes. When a hook of the module runs, Hook::exec() asks Module::getInstanceByName() for the module class and, when it returns nothing, skips that module without an error, so the module’s output disappears silently from the pages those pods render; its back-office pages answer 404 there.

  4. When pod A is replaced, which happens at the next release, an autoscaling event or a node drain, the module’s files vanish with it, because a container’s filesystem is the image’s. The database still says the module is installed, and now no pod has it.

The same pattern holds for upgrading a module (new files on one pod, a migrated schema everywhere), for enabling or disabling (the compiled container of only one pod learns about it), for a theme import (files in themes/ of one pod) and for the Update Assistant. And one action looks harmless and is not: saving Performance > Caching writes app/config/parameters.php, as the next section shows.

The remedy is not to repair the symptoms. It is to make the changes impossible on a running pod and to move them to the release, where they run once, from the command line, before the new pods start.

Four layers that stop it

  1. A read-only root filesystem. The containers of the chart set readOnlyRootFilesystem: true, and only the paths of Cloud: Shared Media and Writable Paths are writable (var/cache, var/logs, /tmp and the shared img/, upload/ and download/). With modules/, themes/ and app/config/ not writable, the extraction of a ZIP fails inside ZipSourceHandler (it raises "Cannot extract module in …​") and the back office shows an error. This is the cheap, general barrier, and it covers the Update Assistant too. It does not cover actions that only change the database.

  2. Permissions. The module manager checks the rights of the employee’s profile on the tab ADMINMODULESSF before each action (moduleAction in ModuleController): create for install, update for upgrade, reset, enable, disable and upload, delete for uninstall and delete. Give the profiles of shop staff the view right only. The SuperAdmin profile is special-cased in the code (Employee::isSuperAdmin() and several checks against _PS_ADMIN_PROFILE_) and is not stopped by rights, so the number of SuperAdmin employees is itself a control.

  3. Demo mode, for the actions that rights cannot reach. _PS_MODE_DEMO_ is a constant that the back office reads (ModuleController::moduleAction and importModuleAction return an error when it is true, and the attribute DemoRestricted redirects the restricted actions of 43 controller classes: themes, performance, import, employees, languages, currencies and more). It is false by default and can be set in config/defines_custom.inc.php, which config/config.inc.php includes before config/defines.inc.php; the official image also exposes it as PS_DEMO_MODE=1, which edits defines.inc.php with sed. It is a blunt instrument: the same attribute blocks viewing a customer’s page in the back office (CustomerController::viewAction) and sending a message from an order, which staff do every day, and it pre-fills the login form from the query string. Use it on the Deployment that runs the back office only if staff do not need those actions on a locked cluster, or use it on a second, read-mostly environment. The file shown after this list turns it on with an environment variable, so the same image can run with and without it.

  4. No Update Assistant on the cluster. Core upgrades are done by the release, with the Update Assistant’s command-line mode or a new image, as described in Cloud: Releases and Upgrades and in Testing, Upgrades and Add-ons. Do not ship it enabled in the production image; the read-only filesystem would stop its writes anyway.

<?php
/**
 * config/defines_custom.inc.php, copied into the image. config/config.inc.php includes this file before
 * config/defines.inc.php, whose define() calls are guarded by defined(), so a value set here wins.
 */

// Production mode: errors are logged, not displayed, and the profiler is off.
if (!defined('_PS_MODE_DEV_')) {
    define('_PS_MODE_DEV_', false);
}

// Demo mode: the back office refuses module install, upgrade, enable, disable and upload, theme changes,
// and a list of configuration pages. The release Job uses bin/console, which does not check it.
if (!defined('_PS_MODE_DEMO_')) {
    define('_PS_MODE_DEMO_', '1' === getenv('PS_BACK_OFFICE_LOCKED'));
}

The file passes php -l on PHP 8.5. The control flow (the include order, the defined() guards, the check in moduleAction) was read in the source; the effect in a running back office, and which of the 181 restricted actions an operator would miss, was not exercised.

The official image’s own defines_custom.inc.php already maps PS_DEV_MODE and PS_HOST_MODE to constants; an image of your own replaces it with the file above or extends it. _PS_HOST_MODE_ is not a switch for this problem: nothing in the 9.2.0 source under classes/, controllers/ or src/ reads it.

What the release does instead

The release applies the same changes from the command line, once, with credentials that only the Job has (the ps_migrate user), and the new pods start afterwards with fresh caches. The commands are those of bin/console prestashop:module (install, uninstall, enable, disable, upgrade, reset, configure), which do not check demo mode or rights; the files of the module are in the new image. The sequence, the ordering with the database migration and the rollback are in Cloud: Releases and Upgrades; the module commands are in CLI Commands and the handling of module upgrade scripts in Module Upgrades, Overrides and Decoration. What stays in the back office is data: products, prices, content, orders and the configuration of a module, which lives in the database and is seen by every pod.

The parameters file must not be rewritten

Several pages of the back office write app/config/parameters.php: the caching form (ps_cache_enable, ps_caching) uses PhpParameters::setProperty() and saveConfiguration(), which reads the file with require, takes the resulting array and writes it back with dumpFile('<?php return ' . var_export($configuration, true)). In a cluster the file is a small script that reads the environment, so the array that comes back contains the resolved values, among them the database password and the cookie keys, and the file that is written is a literal copy of them. On a writable filesystem the pod’s configuration is replaced by a snapshot of secrets, on one pod only. On the read-only filesystem of the chart the write fails with an error, which is the behaviour to want. So the back-office forms for the SQL cache and the other values that parameters.php owns are not used on the cluster; the values come from the release (PS_CACHE_ENABLE and the chart), and the Memcached server list is filled by the SQL of The SQL cache: Memcached or nothing, which writes a table and not the file.

Restricting who reaches the back office

Even with every change locked, the back office is the most valuable target of a shop: a stolen employee password gives access to customers' data. Reduce the exposure in layers, from the cheapest to the strongest.

The folder name is not a secret. The installer renames admin to a random name, and the official image renames it when its container starts, from PS_FOLDER_ADMIN, and the installation page lists the pitfalls of that variable. The image of this shop renames the folder at build to admin-pocketcoffee (the container-image page has the step), and the chart’s shop.adminDir carries the name. It appears in links, referer headers and the browser history, so use it for routing and not as protection. Two other entry points are separate decisions: the Webservice at /api and the Admin API at /admin-api (pages Webservice API and Admin API) are not behind the admin folder and need their own rules.

An allow-list at the edge. The first line is the AWS WAF web ACL attached to the load balancer, with an IP set of the office and VPN addresses and a rule that blocks any request whose path starts with /admin-pocketcoffee/ and whose source address is not in the set. The OpenTofu for it (aws_wafv2_ip_set, aws_wafv2_web_acl with an and_statement of a byte_match_statement on the URI path and a not_statement of an ip_set_reference_statement) is in Cloud: EKS Infrastructure and Helm Deployment and is not repeated here. If CloudFront is in front of the load balancer, the viewer address is not the source address any more: attach the ACL to CloudFront (scope CLOUDFRONT) or configure the forwarded-IP option of the rule, as the AWS documentation describes.

An allow-list in nginx. The second line protects a pod that is reached by another route and is cheap. A location for the admin folder carries the allow and deny rules; because the pass to PHP-FPM is another, regex location, which does not inherit those rules from a sibling, the PHP handler is nested inside the admin location. $remote_addr is the real client address only after the realip directives have run, with the load balancer’s range as the trusted proxy:

# Included in the server block of the nginx container. $remote_addr is the real client address
# because the realip directives of the server block already ran (set_real_ip_from, real_ip_header).
location /admin-pocketcoffee/ {
    allow 203.0.113.0/24;   # office egress (documentation range, a placeholder)
    allow 198.51.100.17;    # VPN
    deny  all;

    # Back-office pages are slow (imports, exports, large catalogues): longer limits than the shop.
    client_max_body_size 64m;

    # A regex location would not inherit the allow and deny rules, so the PHP handler is nested here.
    location ~ \.php(/|$) {
        fastcgi_read_timeout 300s;
        include /etc/nginx/php.conf;
    }

    try_files $uri $uri/ /admin-pocketcoffee/index.php$is_args$args;
}

The listing was run in nginx:1.29 with a server block that has set_real_ip_from, a php.conf that passes to a FastCGI address, and static files. A request from outside the allowed ranges answered 403 for a static file, for index.php and for index.php/sell/x, and a forged X-Forwarded-For: 203.0.113.5 from an untrusted address did not help. With allow all in place of deny all the static file answered 200 and the PHP paths went to the FastCGI upstream (a 502, because the test had no PHP-FPM behind it), which shows that the nested handler is reached. include /etc/nginx/php.conf stands for the fastcgi_* lines of the shop’s own location; the first version of this block used the modifier ^~ and was wrong: ^~ stops the search for regex locations, so .php files were served as text instead of being passed to PHP.

A separate Deployment and route for the back office. The allow-list says who may come. A separate Deployment says what a heavy back-office request costs the shop. An import of ten thousand products or a bulk image regeneration should not take the PHP-FPM workers that serve customers, and the back office needs longer timeouts (request_terminate_timeout of the pool, fastcgi_read_timeout of nginx and the idle timeout of the load balancer) that would be wrong for a storefront. The pods run the same image, the same Secret, the same shared volume and the same Valkey, so everything above holds for both; only the Deployment, its Service and one rule of the route differ. The listing is the template that adds them to the chart of the Helm page (a copy of the chart with only the five helpers pc.name, pc.labels, pc.selector, pc.image and pc.envFrom was used to validate it):

{{- if .Values.admin.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
    app.kubernetes.io/component: admin
spec:
  replicas: {{ .Values.admin.replicas }}
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ include "pc.name" . }}
      app.kubernetes.io/instance: {{ .Release.Name }}
      app.kubernetes.io/component: admin
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "pc.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
        app.kubernetes.io/component: admin
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      serviceAccountName: {{ .Release.Name }}-prestashop
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        fsGroup: 33
        seccompProfile:
          type: RuntimeDefault
      initContainers:
        # Same reason as for the web pods: the compiled container embeds this pod's parameters.
        - name: warmup
          image: {{ include "pc.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          command: ["sh", "-c"]
          args:
            - |
              set -e
              for app in admin front; do
                php bin/console cache:warmup --env=prod --app-id="$app"
              done
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.appUser "key" "DB_PASSWORD_APP") | nindent 12 }}
          volumeMounts:
            {{- include "pc.codeMounts" . | nindent 12 }}
      containers:
        - name: php-fpm
          image: {{ include "pc.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          ports:
            - { name: fastcgi, containerPort: 9000 }
          envFrom:
            {{- include "pc.envFrom" . | nindent 12 }}
          env:
            {{- include "pc.dbEnv" (dict "root" . "user" .Values.database.appUser "key" "DB_PASSWORD_APP") | nindent 12 }}
          resources:
            {{- toYaml .Values.admin.resources | nindent 12 }}
          volumeMounts:
            {{- include "pc.codeMounts" . | nindent 12 }}
            - name: config
              mountPath: /usr/local/etc/php-fpm.d/zz-pocketcoffee.conf
              subPath: php-fpm.conf
        - name: nginx
          image: {{ include "pc.nginxImage" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "pc.containerSecurity" . | nindent 12 }}
            runAsUser: 101
            runAsGroup: 101
          ports:
            - { name: http, containerPort: 8080 }
          readinessProbe:
            httpGet: { path: /readyz, port: http }
          volumeMounts:
            - { name: nginx-tmp, mountPath: /tmp }
            - name: config
              mountPath: /etc/nginx/conf.d/default.conf
              subPath: default.conf
            {{- range .Values.shared.mounts }}
            {{- if .nginx }}
            - { name: shared, mountPath: /var/www/html/{{ .path }}, subPath: {{ .subPath }}, readOnly: true }
            {{- end }}
            {{- end }}
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 128Mi }
      volumes:
        {{- include "pc.volumes" . | nindent 8 }}
        - { name: nginx-tmp, emptyDir: { sizeLimit: 128Mi } }
        - name: config
          configMap:
            name: {{ .Release.Name }}-config
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
    app.kubernetes.io/component: admin
spec:
  selector:
    app.kubernetes.io/name: {{ include "pc.name" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/component: admin
  ports:
    - { name: http, port: 80, targetPort: http }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Release.Name }}-admin
  labels:
    {{- include "pc.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.httpRoute.parentRef.name }}
      namespace: {{ .Values.httpRoute.parentRef.namespace }}
  hostnames:
    - {{ .Values.shop.domain }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /{{ .Values.shop.adminDir }}/
      backendRefs:
        - name: {{ .Release.Name }}-admin
          port: 80
{{- end }}

The values it needs are admin.enabled, admin.replicas, admin.resources (a request of 1 GiB and a limit of 2 GiB for the PHP-FPM container is a starting point, because imports and exports need more memory than a catalogue page), plus the keys and helpers the Helm page already defines (image.*, shop.domain, shop.adminDir, scratch, shared.*, database.appUser, httpRoute.parentRef, and the helpers pc.envFrom, pc.dbEnv, pc.codeMounts, pc.volumes, pc.containerSecurity and pc.nginxImage). Notes on it:

  • The labels are written out. The helper pc.selector ends with app.kubernetes.io/component: web, which is what the Service of the web pods selects. The admin pods carry component: admin so that the web Service never sends a shopper to the back office pods, and the other way round. Reusing pc.selector here would send shop traffic to the admin pods.

  • The route has the same host name as the shop. PrestaShop builds its back-office URLs from the shop domain in ps_shop_url, so a different host name for the back office is a multi-domain configuration of the shop and not just a routing choice. With the Gateway API the longer prefix /admin-pocketcoffee/ wins over the shop route’s /, whatever the order of the two objects. The back office also loads /js/, /themes/, /img/ and module assets from the same host; those requests go to the web pods, which carry the same files.

  • Timeouts. The chart’s nginx configuration and pool file are the web ones (a 60-second FastCGI read timeout and request_terminate_timeout). The longer limits of the back office need an admin variant of both files, and the idle timeout of the ALB is a property of the whole load balancer, not of a route: a long import is better run as a Job than as a request. Do not raise the web limits to fit the back office.

  • No autoscaling. Two replicas for availability; the back office is used by a few people.

  • The network. With a NetworkPolicy (see Network Policies) the admin pods can be given the same egress as the web pods and nothing more; a separate policy for the admin pods is where an extra restriction, such as "no egress to the Internet except the Addons API", would go. That policy was not written for this page.

Three quieter controls complete the list and need no code: employees with strong passwords and the least rights that do the job, the former employees removed (see Security), and the Admin API and Webservice keys limited to the resources they use.

Checks after the first rollout

Check How Expect

Sessions survive the loss of a pod

Log in to the back office, open a form, delete the pod that served it (kubectl delete pod), then submit the form.

The form is accepted and you are still logged in.

The login survives any pod

kubectl rollout restart deployment/pocketcoffee-web while you click through the back office.

No return to the login page.

The three cookie values agree

The fingerprint script above.

One group with every pod.

Valkey is used

In a pod, redis-cli --tls -h $VALKEY_HOST -a $VALKEY_AUTH --no-auth-warning --scan --pattern 'pocketcoffee:sess:*' from a debug container, or DBSIZE on the group.

Keys with the prefix appear after a login.

A module install is refused

Try to upload a ZIP in Module Manager as a non-SuperAdmin, then as a SuperAdmin on a pod with a read-only root filesystem.

A permission error, then an extraction error; nothing in modules/.

The admin allow-list works

curl -s -o /dev/null -w '%{http_code}' https://www.pocketcoffee.example/admin-pocketcoffee/ from outside the allowed ranges.

403 from the WAF or nginx, 200 or a redirect to the login from the office.

Styles after a release

Load the home page right after the rollout from several addresses with curl -I on its CSS files.

No 404 on theme files.

Common problems

Symptom Likely cause and fix

The back office says "Invalid token" or loses its notices

The session handler is still files: the pool file did not apply (the phpredis extension is missing, or the pool file is not mounted after the image’s own), or Valkey is not reachable.

Everybody is logged out at every release

The three cookie values change with the pods. Take them from the Secret, never from the installer inside the pod.

Customers cannot log in after a migration

cookie_key differs from the old server’s, and their password is a legacy md5 hash.

A page has no styles for a few seconds after a release, or after the cache was cleared

CCC files exist on one pod only. Switch CCC off or share the directory.

A module’s output disappears on some requests

The module was installed or upgraded through the back office on one pod. Remove the right, install it from the release, and restart.

A configuration form for the SQL cache or another parameters.php value fails with a file error

The filesystem is read-only, as it should be. Set the value in the chart.

The first requests after a start are slow

Smarty templates compile on first use. Make the readiness probe load a real page, and start pods before the load arrives.

The admin pods receive shop traffic

A Service selects only name and instance. Include component in every selector.

The admin allow-list blocks the office after a change of the egress address

The IP set was not updated. Keep the list in OpenTofu and deploy it with the release.

Version notes and what was not verified

Read in the source of PrestaShop 9.2.0: PerformanceController::clearCacheAction and the cache clearer chain; Media::clearCache() and CccReducer; SmartyCustom and config/smarty.config.inc.php; CacheMemcached, Cache::getInstance() and config/bootstrap.php; app/config/set_parameters.php, config.yml, doctrine.yml and config_prod.yml; Cookie and Hashing; ModuleController and ZipSourceHandler; DemoModeEnabledListener and the DemoRestricted attribute; PhpParameters; bin/console and the three kernels; Hook::exec().

Run:

  • php -l (PHP 8.5.12, php:8.5-cli) on the parameters.php excerpt and defines_custom.inc.php.

  • shellcheck on both shell scripts, the key script against the official image (the three values had the lengths above) and the fingerprint script with a stand-in for kubectl.

  • nginx -t and requests against a running nginx:1.29 for the admin location (403 for denied addresses on static and PHP paths, a forged header ignored, the PHP path reached when allowed).

  • The pool file with ${NAME} expansion, in the official php:8.4-fpm image through a FastCGI request, and the same expansion in a php.ini file in php:8.5-cli.

  • helm lint, helm template and kubeconform -strict (with the CRD schema of the Gateway API) on a copy of the chart that holds the admin template and the five helpers.

  • php -m, php --ini and the docker.conf of the FPM pool on prestashop/prestashop:9.2.0-fpm.

  • xmllint --noout on the figure.

Not verified:

  • Nothing ran against ElastiCache or a cluster. No pod talked to a Valkey or a Memcached endpoint; the TLS session path of phpredis, the AUTH token handling with an ElastiCache group, the behaviour of the shop when Valkey is unreachable, and the Memcached cluster with node endpoints are as documented and as read in code, not observed.

  • The PECL extensions redis and memcached were not compiled. The Docker host had no free disk space when the build was tried (the daemon reported no space in /var/cache/apt/archives), so the Dockerfile lines for them are described and not tested, and the PHP 8.5 compatibility of the two releases has to be checked on your image.

  • A running PrestaShop was not used for three conclusions: that a warmed Symfony cache contains the database host of the build, that two pods with different cookie values log a browser out in alternation, and that a module installed on one pod disappears from the others. Each follows from the code read above and should be reproduced once in staging with two pods.

  • The Doctrine cache pool with CacheMemcached and no Memcached on localhost, and a back-office form failing its CSRF check when two pods use files sessions (it follows from the Symfony defaults and was not reproduced).

  • The effect of demo mode on a daily workflow, and PS_SMARTY_LOCAL with several pods under load.

  • Any prices: ElastiCache nodes, the load balancer and the WAF are billed by the hour, the request and the gigabyte, and the official pricing pages list them; the cost drivers of this page are the Memcached and Valkey node sizes, the number of web ACL rules and requests, and the extra admin pods.