Containers, Docker and Kubernetes
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production) in the Docker Official Image wordpress:7.1.3-php8.4-apache (PHP 8.4.27, Apache 2.4.69),
WP-CLI 2.12.0, mariadb:11.8, Node.js 24.21.0 with
@wordpress/scripts 37.0.0 and Composer 2.10.3. WordPress needs PHP 7.4 at least and PHP 8.3 or later is recommended.
The Kubernetes manifests were validated against the API schemas of Kubernetes 1.35, 1.36 and 1.37. It was checked on
2026-10-28.
The subject is production: an image that contains the Lumen Press code, configuration from the environment and from secrets, and containers that can be replaced at any moment. Development with bind-mounted code is on Local Development Environments, several nodes behind a load balancer on Scaling and High Availability, and Docker itself in the Docker section.
The official wordpress image
The wordpress repository on Docker Hub is a Docker Official Image maintained by the Docker community
(docker-library/wordpress), not by the WordPress project. There is no separate wordpress-cli repository: WP-CLI is a
set of tags of the same repository.
| Variant | Example tag | Contents |
|---|---|---|
|
|
Debian, Apache with |
|
|
Debian, PHP-FPM on port 9000. Needs a web server in front (nginx) that can read the static files. |
|
|
Alpine, PHP-FPM. Smaller; the |
|
|
Alpine, PHP and the WP-CLI Phar, no WordPress. Default user UID 82. |
The tag scheme is <wordpress>-php<x.y>-<variant>, and each part can be shortened (7.1-php8.4-apache,
7-php8.4-apache, php8.4-apache, latest). On 2026-10-28 the 7.1.3 tags existed for PHP 8.2 to 8.5. Short tags move,
and a rebuild for a PHP or Debian fix keeps the full tag but changes the digest, so production names the full tag
(pinned by digest when reproducibility matters) and rebuilds on a schedule. WordPress 7.1.4, a security-only release,
came out on 2026-10-28; production moves to 7.1.4-php8.4-apache once the image has it (no 7.1.4 tag existed on the
check date). Everything on this page was built and run with 7.1.3-php8.4-apache.
What the entrypoint does
docker-entrypoint.sh runs before Apache or PHP-FPM, in the working directory /var/www/html:
-
If neither
index.phpnorwp-includes/version.phpis there, it copies WordPress from/usr/src/wordpress, skipping plugins and themes that already exist in the target. -
If
wp-config.phpis missing and anyWORDPRESS_*variable is set, it writes one fromwp-config-docker.php, replacing each "put your unique phrase here" with a random SHA-1.
The image also declares VOLUME /var/www/html: WordPress is installed into a volume on first start and updates
itself there, like a traditional host, which is the opposite of what containers expect:
-
A new image tag does not update a site whose volume already holds WordPress, because the copy only runs when
index.phpis absent. Core is updated bywp core updateor by recreating the volume. -
Compose keeps a service’s anonymous volumes when it recreates the container, so even without a named volume a rebuilt image can keep serving the previous container’s files (
docker compose up --renew-anon-volumesdiscards them). -
Keys and salts are random per container when no variable sets them, so a login cookie from one replica is rejected by another (see Sessions).
The image documentation describes the alternative used below: serve WordPress from /usr/src/wordpress with the code
baked in, run it read-only, mount only wp-content/uploads, and update by deploying a new image.
Configuration through environment variables
wp-config-docker.php reads every setting through a helper, getenv_docker( $name, $default ), which returns the
contents of the file named by <name>_FILE if that variable is set, else the variable itself, else the default.
| Variable | Default | Effect |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
random per generated file |
The eight keys and salts |
|
empty |
|
|
empty |
PHP code passed to |
Three behaviours of that file cause most surprises:
-
Evaluated on every request, not copied.
wp-config.phpcallsgetenv_docker()each time it is loaded, andWORDPRESS_CONFIG_EXTRAis passed toeval()at run time. The web server, the cron runner and each WP-CLI container need the same variables; a WP-CLI container withoutWORDPRESS_CONFIG_EXTRAsilently lacks its constants (the Redis case is on Performance and Caching). A PHP syntax error in the variable breaks every request. -
Compose interpolates
$. Compose replaces$nameand${name}incompose.yamlbefore the container sees the value, so$lumen = getenv( 'LUMEN_X' );inWORDPRESS_CONFIG_EXTRAreaches PHP as= getenv( 'LUMEN_X' );(with a warning that the variable is not set) and every request fails with a parse error. PHP variables are written$$lumen. -
X-Forwarded-Protois trusted. The file sets$_SERVER['HTTPS'] = 'on'when that header containshttps, so WordPress works behind a TLS-terminating proxy. Any client reaching the container directly can send it too, so the port is published only to the proxy, which overwrites the header (see HTTPS detection behind a load balancer).
WP_ENVIRONMENT_TYPE needs no WORDPRESS_CONFIG_EXTRA: wp_get_environment_type() reads the environment variable
itself (a constant of that name wins). When wp-config.php is the unmodified wp-config-docker.php, as in the image
below, and no key variable is set, the keys keep the literal "put your unique phrase here"; wp_salt() rejects it and
stores generated salts as site options (auth_key, auth_salt and so on, seen with wp option list). Replicas then
agree, but the secrets travel with every database dump, so Lumen Press passes all eight from its secret store.
Docker secrets and _FILE variables
A secret in Compose or Kubernetes is a file mounted into the container, usually under /run/secrets/. Appending
_FILE to a variable name makes getenv_docker() read that file, without trailing line breaks:
WORDPRESS_DB_PASSWORD_FILE=/run/secrets/db_password. The documentation lists the database settings, the table prefix,
the keys and salts and WORDPRESS_DEBUG, but the helper is generic: a test container also evaluated a define() read
through WORDPRESS_CONFIG_EXTRA_FILE. The mariadb image reads MARIADB_PASSWORD_FILE, so both share one secret.
File secrets do not show in docker inspect, but they become PHP constants: phpinfo() stays off in production.
A Lumen Press production image
Lumen Press builds one image per release from a repository that holds the Dockerfile, docker/healthz.php,
docker/zz-lumen.ini, plugins/lumen-catalog/ and themes/lumen/. The plugin is the one of
Plugin Fundamentals and
Block Development Environment: Composer-autoloaded classes and
blocks built with wp-scripts build --blocks-manifest --experimental-modules. vendor/ and build/ are not
committed; the image build produces both.
# syntax=docker/dockerfile:1
# Stage 1: build the blocks of lumen-catalog with @wordpress/scripts.
FROM node:24.21.0-alpine3.24 AS blocks
WORKDIR /build
COPY plugins/lumen-catalog/package.json plugins/lumen-catalog/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY plugins/lumen-catalog/src/blocks ./src/blocks
RUN npm run build
# Stage 2: production Composer dependencies and an authoritative class map.
FROM composer:2.10.3 AS vendor
WORKDIR /build
COPY plugins/lumen-catalog/composer.json plugins/lumen-catalog/composer.lock ./
RUN composer install --no-dev --no-autoloader --no-interaction --no-progress
COPY plugins/lumen-catalog/src ./src
RUN rm -rf src/blocks && composer dump-autoload --no-dev --classmap-authoritative
# Stage 3: the runtime image, with WordPress served from /usr/src/wordpress.
FROM wordpress:7.1.3-php8.4-apache
WORKDIR /usr/src/wordpress
RUN set -eux; \
find /etc/apache2 -name '*.conf' -type f -exec sed -ri \
-e "s!/var/www/html!$PWD!g" -e "s!Directory /var/www/!Directory $PWD!g" '{}' +; \
sed -i "/That's all, stop editing/i define( 'DISALLOW_FILE_MODS', true );" wp-config-docker.php; \
cp -s wp-config-docker.php wp-config.php; \
rm -rf wp-content/plugins/akismet wp-content/plugins/hello.php; \
mkdir -p wp-content/uploads; \
chown -R root:root .; \
chown www-data:www-data wp-content/uploads
COPY --from=wordpress:cli-2.12.0-php8.4 /usr/local/bin/wp /usr/local/bin/wp
COPY docker/zz-lumen.ini $PHP_INI_DIR/conf.d/
COPY docker/healthz.php ./healthz.php
COPY plugins/lumen-catalog/lumen-catalog.php plugins/lumen-catalog/uninstall.php \
plugins/lumen-catalog/readme.txt wp-content/plugins/lumen-catalog/
COPY plugins/lumen-catalog/languages wp-content/plugins/lumen-catalog/languages
COPY --from=vendor /build/src wp-content/plugins/lumen-catalog/src
COPY --from=vendor /build/vendor wp-content/plugins/lumen-catalog/vendor
COPY --from=blocks /build/build wp-content/plugins/lumen-catalog/build
COPY themes/lumen wp-content/themes/lumen
VOLUME /usr/src/wordpress/wp-content/uploads
ENV WP_ENVIRONMENT_TYPE=production
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS -o /dev/null http://127.0.0.1/healthz.php || exit 1
How the stages fit together:
-
blocksrunsnpm ciand thebuildscript; copying the manifests before the sources keeps thenpm cilayer cached while only block code changes. Node.js andnode_modulesnever reach the final image. -
vendorinstalls production dependencies (no WPCS), drops the block sources and writes an authoritative class map, suited to fixed code. The runtime stage copiessrc/from this stage, subfolders such assrc/Rest/andsrc/Cli/included, so the shipped classes are exactly the ones in the map. With--classmap-authoritativethe autoloader never falls back to the file system: a class file missing from the image is a fatal "Class not found". -
The runtime stage follows the documented static-image recipe:
find ... sedpoints Apache at/usr/src/wordpressandcp -slinkswp-config.phptowp-config-docker.php, so the entrypoint neither copies WordPress nor writes a configuration file. -
DISALLOW_FILE_MODSgoes before "That’s all, stop editing". A firstsedmatching only "stop editing" inserted it twice (a comment above contains the words), and every request logged "Constant DISALLOW_FILE_MODS already defined". -
chown -R root:rootkeeps code unwritable bywww-dataeven without a read-only file system, at a cost: that layer rewrites all of WordPress (110 MB; 919 MB image against 802 MB base). Onlywp-content/uploadsbelongs towww-data. -
WP-CLI is copied, pinned, from the
cliimage. Akismet and Hello Dolly are removed because nothing could update them.
Source: Docker Docs: Multi-stage builds
docker/zz-lumen.ini sets opcache.validate_timestamps = 0, opcache.memory_consumption = 192, 32M for
upload_max_filesize and post_max_size, memory_limit = 256M and expose_php = Off. Not validating timestamps is
right for immutable code and wrong where code changes in place. .dockerignore excludes **/node_modules,
**/vendor, **/build, .git, *.env and secrets/.
Immutable code and DISALLOW_FILE_MODS
With DISALLOW_FILE_MODS WordPress does not install, update or delete plugins, themes, translations or core, removes
the file editors and disables background updates (full table in
DISALLOW_FILE_EDIT and DISALLOW_FILE_MODS).
In the built image, current_user_can() returned false for install_plugins, update_core and
edit_plugins when run with wp eval ... --user=lp_admin.
Updates therefore belong to the release process: a WordPress security release is a new FROM tag and a rebuild,
third-party plugins are added in the build (see
Composer-Based WordPress), and translations are copied into wp-content/languages because WordPress cannot download language packs.
Update checks still run and the dashboard still lists available updates, a useful signal for the next build. The
pipeline is on Deployment and CI/CD.
Persistent uploads and other writable paths
www-data owns wp-content/uploads, which the image declares as a volume. A new named volume copies that ownership
(UID 33); a bind mount or network share keeps its own owner and must be made writable for UID 33 first. Fonts from the
Font Library (6.5+) live in uploads/fonts and persist with it. Everything else is read-only: plugins that write a page
cache to wp-content/cache or files into their own folder fail, and WP_DEBUG_LOG points at /dev/stderr or /tmp.
Health checks
docker/healthz.php answers without loading WordPress:
<?php
// Liveness: Apache and PHP answer. WordPress and the database are not loaded.
header( 'Content-Type: text/plain' );
header( 'Cache-Control: no-store' );
echo "ok\n";
With MariaDB stopped, /wp-login.php returned 500 ("Error establishing a database connection") while /healthz.php
kept returning 200. A WordPress page is the right signal for "can this replica serve traffic" (readiness) and the wrong
one for "restart this container" (HEALTHCHECK, liveness): a database outage would restart every container at once. The
apache images include curl; an fpm container has no HTTP listener, so its check speaks FastCGI or tests the web
server in front.
Running the image with Compose on one host
On a single server the image runs under Compose. The database password and the eight keys and salts are Docker secrets:
files under secrets/, generated on the server (openssl rand -base64 48 per key) and never committed.
name: lumen-prod
x-wordpress: &wordpress
image: lumen-press:0.1.0
read_only: true
tmpfs: [/tmp, /run]
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: lumen
WORDPRESS_DB_USER: lumen
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
WORDPRESS_TABLE_PREFIX: lp_
WORDPRESS_AUTH_KEY_FILE: /run/secrets/wp_auth_key
WORDPRESS_SECURE_AUTH_KEY_FILE: /run/secrets/wp_secure_auth_key
WORDPRESS_LOGGED_IN_KEY_FILE: /run/secrets/wp_logged_in_key
WORDPRESS_NONCE_KEY_FILE: /run/secrets/wp_nonce_key
WORDPRESS_AUTH_SALT_FILE: /run/secrets/wp_auth_salt
WORDPRESS_SECURE_AUTH_SALT_FILE: /run/secrets/wp_secure_auth_salt
WORDPRESS_LOGGED_IN_SALT_FILE: /run/secrets/wp_logged_in_salt
WORDPRESS_NONCE_SALT_FILE: /run/secrets/wp_nonce_salt
WP_ENVIRONMENT_TYPE: production
# Evaluated by wp-config.php on every request, so every service needs it.
WORDPRESS_CONFIG_EXTRA: |
define( 'WP_HOME', 'https://lumenpress.example' );
define( 'WP_SITEURL', 'https://lumenpress.example' );
define( 'DISABLE_WP_CRON', true );
secrets: [db_password, wp_auth_key, wp_secure_auth_key, wp_logged_in_key, wp_nonce_key,
wp_auth_salt, wp_secure_auth_salt, wp_logged_in_salt, wp_nonce_salt]
volumes:
- uploads:/usr/src/wordpress/wp-content/uploads
depends_on:
db:
condition: service_healthy
services:
db:
image: mariadb:11.8
environment:
MARIADB_DATABASE: lumen
MARIADB_USER: lumen
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_RANDOM_ROOT_PASSWORD: "1"
secrets: [db_password]
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
retries: 10
restart: unless-stopped
web:
<<: *wordpress
ports:
- "127.0.0.1:8080:80" # the TLS-terminating proxy runs on the host
restart: unless-stopped
cron:
<<: *wordpress
user: "33:33"
entrypoint: ["/bin/sh", "-c"]
command:
- while true; do wp cron event run --due-now --quiet; sleep 60; done
healthcheck:
disable: true
restart: unless-stopped
cli:
<<: *wordpress
user: "33:33"
entrypoint: ["wp"]
profiles: ["tools"]
volumes:
db-data:
uploads:
secrets:
db_password: { file: ./secrets/db_password.txt }
wp_auth_key: { file: ./secrets/wp_auth_key.txt }
wp_secure_auth_key: { file: ./secrets/wp_secure_auth_key.txt }
wp_logged_in_key: { file: ./secrets/wp_logged_in_key.txt }
wp_nonce_key: { file: ./secrets/wp_nonce_key.txt }
wp_auth_salt: { file: ./secrets/wp_auth_salt.txt }
wp_secure_auth_salt: { file: ./secrets/wp_secure_auth_salt.txt }
wp_logged_in_salt: { file: ./secrets/wp_logged_in_salt.txt }
wp_nonce_salt: { file: ./secrets/wp_nonce_salt.txt }
-
One environment. The
x-wordpressblock and the<<merge key giveweb,cronandclithe same variables, secrets and volume.read_onlywithtmpfsfor/run(Apache’s PID file) and/tmpleaves uploads as the only persistent writable path. -
File secrets outside Swarm are bind mounts with the host file’s owner and mode; UID 33 must read them (mode 644 here).
-
Cron.
DISABLE_WP_CRONstops page loads from spawning WP-Cron; the onecronservice runs due events every minute (see Cron on one node only).
WP-CLI as a one-off container
Administrative commands run in a throwaway container of the same image, so they see the same code, PHP version and configuration as the web server:
docker compose up -d
docker compose run --rm cli core install --url=https://lumenpress.example --title="Lumen Press" \
--admin_user=lp_admin --admin_email=admin@lumenpress.example --skip-email
docker compose run --rm cli plugin activate lumen-catalog
docker compose run --rm cli eval 'echo AUTH_KEY === "put your unique phrase here" ? "default" : "from-secret",
" ", wp_get_environment_type(), PHP_EOL;'
docker compose run --rm cli cron event schedule lp_catalog_sync_stock now
Success: WordPress installed successfully.
Success: Activated 1 of 1 plugins.
from-secret production
Success: Scheduled event with hook 'lp_catalog_sync_stock' for 2026-10-28 19:28:12 GMT.
Source: WP-CLI Commands: wp core install
Within a minute the cron service had run the event, and wp cron event list no longer showed it. The generated admin
password is printed once and goes straight into a password manager.
The Debian apache and fpm images run PHP as www-data, UID 33. Started as root, WP-CLI stops with "YIKES! It looks
like you’re running this as root"; --allow-root would run plugin code as root, so user: "33:33" is the fix. The
cli image defaults to UID 82 (Alpine’s www-data) and needs --user 33:33 and HOME=/tmp against Debian-owned
files, as on Local Development Environments. A plain
docker run --env-file ... cannot carry the multi-line WORDPRESS_CONFIG_EXTRA, since env files are read line by
line; the shared Compose block avoids that. Commands that need host files mount them read-only (see
WP-CLI).
Kubernetes
Kubernetes runs the same image under the same rules: a ConfigMap for the non-secret variables, a Secret mounted as
files for _FILE (Secrets), a ReadWriteMany claim for uploads
(Persistent Volumes and Storage Classes), a
Deployment with probes (Health Probes and Lifecycle), a
Service and an Ingress (Ingress), and a CronJob as the single WP-Cron runner
(Jobs and CronJobs). The concepts are explained in the
Kubernetes section.
The plain manifests below are a single-namespace starting point. For a multi-node EKS cluster with an OpenTofu-built infrastructure, a Helm chart, media on object storage and a managed database, see Deploying on EKS with Helm and EKS Infrastructure with OpenTofu.
The database is a managed MariaDB service outside the cluster, reached at db.lumenpress.internal. The secret is
created from a directory with one file per value (db-password, auth-key, secure-auth-key, logged-in-key,
nonce-key, auth-salt, secure-auth-salt, logged-in-salt, nonce-salt), each file name becoming a key; the
files stay out of version control:
kubectl create namespace lumen
kubectl create secret generic lumen-wordpress --namespace lumen --from-file=secrets/
Source: Kubernetes Docs: Secrets
Configuration and storage, config.yaml:
apiVersion: v1
kind: ConfigMap
metadata: { name: lumen-wordpress, namespace: lumen }
data:
WORDPRESS_DB_HOST: db.lumenpress.internal
WORDPRESS_DB_NAME: lumen
WORDPRESS_DB_USER: lumen
WORDPRESS_TABLE_PREFIX: lp_
WP_ENVIRONMENT_TYPE: production
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/lumen/db-password
WORDPRESS_AUTH_KEY_FILE: /run/secrets/lumen/auth-key
WORDPRESS_SECURE_AUTH_KEY_FILE: /run/secrets/lumen/secure-auth-key
WORDPRESS_LOGGED_IN_KEY_FILE: /run/secrets/lumen/logged-in-key
WORDPRESS_NONCE_KEY_FILE: /run/secrets/lumen/nonce-key
WORDPRESS_AUTH_SALT_FILE: /run/secrets/lumen/auth-salt
WORDPRESS_SECURE_AUTH_SALT_FILE: /run/secrets/lumen/secure-auth-salt
WORDPRESS_LOGGED_IN_SALT_FILE: /run/secrets/lumen/logged-in-salt
WORDPRESS_NONCE_SALT_FILE: /run/secrets/lumen/nonce-salt
WORDPRESS_CONFIG_EXTRA: |
define( 'WP_HOME', 'https://lumenpress.example' );
define( 'WP_SITEURL', 'https://lumenpress.example' );
define( 'FORCE_SSL_ADMIN', true );
define( 'DISABLE_WP_CRON', true );
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: lumen-uploads, namespace: lumen }
spec:
accessModes: ["ReadWriteMany"]
storageClassName: shared-files
resources: { requests: { storage: 50Gi } }
Source: Kubernetes Docs: ConfigMaps
shared-files stands for whatever ReadWriteMany storage class the cluster offers (NFS, a managed file service). A
ReadWriteOnce volume attaches to one node only, so a replica scheduled on another node never starts. Object storage
with an offload plugin avoids the shared volume (see
Shared uploads).
The web tier, web.yaml:
apiVersion: apps/v1
kind: Deployment
metadata: { name: lumen-web, namespace: lumen }
spec:
replicas: 2
selector: { matchLabels: { app.kubernetes.io/name: lumen-web } }
template:
metadata: { labels: { app.kubernetes.io/name: lumen-web } }
spec:
containers:
- name: wordpress
image: registry.lumenpress.example/lumen-press:0.1.0
ports: [{ name: http, containerPort: 80 }]
envFrom: [{ configMapRef: { name: lumen-wordpress } }]
securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false }
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 512Mi }
startupProbe:
httpGet: { path: /healthz.php, port: http }
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: { path: /healthz.php, port: http }
readinessProbe:
httpGet:
path: /wp-login.php
port: http
httpHeaders: [{ name: X-Forwarded-Proto, value: https }]
timeoutSeconds: 5
volumeMounts:
- { name: secrets, mountPath: /run/secrets/lumen, readOnly: true }
- { name: uploads, mountPath: /usr/src/wordpress/wp-content/uploads }
- { name: tmp, mountPath: /tmp }
- { name: run, mountPath: /run }
volumes:
- { name: secrets, secret: { secretName: lumen-wordpress } }
- { name: uploads, persistentVolumeClaim: { claimName: lumen-uploads } }
- { name: tmp, emptyDir: {} }
- { name: run, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata: { name: lumen-web, namespace: lumen }
spec:
selector: { app.kubernetes.io/name: lumen-web }
ports: [{ name: http, port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: lumen-web, namespace: lumen }
spec:
ingressClassName: traefik
tls: [{ hosts: ["lumenpress.example"], secretName: lumenpress-example-tls }]
rules:
- host: lumenpress.example
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: lumen-web, port: { name: http } } }
Source: Kubernetes Docs: Deployments
-
Probes. Startup and liveness use
/healthz.php; readiness uses/wp-login.php, which loads WordPress and connects to the database, so a pod that cannot reach MariaDB leaves the Service instead of serving errors. Without theX-Forwarded-Protoheader, the probe’s plain HTTP request meetsFORCE_SSL_ADMINand gets a redirect; with it, the image’swp-config.phpsetsHTTPS=onand the page answers 200. -
Security context. Apache starts as root to bind port 80 and runs its workers as
www-data, so the web container cannot userunAsNonRoot; the read-only root file system and root-owned code limit what a compromised worker can change. -
Ingress. The controller terminates TLS with the certificate in
lumenpress-example-tls, must setX-Forwarded-Protoitself and must allow request bodies of 32 MB for uploads. The Ingress API is frozen and the ingress-nginx controller is being retired; Gateway API is the successor.
Source: Kubernetes Docs: Ingress
WP-Cron runs as a CronJob with the same image, configuration and volumes, entirely as UID 33, cron.yaml:
apiVersion: batch/v1
kind: CronJob
metadata: { name: lumen-cron, namespace: lumen }
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 50
successfulJobsHistoryLimit: 1
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: Never
securityContext: { runAsUser: 33, runAsGroup: 33, runAsNonRoot: true }
containers:
- name: wp-cron
image: registry.lumenpress.example/lumen-press:0.1.0
command: ["wp", "cron", "event", "run", "--due-now", "--quiet"]
envFrom: [{ configMapRef: { name: lumen-wordpress } }]
env: [{ name: HOME, value: /tmp }]
securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false }
volumeMounts:
- { name: secrets, mountPath: /run/secrets/lumen, readOnly: true }
- { name: uploads, mountPath: /usr/src/wordpress/wp-content/uploads }
- { name: tmp, mountPath: /tmp }
volumes:
- { name: secrets, secret: { secretName: lumen-wordpress } }
- { name: uploads, persistentVolumeClaim: { claimName: lumen-uploads } }
- { name: tmp, emptyDir: {} }
Source: Kubernetes Docs: CronJob
concurrencyPolicy: Forbid matters because wp cron event run does not take the doing_cron lock of
wp-cron.php (--due-now ignores it in WP-CLI 2.12.0), so two overlapping jobs could run one event twice. A
one-off task, such as wp core update-db after a release that changes the schema or wp lumen import, is a Job
with the same pod template. kubectl exec deploy/lumen-web -- wp ... runs as root in the web container and meets
the same WP-CLI root check as docker exec.
The three files were validated offline with kubeconform (-strict -kubernetes-version 1.37.0, also 1.36.0 and
1.35.0): 6 resources, all valid. kubectl apply --dry-run=client needs a reachable API server for its schemas.
The Kubernetes tutorial "Deploying WordPress and MySQL with Persistent Volumes", cited by several pages of the
Kubernetes section, says it is not for production: wordpress:6.2.1-apache with all of /var/www/html on a
ReadWriteOnce claim (one replica, Recreate), a single MySQL pod, no probes, random salts, WP-Cron on page loads and
a LoadBalancer Service without TLS. It is a good first exercise; the differences above make it a production layout.
Statelessness requirements
A WordPress container can be stopped, replaced or multiplied only when it holds nothing that is not somewhere else:
| Requirement | How the Lumen Press image meets it |
|---|---|
Code comes from the image |
WordPress served from |
Configuration comes from the environment |
|
Secrets come from files |
|
Uploads are shared and persistent |
One volume, |
No local session state |
WordPress uses signed cookies; plugins that use PHP sessions need a shared handler |
Cache is shared |
A persistent object cache in Redis (Performance and Caching); no file-based page cache in the container |
Cron runs once |
|
Pitfalls
-
"WordPress not found in /var/www/html - copying now…" in the logs of a custom image means it still works in
/var/www/html: the baked code is copied into a volume, served from there, and later images are ignored. -
Logged out on every other request behind a load balancer: the replicas generated their own keys and salts.
-
"Error establishing a database connection" at start-up when the web container starts before MariaDB is ready;
depends_onwithcondition: service_healthy, or a readiness probe in Kubernetes, handles it. -
Uploads fail with "Unable to create directory" when the uploads volume is not writable by UID 33, typically a bind mount or a network share owned by root.
Corrections to older material
Older WordPress-in-Docker tutorials, and the hosting chapters of older books, predate several points of this page:
-
A top-level
version:key andlinks:are Compose V1 habits; current Compose ignoresversionand resolves service names withoutlinks.mysql:5.7reached end of life in 2023; current examples usemariadb:11.8ormysql:8.4. -
Tags without a WordPress version (
latest,php8.4-apache) make deployments unreproducible. -
Mounting all of
/var/www/htmlin a volume, and installing plugins from the dashboard, is the self-updating model, not a production image; passwords written intocompose.yamlbelong in secrets.