Deploying on EKS with Helm

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 as the site baseline (7.1.4, the security release of 2026-10-28, is the version to run, and is the image used below), PHP 8.4, WP-CLI 2.12.0, nginx 1.29, Helm 4.3.1, Kubernetes 1.36 on Amazon EKS (1.37 has been available on EKS since 2026-10-01) and External Secrets Operator with its external-secrets.io/v1 API, and checked on 2026-10-29. Lumen Press runs www.lumenpress.example in the namespace lumenpress of the cluster lumenpress-prod in eu-west-1; the Helm release is lumenpress and the chart directory lumenpress-wordpress.

The infrastructure underneath (cluster, RDS, EFS, Valkey, ECR, secrets, certificate, WAF) is created in EKS Infrastructure with OpenTofu; the architecture is on Cloud Reference Architecture. This page builds the image, writes the chart and performs the first install. Releases and rollbacks are on Releases, Upgrades and Autoscaling on Kubernetes. Docker, Helm and Kubernetes fundamentals are on Helm, Deployments, Jobs and CronJobs, Health Probes and Lifecycle, Scheduling and Disruptions and Deploying Microservices on EKS, and the single-host image this one grows out of on Containers, Docker and Kubernetes. Everything on this page was validated locally as noted in each section; nothing was deployed to a live cluster.

The release at a glance

Kubernetes objects of the release: ALB from the Ingress, the Service, pods with nginx and php-fpm containers sharing an EFS uploads volume, configuration from a ConfigMap and External Secrets, and the cron, migrate and install Jobs

Each pod of the Deployment lumenpress-web runs two containers. nginx serves static files and sends .php requests to PHP-FPM over FastCGI. Both containers share the pod’s network namespace, so nginx reaches FPM at 127.0.0.1:9000 and there is no Service between them. This is the standard shape for PHP on Kubernetes, and it differs from the Apache image (mod_php) that the single-server page uses in three ways that matter:

  • the web server and the PHP runtime are tuned and restarted independently, and PHP-FPM’s pm.max_children is the concurrency knob;

  • static files never reach PHP: nginx serves wp-includes and wp-content assets itself;

  • two images are needed, because nginx has to see the same static files that the PHP image carries. The Dockerfile builds both from one source tree, so a release is one commit and two tags.

The image

The Lumen Press repository holds docker/ (this section), plugins/lumen-catalog/, themes/lumen/ and the chart. The Composer and Node build stages for the plugin are shown on Containers, Docker and Kubernetes; here the plugin and theme are plain folders so the Dockerfile stays readable.

# syntax=docker/dockerfile:1

FROM wordpress:cli-2.12.0-php8.4 AS wpcli

# ---- the Redis Object Cache plugin, downloaded at build time (it also works with Valkey) ----
FROM alpine:3.22 AS redis-cache
ARG REDIS_CACHE_VERSION=3.0.0
RUN set -eux; \
	apk add --no-cache curl unzip; \
	curl -fsSL -o /tmp/redis-cache.zip "https://downloads.wordpress.org/plugin/redis-cache.${REDIS_CACHE_VERSION}.zip"; \
	unzip -q /tmp/redis-cache.zip -d /out; \
	test -f /out/redis-cache/includes/object-cache.php

# ---- target "php": PHP-FPM with WordPress and the Lumen Press code baked in ----------
FROM wordpress:7.1.4-php8.4-fpm AS php

# The official image keeps WordPress in /usr/src/wordpress and copies it into
# /var/www/html when its entrypoint starts. Here the code is part of the image.
RUN set -eux; \
	cp -a /usr/src/wordpress/. /var/www/html/; \
	rm -rf /var/www/html/wp-content/plugins/akismet /var/www/html/wp-content/plugins/hello.php \
		/var/www/html/wp-config-docker.php /var/www/html/wp-config-sample.php; \
	mkdir -p /var/www/html/wp-content/uploads

COPY --from=wpcli /usr/local/bin/wp /usr/local/bin/wp
COPY docker/php/zz-lumen.ini ${PHP_INI_DIR}/conf.d/zz-lumen.ini
COPY docker/php/zz-lumen.conf /usr/local/etc/php-fpm.d/zz-lumen.conf
COPY docker/wp-config.php /var/www/html/wp-config.php
COPY docker/readyz.php /var/www/html/readyz.php
COPY plugins/lumen-catalog /var/www/html/wp-content/plugins/lumen-catalog
COPY themes/lumen /var/www/html/wp-content/themes/lumen
# The plugin folder plus its drop-in: `wp redis enable` cannot copy the drop-in at run time
# on a read-only code tree, so the build does it.
COPY --from=redis-cache /out/redis-cache /var/www/html/wp-content/plugins/redis-cache
RUN cp /var/www/html/wp-content/plugins/redis-cache/includes/object-cache.php /var/www/html/wp-content/object-cache.php

# Code belongs to root and is read-only for the PHP user; only uploads is writable.
RUN set -eux; \
	chown -R root:root /var/www/html; \
	find /var/www/html -type d -exec chmod 0755 {} +; \
	find /var/www/html -type f -exec chmod 0644 {} +; \
	chown www-data:www-data /var/www/html/wp-content/uploads

# Skip the official entrypoint: it would try to copy WordPress and write wp-config.php.
ENTRYPOINT ["docker-php-entrypoint"]
CMD ["php-fpm"]
USER 33:33
EXPOSE 9000

# ---- target "nginx": the static files and the proxy to PHP-FPM ------------------------
FROM nginxinc/nginx-unprivileged:1.29-alpine AS nginx

COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
# nginx serves wp-includes, wp-content assets and uploads; PHP files are never executed here.
COPY --from=php --chown=0:0 /var/www/html /var/www/html
EXPOSE 8080

