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

apache (default)

7.1.3-php8.4-apache, also 7.1.3-php8.4, 7.1.3, latest

Debian, Apache with mod_php and mod_rewrite, port 80. One container serves everything.

fpm

7.1.3-php8.4-fpm

Debian, PHP-FPM on port 9000. Needs a web server in front (nginx) that can read the static files.

fpm-alpine

7.1.3-php8.4-fpm-alpine

Alpine, PHP-FPM. Smaller; the www-data user is UID 82, not 33.

cli

cli-2.12.0-php8.4, also cli-2.12, cli-2, cli

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:

  1. If neither index.php nor wp-includes/version.php is there, it copies WordPress from /usr/src/wordpress, skipping plugins and themes that already exist in the target.

  2. If wp-config.php is missing and any WORDPRESS_* variable is set, it writes one from wp-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.php is absent. Core is updated by wp core update or 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-volumes discards 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

WORDPRESS_DB_HOST, WORDPRESS_DB_NAME, WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD

mysql, wordpress, placeholders

DB_HOST, DB_NAME, DB_USER, DB_PASSWORD. The database must already exist; the container does not create it.

WORDPRESS_DB_CHARSET, WORDPRESS_DB_COLLATE

utf8mb4, empty

DB_CHARSET, DB_COLLATE

WORDPRESS_TABLE_PREFIX

wp_

$table_prefix; Lumen Press uses lp_

WORDPRESS_AUTH_KEY …​ WORDPRESS_NONCE_SALT (eight)

random per generated file

The eight keys and salts

WORDPRESS_DEBUG

empty

WP_DEBUG is true for any non-empty value except 0 (the file casts the string with !!), so false and no turn debugging on

WORDPRESS_CONFIG_EXTRA

empty

PHP code passed to eval() near the end of wp-config.php

Three behaviours of that file cause most surprises:

  • Evaluated on every request, not copied. wp-config.php calls getenv_docker() each time it is loaded, and WORDPRESS_CONFIG_EXTRA is passed to eval() at run time. The web server, the cron runner and each WP-CLI container need the same variables; a WP-CLI container without WORDPRESS_CONFIG_EXTRA silently 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 $name and ${name} in compose.yaml before the container sees the value, so $lumen = getenv( 'LUMEN_X' ); in WORDPRESS_CONFIG_EXTRA reaches 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-Proto is trusted. The file sets $_SERVER['HTTPS'] = 'on' when that header contains https, 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:

  • blocks runs npm ci and the build script; copying the manifests before the sources keeps the npm ci layer cached while only block code changes. Node.js and node_modules never reach the final image.

  • vendor installs production dependencies (no WPCS), drops the block sources and writes an authoritative class map, suited to fixed code. The runtime stage copies src/ from this stage, subfolders such as src/Rest/ and src/Cli/ included, so the shipped classes are exactly the ones in the map. With --classmap-authoritative the 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 ... sed points Apache at /usr/src/wordpress and cp -s links wp-config.php to wp-config-docker.php, so the entrypoint neither copies WordPress nor writes a configuration file.

  • DISALLOW_FILE_MODS goes before "That’s all, stop editing". A first sed matching 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:root keeps code unwritable by www-data even without a read-only file system, at a cost: that layer rewrites all of WordPress (110 MB; 919 MB image against 802 MB base). Only wp-content/uploads belongs to www-data.

  • WP-CLI is copied, pinned, from the cli image. Akismet and Hello Dolly are removed because nothing could update them.

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-wordpress block and the << merge key give web, cron and cli the same variables, secrets and volume. read_only with tmpfs for /run (Apache’s PID file) and /tmp leaves 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_CRON stops page loads from spawning WP-Cron; the one cron service 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.

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/

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 } }

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 } } }
  • 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 the X-Forwarded-Proto header, the probe’s plain HTTP request meets FORCE_SSL_ADMIN and gets a redirect; with it, the image’s wp-config.php sets HTTPS=on and 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 use runAsNonRoot; 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 set X-Forwarded-Proto itself 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.

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: {} }

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 /usr/src/wordpress, code owned by root, read-only root file system, DISALLOW_FILE_MODS

Configuration comes from the environment

WORDPRESS_* variables, WP_ENVIRONMENT_TYPE, and WORDPRESS_CONFIG_EXTRA identical in every container

Secrets come from files

_FILE variables for the database password and all eight keys and salts

Uploads are shared and persistent

One volume, ReadWriteMany in Kubernetes, or object storage

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

DISABLE_WP_CRON everywhere, one Compose service or one CronJob with concurrencyPolicy: Forbid

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_on with condition: 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 and links: are Compose V1 habits; current Compose ignores version and resolves service names without links. mysql:5.7 reached end of life in 2023; current examples use mariadb:11.8 or mysql:8.4.

  • Tags without a WordPress version (latest, php8.4-apache) make deployments unreproducible.

  • Mounting all of /var/www/html in a volume, and installing plugins from the dashboard, is the self-updating model, not a production image; passwords written into compose.yaml belong in secrets.