The Dockerfile was built with docker build --target php and docker build --target nginx while writing this page, with wordpress:7.1.4-php8.4-fpm pulled from Docker Hub (verify that the tag exists with docker pull when you build; the tag scheme of the official image is <version>-php<version>-fpm). The PHP target came to about 1 GB and the nginx target to about 170 MB on the test machine. What it does and why:

  • Three stages, two targets. wpcli only provides the wp binary, copied pinned from the official CLI image. redis-cache downloads the object-cache plugin (see Object Cache, Sessions and Cron in a Cluster). The php target is the runtime. The nginx target copies the finished /var/www/html out of the php target, so the static files can never differ from the code that PHP runs.

  • The code is copied into the image. The official image keeps WordPress in /usr/src/wordpress and copies it into /var/www/html when its entrypoint runs. That is a convenience for volumes; here it would write to a read-only file system. The Dockerfile copies the tree once, and replaces the entrypoint with docker-php-entrypoint, so the official docker-entrypoint.sh (which would copy files and generate a wp-config.php) never runs.

  • Own wp-config.php. The image’s generated configuration reads WORDPRESS_* variables and a WORDPRESS_CONFIG_EXTRA string passed to eval(). Lumen Press ships its own file (next section), which reads variables directly, fails loudly when a secret is missing and has no eval().

  • Root owns the code. Files are root:root and not writable by the PHP user (UID 33, www-data). Even without a read-only root file system, a vulnerability in a plugin cannot rewrite code. Only the uploads directory belongs to www-data, and in the cluster it is a mounted volume.

  • USER 33:33 and port 9000. PHP-FPM then runs as a normal user and listens on an unprivileged port. FPM logs a notice that its user directive is ignored when not running as root; that is harmless and expected.

  • Akismet and Hello Dolly are removed and wp-config-sample.php is deleted: nothing could update them and the sample is noise in a production image.

  • nginx-unprivileged. The nginxinc/nginx-unprivileged image runs as UID 101, listens on 8080 and keeps its temporary files and PID file under /tmp, which makes it work with a read-only root file system and a tmpfs. There is no root process in the pod.

PHP and FPM settings

; Immutable code: never stat files again.
opcache.enable = 1
opcache.validate_timestamps = 0
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000

memory_limit = 256M
upload_max_filesize = 32M
post_max_size = 32M
expose_php = Off
display_errors = Off
log_errors = On
error_log = /dev/stderr

opcache.validate_timestamps = 0 is the production OPcache setting for immutable code: PHP never checks whether a file changed, which saves a stat per file per request. It is correct here because a code change is a new image and a new pod, and wrong on any host where files change in place. The 192 MB of OPcache memory and 20 000 accelerated files cover WordPress core, the theme and a medium plugin set; check opcache_get_status() for a full cache before raising traffic. log_errors with error_log = /dev/stderr sends PHP errors to the container log, display_errors is off, and expose_php hides the PHP version header.

; Loaded after the image's zz-docker.conf, so these values win.
[global]
error_log = /proc/self/fd/2

[www]
; FPM runs as UID 33 (set by the container), so no user or group directives here.
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 16
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
request_terminate_timeout = 60s

; Liveness and metrics, reachable only through nginx locations that are not public.
ping.path = /fpm-ping
ping.response = pong
pm.status_path = /fpm-status

catch_workers_output = yes
decorate_workers_output = no
clear_env = no

The pool file loads after the image’s zz-docker.conf and wins on conflicts. pm.max_children = 16 is the number that limits database connections per pod: every busy worker holds one (the arithmetic with replicas is on the database page). pm.max_requests = 500 recycles workers to contain slow memory growth in plugins, request_terminate_timeout stops a runaway request, and the status and ping paths are reachable only through nginx locations that allow 127.0.0.1. clear_env = no is the default of the official image and is kept explicit because it is what lets the pod’s environment variables reach PHP.

nginx

# Pod-local: nginx and PHP-FPM share the pod network namespace, so FPM is on 127.0.0.1.
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

server {
    listen 8080 default_server;
    server_name _;
    root /var/www/html;
    index index.php;

    client_max_body_size 32m;
    server_tokens off;

    # Kubernetes liveness: answered by nginx itself.
    location = /healthz {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

    # FPM liveness and status through the pod's own listener only.
    location ~ ^/(fpm-ping|fpm-status)$ {
        allow 127.0.0.1;
        deny all;
        access_log off;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;
    }

    # Uploads are data: never execute PHP from them.
    location ~* ^/wp-content/uploads/.*\.php$ {
        return 403;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }

    location ~* \.(?:css|js|mjs|woff2?|svg|png|jpe?g|gif|webp|avif|ico)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        # X-Forwarded-Proto from the load balancer arrives as HTTP_X_FORWARDED_PROTO.
        fastcgi_read_timeout 60s;
        fastcgi_pass 127.0.0.1:9000;
    }
}

Key points: the realip directives (nginx’s ngx_http_realip_module) take the client address from the X-Forwarded-For header that the ALB sets, but only when the connection comes from the VPC range, so a client cannot forge its address. /healthz is answered by nginx itself and is the liveness and load-balancer health check. Uploads can never run PHP (a 403 for any .php below wp-content/uploads), dotfiles are denied, and the front controller rule sends unknown paths to index.php. The fastcgi_params file makes HTTP_X_FORWARDED_PROTO visible to PHP, which wp-config.php uses.

The server_tokens off directive hides the nginx version. Compression and fine-grained cache headers are left to CloudFront and the Ingress; the static-asset rule sets a thirty-day lifetime and relies on WordPress’s versioned query strings for cache busting.

wp-config.php from environment variables

<?php
/**
 * Lumen Press production wp-config.php for Kubernetes.
 * Every value comes from the environment: plain settings from a ConfigMap,
 * secrets from a Secret created by External Secrets Operator.
 */

/** Read an environment variable, with an optional default. */
function lumen_env( string $name, ?string $default = null ): ?string {
	$value = getenv( $name );
	return ( false === $value || '' === $value ) ? $default : $value;
}

/** Like lumen_env() but fail loudly: a pod without its secrets must not start serving. */
function lumen_required( string $name ): string {
	$value = lumen_env( $name );
	if ( null === $value ) {
		http_response_code( 500 );
		error_log( "wp-config: missing environment variable $name" );
		exit( 'Configuration error' );
	}
	return $value;
}

// Database.
define( 'DB_NAME', lumen_required( 'WORDPRESS_DB_NAME' ) );
define( 'DB_USER', lumen_required( 'WORDPRESS_DB_USER' ) );
define( 'DB_PASSWORD', lumen_required( 'DB_PASSWORD' ) );
define( 'DB_HOST', lumen_required( 'WORDPRESS_DB_HOST' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
// TLS to RDS; certificate verification is covered on the managed database page.
define( 'MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL );
$table_prefix = lumen_env( 'WORDPRESS_TABLE_PREFIX', 'wp_' );

// The same eight values on every pod, or logins fail on the next request.
foreach ( array(
	'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
	'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
) as $lumen_constant ) {
	define( $lumen_constant, lumen_required( $lumen_constant ) );
}

// Addresses.
define( 'WP_HOME', lumen_required( 'WP_HOME' ) );
define( 'WP_SITEURL', lumen_required( 'WP_HOME' ) );

// HTTPS: the ALB terminates TLS. Trusting the header is safe only because the
// pods accept traffic from the load balancer and nothing else.
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
	$_SERVER['HTTPS'] = 'on';
}
define( 'FORCE_SSL_ADMIN', true );

// Immutable code, one scheduler.
define( 'DISALLOW_FILE_MODS', true );
define( 'DISABLE_WP_CRON', true );
define( 'WP_AUTO_UPDATE_CORE', false );
define( 'WP_ENVIRONMENT_TYPE', lumen_env( 'WP_ENVIRONMENT_TYPE', 'production' ) );

// Logs go to stderr, where the container runtime collects them.
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', '/dev/stderr' );
define( 'WP_DEBUG_DISPLAY', false );

// Object cache: Redis Object Cache plugin settings, pointing at ElastiCache for Valkey.
if ( null !== lumen_env( 'VALKEY_HOST' ) ) {
	define( 'WP_REDIS_HOST', lumen_required( 'VALKEY_HOST' ) );
	define( 'WP_REDIS_PORT', (int) lumen_env( 'VALKEY_PORT', '6379' ) );
	define( 'WP_REDIS_SCHEME', lumen_env( 'VALKEY_SCHEME', 'tls' ) );
	define( 'WP_REDIS_PASSWORD', lumen_required( 'VALKEY_AUTH' ) );
	define( 'WP_REDIS_PREFIX', lumen_env( 'WP_REDIS_PREFIX', 'lumenpress:' ) );
	define( 'WP_REDIS_TIMEOUT', 1 );
	define( 'WP_REDIS_READ_TIMEOUT', 1 );
	define( 'WP_REDIS_GRACEFUL', false );
}

if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';

The file was checked with php -l (PHP 8.4, No syntax errors detected) in a php:8.4-cli container. Points that deserve attention:

  • One helper, two behaviours. lumen_env() returns a default when a variable is unset; lumen_required() stops the request with HTTP 500 and a log line. A pod without its database password or salts must fail its readiness check, not serve a login page that cannot work.

  • Secrets come from the environment. The ConfigMap supplies the plain settings (WP_HOME, database host, user, name, table prefix), the Secret supplies DB_PASSWORD, the eight keys and salts and the Valkey values. An environment variable is visible to anyone who can exec into the pod or read /proc; the alternative is mounting the Secret as files and reading them, which the Docker image’s _FILE convention does. Environment variables are simpler and equal in strength for a pod whose access is controlled by RBAC and the Pod Security level.

  • Identical salts everywhere. All eight constants come from the same Secret. A pod with a different salt rejects every login cookie from the others.

  • Database TLS. RDS for MariaDB 11.8 requires TLS by default (require_secure_transport is on from 11.8.3), and MYSQL_CLIENT_FLAGS set to MYSQLI_CLIENT_SSL makes WordPress connect with TLS. It does not verify the server certificate; verification with the RDS CA bundle and the corresponding settings are on Managed Database: RDS and Aurora.

  • HTTPS detection. The ALB terminates TLS and connects to the pod over HTTP, adding X-Forwarded-Proto. Without the detection, WordPress builds http:// URLs, loops on redirects with FORCE_SSL_ADMIN and sets non-secure cookies. The header is trusted without checking the sender, which is safe only when nothing but the load balancer can reach the pod. Keep the Service ClusterIP, and add a network policy if other namespaces must not call it directly. The general discussion is on Scaling and High Availability.

  • Immutable code, one scheduler. DISALLOW_FILE_MODS blocks plugin and core installation and updates from the dashboard, automatic core updates are off and DISABLE_WP_CRON hands scheduling to the CronJob.

  • Logs to stderr. WP_DEBUG_LOG points at /dev/stderr, so the container runtime collects PHP and WordPress log lines together with the FPM and nginx logs.

Probes: readiness is not liveness

<?php
// Readiness: WordPress loads (SHORTINIT skips plugins) and the database answers.
define( 'SHORTINIT', true );
header( 'Content-Type: text/plain' );
header( 'Cache-Control: no-store' );

require __DIR__ . '/wp-load.php';

global $wpdb;
if ( 1 !== (int) $wpdb->get_var( 'SELECT 1' ) ) {
	http_response_code( 503 );
	echo "database unavailable\n";
	exit;
}
echo "ready\n";

readyz.php loads WordPress with SHORTINIT (no plugins, no theme) and asks the database one question. It is the readiness probe: a pod that cannot reach the database leaves the Service endpoints and the ALB target group, and returns when the database does. The liveness probes are deliberately cheap and independent of dependencies: nginx answers /healthz itself and the FPM container is checked with a TCP connection to port 9000. If liveness depended on the database, a database outage would restart every pod at once and make recovery slower. The behaviour of /healthz.php on a single host is explained on Containers, Docker and Kubernetes.

Building, testing and pushing

The script below builds both targets and pushes them to ECR with the short git SHA as the tag. The nginx image gets the same tag with the suffix -nginx. The registry is lumenpress/wordpress with immutable tags (OpenTofu page), so a SHA can be pushed only once:

#!/usr/bin/env bash
# Builds both targets of the Dockerfile and pushes them to ECR with the git SHA as the tag.
set -euo pipefail

account="123456789012"
region="eu-west-1"
repo="${account}.dkr.ecr.${region}.amazonaws.com/lumenpress/wordpress"
sha="$(git rev-parse --short=12 HEAD)"

aws ecr get-login-password --region "$region" \
  | docker login --username AWS --password-stdin "${account}.dkr.ecr.${region}.amazonaws.com"

docker build --target php   -t "${repo}:${sha}" .
docker build --target nginx -t "${repo}:${sha}-nginx" .

docker push "${repo}:${sha}"
docker push "${repo}:${sha}-nginx"
echo "Pushed ${sha}"

In CI the same steps run from GitHub Actions with an OIDC role (see Releases, Upgrades and Autoscaling on Kubernetes). Before pushing, a local smoke test starts both images the way the pod does: read-only root file system, tmpfs for /tmp, UID 33 for PHP, nginx sharing the PHP container’s network namespace:

#!/usr/bin/env bash
# Starts the two images the way the pod does (read-only, non-root, one shared network
# namespace) and probes the endpoints the probes use. No database is needed for these.
set -euo pipefail

tag="${1:?usage: smoke-test.sh <image-tag>}"
repo="lumenpress/wordpress"

cleanup() { docker rm -f lp-php lp-nginx >/dev/null 2>&1 || true; }
trap cleanup EXIT

docker run -d --name lp-php --read-only --tmpfs /tmp --user 33:33 "${repo}:${tag}" >/dev/null
docker run -d --name lp-nginx --network container:lp-php --read-only --tmpfs /tmp \
  "${repo}:${tag}-nginx" >/dev/null
sleep 3

probe() {
  docker run --rm --network container:lp-php curlimages/curl:8.15.0 -s -m 5 -o /dev/null \
    -w "$1 -> %{http_code}\n" "http://127.0.0.1:8080$1"
}
probe /healthz
probe /fpm-ping
probe /readyz.php
probe /wp-includes/js/jquery/jquery.min.js
probe /wp-content/uploads/shell.php

docker logs lp-php 2>&1 | tail -n 3

On the test machine the output was:

/healthz -> 200
/fpm-ping -> 200
/readyz.php -> 500
/wp-includes/js/jquery/jquery.min.js -> 200
/wp-content/uploads/shell.php -> 403
127.0.0.1 -  29/Oct/2026:17:18:47 +0000 "GET /fpm-ping" 200
127.0.0.1 -  29/Oct/2026:17:18:47 +0000 "GET /readyz.php" 500
[29-Oct-2026 17:18:47 UTC] wp-config: missing environment variable WORDPRESS_DB_NAME

The 500 for /readyz.php is the expected answer: the test container has no environment, and wp-config.php refuses to serve. It proves the fail-closed behaviour and the log line. Both containers ran with --read-only and no root.

Cluster prerequisites

Three things are created once per cluster, outside the chart: the namespace (with the restricted Pod Security level, which the chart’s pods satisfy), the ClusterSecretStore of External Secrets Operator and the Auto Mode IngressClass.

apiVersion: eks.amazonaws.com/v1
kind: IngressClassParams
metadata:
  name: alb
spec:
  scheme: internet-facing
  certificateARNs:
    - arn:aws:acm:eu-west-1:123456789012:certificate/00000000-0000-0000-0000-000000000000
  group:
    name: lumenpress
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb
spec:
  controller: eks.amazonaws.com/alb
  parameters:
    apiGroup: eks.amazonaws.com
    kind: IngressClassParams
    name: alb
---
# No auth block: the controller uses the credentials of its own Pod Identity association.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: aws-secretsmanager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
---
apiVersion: v1
kind: Namespace
metadata:
  name: lumenpress
  labels:
    pod-security.kubernetes.io/enforce: restricted

The manifests were checked with kubeconform -strict -ignore-missing-schemas; the two custom resources are skipped because their schemas are not in the default catalog, so a typo in a CRD field is caught only by the cluster. Notes:

  • Pod Identity for the operator. The ClusterSecretStore has no auth block, so the operator uses the credentials of its own pod, which the Pod Identity association from the OpenTofu page provides for the service account external-secrets in the namespace external-secrets. The operator is installed from its Helm chart (helm repo add external-secrets https://charts.external-secrets.io and helm install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace); the exact way the operator picks up Pod Identity credentials is described on its AWS authentication page, so check that page for your operator version.

  • Auto Mode IngressClass. With EKS Auto Mode the certificate belongs in IngressClassParams (the ARN is the output site_certificate_arn) and the WAF web ACL is an annotation on the Ingress, because the class parameters do not support it. With the self-managed AWS Load Balancer Controller the same class uses the controller name ingress.k8s.aws/alb and annotations carry the certificate; the comparison is in Deploying Microservices on EKS.

  • The restricted level. A pod that runs as a non-root user, drops all capabilities, forbids privilege escalation and uses the default seccomp profile satisfies it, which the Deployment does.

The Helm chart

The chart lives in lumenpress-wordpress/: Chart.yaml, values.yaml and a templates/ directory with one file per object. It was checked with helm lint (no failures) and with helm template | kubeconform -strict -summary -ignore-missing-schemas against the Kubernetes 1.36 schemas (Valid: 12, Invalid: 0, Errors: 0, Skipped: 3 when the install Job and the HTTPRoute are enabled: the two ExternalSecrets and the HTTPRoute are custom resources, so they are skipped). With the defaults (Gateway API and install Job off) twelve objects render, eleven of them validated and the ExternalSecret skipped.

apiVersion: v2
name: lumenpress-wordpress
description: WordPress on Kubernetes - nginx and PHP-FPM, cron, migrations and install Jobs
type: application
version: 0.1.0
appVersion: "7.1.4"
kubeVersion: ">=1.34.0-0"
# Image: the same repository, two tags per release (<git-sha> for PHP-FPM, <git-sha>-nginx).
image:
  repository: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/lumenpress/wordpress
  tag: ""            # required: the git SHA of the release, passed with --set image.tag=
  pullPolicy: IfNotPresent

site:
  url: https://www.lumenpress.example
  environmentType: production
  tablePrefix: lp_
  redisPrefix: "lumenpress:"

database:
  host: lumenpress-prod.cxxxxxxxxxxx.eu-west-1.rds.amazonaws.com   # output db_endpoint
  name: lumenpress
  user: wp_app
  migrateUser: wp_migrate

# Secrets Manager secrets read by External Secrets Operator.
secrets:
  enabled: true
  storeName: aws-secretsmanager        # ClusterSecretStore created once per cluster
  wordpressKey: lumenpress/prod/wordpress
  valkeyKey: lumenpress/prod/valkey
  refreshInterval: 1h

serviceAccount:
  name: lumenpress-wordpress          # the Pod Identity association names this account

replicaCount: 3

web:
  phpFpm:
    resources:
      requests: { cpu: 500m, memory: 512Mi }
      limits: { memory: 768Mi }
  nginx:
    resources:
      requests: { cpu: 50m, memory: 64Mi }
      limits: { memory: 128Mi }
  terminationGracePeriodSeconds: 45

uploads:
  mode: efs                           # efs | emptyDir (use emptyDir with an S3 offload plugin)
  size: 100Gi
  efs:
    fileSystemId: fs-0123456789abcdef0   # output efs_file_system_id
    accessPointId: fsap-0123456789abcdef0 # output efs_access_point_id

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 12
  targetCPUUtilizationPercentage: 65

podDisruptionBudget:
  maxUnavailable: 1

ingress:
  enabled: true
  className: alb                       # IngressClass created once per cluster (see the page)
  host: www.lumenpress.example
  annotations:
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    alb.ingress.kubernetes.io/wafv2-acl-arn: arn:aws:wafv2:eu-west-1:123456789012:regional/webacl/lumenpress-prod-alb/00000000-0000-0000-0000-000000000000

# Gateway API alternative to the Ingress (needs a Gateway owned by the platform team).
httpRoute:
  enabled: false
  parentRef:
    name: lumenpress
    namespace: gateway-system
  host: www.lumenpress.example

cron:
  enabled: true
  schedule: "*/5 * * * *"

migrate:
  enabled: true                        # pre-upgrade hook Job: wp core update-db

install:
  enabled: false                       # first install only: helm upgrade --install --set install.enabled=true
  title: Lumen Press
  adminUser: lp_admin
  adminEmail: admin@lumenpress.example
  secretKey: lumenpress/prod/wordpress-install   # JSON key ADMIN_PASSWORD

nodeSelector: {}
tolerations: []

The values are the contract with the infrastructure: database.host, uploads.efs.fileSystemId, uploads.efs.accessPointId and the WAF and certificate ARNs are outputs of the OpenTofu configuration, and the secret names are the ones it creates. image.tag has no default on purpose: the helper required fails the render when it is missing, so no one ever deploys latest by omission. Per environment, a values file (values-prod.yaml, values-staging.yaml) overrides the host, URLs, secret names and sizes; the tag is passed by the pipeline.

{{- define "lp.name" -}}lumenpress{{- end -}}

{{- define "lp.labels" -}}
app.kubernetes.io/name: {{ include "lp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}

{{- define "lp.selector" -}}
app.kubernetes.io/name: {{ include "lp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: web
{{- end -}}

{{- define "lp.phpImage" -}}
{{ .Values.image.repository }}:{{ required "set image.tag to the git SHA of the release" .Values.image.tag }}
{{- end -}}

{{- define "lp.nginxImage" -}}
{{ .Values.image.repository }}:{{ required "set image.tag to the git SHA of the release" .Values.image.tag }}-nginx
{{- end -}}

{{/* Environment shared by every container that loads WordPress (web, cron, Jobs). */}}
{{- define "lp.envFrom" -}}
- configMapRef:
    name: {{ .Release.Name }}-config
- secretRef:
    name: {{ .Release.Name }}-secrets
{{- end -}}

{{- define "lp.uploadsVolume" -}}
- name: uploads
{{- if eq .Values.uploads.mode "efs" }}
  persistentVolumeClaim:
    claimName: {{ .Release.Name }}-uploads
{{- else }}
  emptyDir: {}
{{- end }}
{{- end -}}

{{/* Restrictive container security context. */}}
{{- define "lp.containerSecurity" -}}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
  drop: ["ALL"]
{{- end -}}

The helpers keep the three consumers of the application (the web pods, the cron Job and the Jobs) on one definition: the same envFrom, the same uploads volume and the same restrictive container security context.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ .Values.serviceAccount.name }}
  labels:
    {{- include "lp.labels" . | nindent 4 }}
automountServiceAccountToken: false

The service account has automountServiceAccountToken: false: WordPress never calls the Kubernetes API. Its name must match the Pod Identity association created by OpenTofu. Pod Identity does not use the mounted token; the agent injects credentials through the EKS endpoint.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config
  labels:
    {{- include "lp.labels" . | nindent 4 }}
data:
  WP_HOME: {{ .Values.site.url | quote }}
  WP_ENVIRONMENT_TYPE: {{ .Values.site.environmentType | quote }}
  WORDPRESS_TABLE_PREFIX: {{ .Values.site.tablePrefix | quote }}
  WP_REDIS_PREFIX: {{ .Values.site.redisPrefix | quote }}
  WORDPRESS_DB_HOST: {{ .Values.database.host | quote }}
  WORDPRESS_DB_NAME: {{ .Values.database.name | quote }}
  WORDPRESS_DB_USER: {{ .Values.database.user | quote }}
  HOME: /tmp
{{- if .Values.secrets.enabled }}
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-secrets
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  refreshInterval: {{ .Values.secrets.refreshInterval }}
  secretStoreRef:
    kind: ClusterSecretStore
    name: {{ .Values.secrets.storeName }}
  target:
    name: {{ .Release.Name }}-secrets
    creationPolicy: Owner
  # Every JSON key of both secrets becomes a key of one Kubernetes Secret:
  # DB_PASSWORD, AUTH_KEY ... NONCE_SALT, VALKEY_HOST, VALKEY_AUTH ...
  dataFrom:
    - extract:
        key: {{ .Values.secrets.wordpressKey }}
    - extract:
        key: {{ .Values.secrets.valkeyKey }}
{{- end }}

dataFrom with extract copies every JSON key of the two Secrets Manager secrets into one Kubernetes Secret, which envFrom exposes as environment variables: DB_PASSWORD, AUTH_KEY to NONCE_SALT, and the VALKEY_* keys. The refreshInterval is how often the operator re-reads the source. Pods do not pick up a changed value until they restart, so changing a secret means a rollout (see the releases page). There is no secret in the chart, in the values or in Git.

{{- if eq .Values.uploads.mode "efs" }}
# Static provisioning: the PV points at the EFS access point created by OpenTofu
# (UID and GID 33), so no StorageClass is involved.
apiVersion: v1
kind: PersistentVolume
metadata:
  name: {{ .Release.Namespace }}-{{ .Release.Name }}-uploads
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  capacity:
    storage: {{ .Values.uploads.size }}
  accessModes: [ReadWriteMany]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  csi:
    driver: efs.csi.aws.com
    volumeHandle: {{ .Values.uploads.efs.fileSystemId }}::{{ .Values.uploads.efs.accessPointId }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: {{ .Release.Name }}-uploads
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  accessModes: [ReadWriteMany]
  storageClassName: ""
  volumeName: {{ .Release.Namespace }}-{{ .Release.Name }}-uploads
  resources:
    requests:
      storage: {{ .Values.uploads.size }}
{{- end }}

Uploads use static provisioning: the volume handle is <file-system-id>::<access-point-id>, which makes the CSI driver mount the access point from OpenTofu (UID and GID 33). ReadWriteMany lets every pod mount it, and an empty storageClassName stops Kubernetes from binding another class. Retain protects media when the release is deleted. A site that offloads media to S3 sets uploads.mode to emptyDir and no EFS objects render (the choice and its trade-offs are on Media: S3 Offload, EFS and CloudFront).

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "lp.labels" . | nindent 4 }}
    app.kubernetes.io/component: web
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  selector:
    matchLabels:
      {{- include "lp.selector" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "lp.selector" . | nindent 8 }}
      annotations:
        # A changed ConfigMap rolls the pods; the Secret is reloaded by the next deployment.
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      serviceAccountName: {{ .Values.serviceAccount.name }}
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: {{ .Values.web.terminationGracePeriodSeconds }}
      securityContext:
        fsGroup: 33
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "lp.selector" . | nindent 14 }}
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "lp.selector" . | nindent 14 }}
      containers:
        - name: php-fpm
          image: {{ include "lp.phpImage" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "lp.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          ports:
            - name: fastcgi
              containerPort: 9000
          envFrom:
            {{- include "lp.envFrom" . | nindent 12 }}
          startupProbe:
            tcpSocket:
              port: fastcgi
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            tcpSocket:
              port: fastcgi
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]
          resources:
            {{- toYaml .Values.web.phpFpm.resources | nindent 12 }}
          volumeMounts:
            - name: tmp-php
              mountPath: /tmp
            - name: uploads
              mountPath: /var/www/html/wp-content/uploads
        - name: nginx
          image: {{ include "lp.nginxImage" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- include "lp.containerSecurity" . | nindent 12 }}
            runAsUser: 101
            runAsGroup: 101
          ports:
            - name: http
              containerPort: 8080
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz.php
              port: http
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]
          resources:
            {{- toYaml .Values.web.nginx.resources | nindent 12 }}
          volumeMounts:
            - name: tmp-nginx
              mountPath: /tmp
            - name: uploads
              mountPath: /var/www/html/wp-content/uploads
              readOnly: true
      volumes:
        - name: tmp-php
          emptyDir:
            sizeLimit: 256Mi
        - name: tmp-nginx
          emptyDir:
            sizeLimit: 128Mi
        {{- include "lp.uploadsVolume" . | nindent 8 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

The details that make this Deployment production-shaped:

  • Rollout. maxUnavailable: 0 with a surge keeps full capacity during a release: a new pod must pass readiness before an old one is removed. A preStop sleep of ten seconds gives the load balancer time to deregister the pod before nginx stops, and terminationGracePeriodSeconds covers it plus in-flight requests.

  • Replicas. replicas is omitted when the HPA is enabled, so a helm upgrade does not reset the scaled count.

  • Spread. Two topology spread constraints (zone and node) with ScheduleAnyway prefer an even spread without blocking scheduling when capacity is short; use DoNotSchedule for the zone constraint when a zone outage must never leave the site on one zone.

  • Security. Pod level: fsGroup: 33 and the default seccomp profile. Container level: non-root user, no privilege escalation, read-only root file system and all capabilities dropped. The only writable paths are two emptyDir volumes for /tmp and the uploads volume; the code is not writable.

  • Probes. The startup probe protects slow starts, the FPM liveness probe is a TCP check on port 9000, nginx liveness is /healthz and readiness is /readyz.php. After a database failover the pods become unready for the duration of the failover and rejoin by themselves.

  • Resources. Requests drive scheduling and the HPA’s CPU percentage; the memory limit is the OOM kill line. Size the FPM container from pm.max_children times the memory a request uses: 16 workers at an assumed 40 MB each is why the limit is 768 MiB; measure your own plugins. There is no CPU limit, so a pod can use idle CPU; add one if noisy neighbours are a problem.

  • Config rollout. The checksum/config annotation changes when the ConfigMap changes, which rolls the pods.

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  type: ClusterIP
  selector:
    {{- include "lp.selector" . | nindent 4 }}
  ports:
    - name: http
      port: 80
      targetPort: http
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Release.Name }}
  labels:
    {{- include "lp.labels" . | nindent 4 }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ .Release.Name }}-web
                port:
                  name: http
{{- end }}

The Ingress selects the Auto Mode class and carries the annotations of the load balancer: the target type ip (the ALB sends traffic straight to pod addresses), the health check on /healthz, the redirect from HTTP to HTTPS and the WAF web ACL. The ALB sets X-Forwarded-Proto and X-Forwarded-For on every request. If your chart runs on a cluster with the self-managed AWS Load Balancer Controller, the annotations are the same family and the certificate ARN is added as an annotation too.

{{- if .Values.httpRoute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Release.Name }}
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  parentRefs:
    - name: {{ .Values.httpRoute.parentRef.name }}
      namespace: {{ .Values.httpRoute.parentRef.namespace }}
  hostnames:
    - {{ .Values.httpRoute.host }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: {{ .Release.Name }}-web
          port: 80
{{- end }}

The Gateway API alternative: an HTTPRoute attached to a Gateway that the platform team owns. It renders only when httpRoute.enabled is set. On EKS, Gateway API support needs the AWS Load Balancer Controller (see Gateway API and Deploying Microservices on EKS). Use one of the two, not both.

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ .Release.Name }}-web
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
{{- end }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ .Release.Name }}-web
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
  selector:
    matchLabels:
      {{- include "lp.selector" . | nindent 6 }}

The HPA scales on CPU utilisation against the requests, with a five-minute scale-down window so traffic waves do not cause flapping. Scaling on PHP-FPM saturation or request rate is covered on the releases page. The PDB allows one pod to be disrupted at a time; EKS Auto Mode replaces nodes at least every 21 days, and a PDB that is too strict blocks those drains.

{{- if .Values.cron.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
  name: {{ .Release.Name }}-cron
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  schedule: {{ .Values.cron.schedule | quote }}
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 120
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 0
      activeDeadlineSeconds: 280
      ttlSecondsAfterFinished: 3600
      template:
        metadata:
          labels:
            app.kubernetes.io/name: {{ include "lp.name" . }}
            app.kubernetes.io/instance: {{ .Release.Name }}
            app.kubernetes.io/component: cron
        spec:
          restartPolicy: Never
          serviceAccountName: {{ .Values.serviceAccount.name }}
          automountServiceAccountToken: false
          securityContext:
            fsGroup: 33
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: wp-cron
              image: {{ include "lp.phpImage" . }}
              securityContext:
                {{- include "lp.containerSecurity" . | nindent 16 }}
                runAsUser: 33
                runAsGroup: 33
              command: ["wp", "--path=/var/www/html", "cron", "event", "run", "--due-now"]
              envFrom:
                {{- include "lp.envFrom" . | nindent 16 }}
              resources:
                requests: { cpu: 100m, memory: 256Mi }
                limits: { memory: 512Mi }
              volumeMounts:
                - name: tmp
                  mountPath: /tmp
                - name: uploads
                  mountPath: /var/www/html/wp-content/uploads
          volumes:
            - name: tmp
              emptyDir: {}
            {{- include "lp.uploadsVolume" . | nindent 12 }}
{{- end }}

The scheduler. Its design, the settings and the Multisite variant are explained on Object Cache, Sessions and Cron in a Cluster.

{{- if .Values.migrate.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate
  labels:
    {{- include "lp.labels" . | nindent 4 }}
  annotations:
    # Runs before the new Deployment is rolled out, with the new image.
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "lp.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
        app.kubernetes.io/component: migrate
    spec:
      restartPolicy: Never
      serviceAccountName: {{ .Values.serviceAccount.name }}
      automountServiceAccountToken: false
      securityContext:
        fsGroup: 33
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: update-db
          image: {{ include "lp.phpImage" . }}
          securityContext:
            {{- include "lp.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          command: ["wp", "--path=/var/www/html", "core", "update-db"]
          envFrom:
            {{- include "lp.envFrom" . | nindent 12 }}
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { memory: 512Mi }
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
{{- end }}

A WordPress update can include a database schema change that wp core update-db applies. The hook Job runs it with the new image before Helm rolls out the new Deployment, so the new pods never see an old schema. The details to know:

  • The hook is pre-upgrade, not pre-install: on the first install the database is empty and the install Job creates the schema. The before-hook-creation delete policy removes a previous Job of the same name so the hook can run again.

  • The Job uses the application database user in this example. The design with a separate wp_migrate user that holds the schema-altering privileges, and the choice to give the application user data privileges only, is described on Managed Database: RDS and Aurora; to use it, point the hook at a second Secret key.

  • A pre-upgrade hook runs while the old pods still serve traffic. The migration must therefore be compatible with the old code for the duration of the rollout, which is the standard expand-and-contract discipline. WordPress core schema changes are, in practice, additive.

  • If the Job fails, helm upgrade fails and nothing is rolled out. The full sequence and rollback limits are on Releases, Upgrades and Autoscaling on Kubernetes.

{{- if .Values.install.enabled }}
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-install
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: {{ .Values.secrets.storeName }}
  target:
    name: {{ .Release.Name }}-install
  data:
    - secretKey: ADMIN_PASSWORD
      remoteRef:
        key: {{ .Values.install.secretKey }}
        property: ADMIN_PASSWORD
---
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-install
  labels:
    {{- include "lp.labels" . | nindent 4 }}
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 900
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "lp.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
        app.kubernetes.io/component: install
    spec:
      restartPolicy: Never
      serviceAccountName: {{ .Values.serviceAccount.name }}
      automountServiceAccountToken: false
      securityContext:
        fsGroup: 33
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: core-install
          image: {{ include "lp.phpImage" . }}
          securityContext:
            {{- include "lp.containerSecurity" . | nindent 12 }}
            runAsUser: 33
            runAsGroup: 33
          command:
            - /bin/sh
            - -ec
            - |
              wp --path=/var/www/html core is-installed && { echo "already installed"; exit 0; }
              wp --path=/var/www/html core install \
                --url="$WP_HOME" \
                --title={{ .Values.install.title | quote }} \
                --admin_user={{ .Values.install.adminUser | quote }} \
                --admin_email={{ .Values.install.adminEmail | quote }} \
                --admin_password="$ADMIN_PASSWORD" \
                --skip-email
              wp --path=/var/www/html rewrite structure '/%postname%/'
          envFrom:
            {{- include "lp.envFrom" . | nindent 12 }}
            - secretRef:
                name: {{ .Release.Name }}-install
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { memory: 512Mi }
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: uploads
              mountPath: /var/www/html/wp-content/uploads
      volumes:
        - name: tmp
          emptyDir: {}
        {{- include "lp.uploadsVolume" . | nindent 8 }}
{{- end }}

The first install is a Job, run by a person or a pipeline once per empty database: wp core is-installed makes it safe to run twice, wp core install creates the tables, the administrator and the first post, and wp rewrite structure stores pretty permalinks in the database (no .htaccess is needed with nginx). The administrator password is read from a separate secret, lumenpress/prod/wordpress-install, and never appears in the values. The shell body of this Job passed shellcheck. The Job is not a Helm hook, so helm upgrade --wait waits for it and the release fails if it does. Disable it again (install.enabled=false) once the site exists: a Job’s pod template cannot be changed in place, so an upgrade that changes the image with the Job still enabled is rejected.

First deployment

The deployment script ties the pieces together. The commands are the ones run for production, once the OpenTofu apply is done, the secrets are seeded and the image is pushed:

#!/usr/bin/env bash
# First deployment of the chart to lumenpress-prod, then the checks.
set -euo pipefail

sha="${1:?usage: deploy.sh <git-sha>}"
region="eu-west-1"

aws eks update-kubeconfig --name lumenpress-prod --region "$region"

# Once per cluster: the namespace, the ClusterSecretStore and the IngressClass.
kubectl apply -f cluster-manifests.yaml

# First install of the chart. The install Job creates the site in an empty database.
helm upgrade --install lumenpress ./lumenpress-wordpress \
  --namespace lumenpress \
  --set image.tag="$sha" \
  --set install.enabled=true \
  --wait --timeout 10m

kubectl -n lumenpress rollout status deployment/lumenpress-web
kubectl -n lumenpress get pods,ingress,hpa,pdb,cronjob
kubectl -n lumenpress logs job/lumenpress-install

# Later releases: the migrate Job runs as a pre-upgrade hook before the rollout.
# helm upgrade lumenpress ./lumenpress-wordpress -n lumenpress --reuse-values --set install.enabled=false --set image.tag="$sha"

helm upgrade --install --wait creates the release, waits for the Deployment and the install Job to be ready, and returns non-zero when they are not. Then the script checks the rollout and prints the objects and the install log. The script passed shellcheck.

Verifying the deployment

kubectl -n lumenpress get pods -o wide
kubectl -n lumenpress get ingress lumenpress -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
curl -fsS -o /dev/null -w '%{http_code}\n' https://www.lumenpress.example/healthz
curl -fsSI https://www.lumenpress.example/ | head -n 5
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html core version
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html option get siteurl
kubectl -n lumenpress exec deploy/lumenpress-web -c php-fpm -- wp --path=/var/www/html cache type

Check that: every pod shows 2/2 ready containers and is spread over the zones; the Ingress has an ALB host name and the www record points at it (Route 53 alias or ExternalDNS, see Route 53 and Certificates); /healthz returns 200 through the load balancer; siteurl is the https:// address and not an internal one; the object cache reports Redis; and a login works and survives a reload on a different pod. A log line per request appears with kubectl logs deploy/lumenpress-web -c nginx.

Common problems

  • Redirect loop on the dashboard. X-Forwarded-Proto is not reaching PHP or WP_HOME is http://. Check the ALB listener (HTTPS to HTTP target) and the value in the ConfigMap.

  • Pods stay Pending on the PVC. The persistent volume has the wrong file system or access point ID, or the EFS add-on is not healthy. kubectl describe pvc and the events of the CSI driver show the mount error.

  • CreateContainerConfigError. The Secret lumenpress-secrets does not exist yet: the ExternalSecret has not synced, for example because the secret has no value yet. kubectl describe externalsecret shows the provider error.

  • HTTP 500 on every request, Configuration error in the logs. A required environment variable is missing; the log line names it.

  • Upload fails with a permission error. The EFS access point owner is not UID 33, or the volume was mounted without it. The access point of the OpenTofu configuration fixes ownership.

  • The migrate hook fails with Error establishing a database connection. The hook runs before the new ConfigMap and Secret values reach the pod if they changed in the same upgrade; change them in a separate release.

  • helm upgrade fails with an immutable field. The install Job is still enabled with a new image, or a selector was changed. Disable the Job; never change spec.selector of a Deployment in place.

  • Admin cannot install plugins. Intended: DISALLOW_FILE_MODS. Plugins arrive through the image.

Version notes and what was not verified

  • Validated locally: docker build of both targets, a smoke test of the two containers running read-only and non-root, php -l of wp-config.php and readyz.php, nginx -t, helm lint, helm template | kubeconform -strict against the 1.36 schemas, shellcheck of the scripts and the Job shell body.

  • Not verified on a live cluster: the External Secrets Operator pod picking up Pod Identity credentials for the empty auth block, the Auto Mode IngressClass with these annotations and the EFS static provisioning against a real access point. The manifests follow the current documentation of each project; test them in staging first.

  • The image tag wordpress:7.1.4-php8.4-fpm existed on Docker Hub on 2026-10-29 (pulled for the build). Pin a digest in the Dockerfile if you need byte-for-byte reproducible builds.

  • Bitnami’s WordPress image and chart are not used: the public Bitnami images were moved to a legacy repository without updates in 2025 and the catalogue is a paid product, so this chart builds on the official image (see Cloud Reference Architecture).

  • Kubernetes 1.34 leaves EKS standard support on 2026-12-02; the chart requires 1.34 or later (kubeVersion).