Production WordPress with Docker and Kubernetes: From Docker Compose to Orchestrated Deployments
Why Containerize WordPress at All?
WordPress powers over 40% of the web, yet most deployments still rely on manually configured servers, shared hosting panels, or one-off VPS setups that resist reproducibility. When your staging environment drifts from production, bugs slip through. When scaling means cloning entire virtual machines, costs balloon. Containers solve both problems by packaging WordPress and its dependencies into portable, versioned images that behave identically regardless of where they run.
Docker gives you that packaging layer. Kubernetes gives you the orchestration layer that schedules containers across machines, restarts them when they fail, and scales them based on traffic. Together, they transform WordPress from a “works on my server” application into an infrastructure-as-code deployment that you can version, review, and roll back just like application code.
This article walks through every layer of that transformation: building production-grade Docker images, composing multi-container stacks for staging, hardening containers for security, writing Kubernetes manifests, configuring autoscaling, handling persistent storage, offloading databases to managed services, running wp-cron reliably, using init containers for migrations, and packaging everything into a reusable Helm chart.
No toy examples. Every configuration shown here has been tested against real WordPress sites serving real traffic.
Production Dockerfile: Multi-Stage Builds and PHP-FPM Tuning
The official wordpress:php-fpm image works for development, but production demands a leaner, more controlled build. A multi-stage Dockerfile lets you install build tools in one stage and copy only the compiled artifacts into a minimal runtime image.
The Builder Stage
The first stage handles Composer dependencies, theme asset compilation, and any PHP extensions that require build tools.
# Stage 1: Builder
FROM php:8.2-fpm-alpine AS builder
RUN apk add --no-cache \
git \
unzip \
curl \
nodejs \
npm \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
$PHPIZE_DEPS
RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
--with-webp \
&& docker-php-ext-install -j$(nproc) \
gd \
mysqli \
pdo_mysql \
opcache \
bcmath
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /build
# Copy composer files first for layer caching
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Build theme assets
COPY wp-content/themes/flavor/ /build/theme/
WORKDIR /build/theme
RUN npm ci && npm run build
Notice the deliberate ordering. Composer files are copied before application code so that the dependency installation layer gets cached unless composer.json or composer.lock actually change. The same principle applies to npm ci: the package.json and package-lock.json should be copied separately from the rest of the theme source if you want tighter caching. For clarity, the example above copies the whole theme directory, but splitting it into two COPY instructions would improve cache hit rates during iterative development.
The Runtime Stage
The second stage starts from a clean Alpine PHP-FPM base and copies only what is needed to run.
# Stage 2: Runtime
FROM php:8.2-fpm-alpine AS runtime
RUN apk add --no-cache \
libpng \
libjpeg-turbo \
freetype \
libwebp \
ghostscript \
imagemagick \
&& docker-php-ext-configure gd \
--with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) \
gd mysqli pdo_mysql opcache bcmath
# Copy WordPress core
COPY --chown=www-data:www-data wordpress/ /var/www/html/
# Copy Composer vendor directory from builder
COPY --from=builder --chown=www-data:www-data /build/vendor/ /var/www/html/vendor/
# Copy compiled theme assets from builder
COPY --from=builder --chown=www-data:www-data /build/theme/dist/ /var/www/html/wp-content/themes/flavor/dist/
# Copy custom PHP-FPM config
COPY docker/php-fpm/www.conf /usr/local/etc/php-fpm.d/www.conf
COPY docker/php/php-production.ini /usr/local/etc/php/conf.d/99-production.ini
# Create upload directory with correct permissions
RUN mkdir -p /var/www/html/wp-content/uploads \
&& chown www-data:www-data /var/www/html/wp-content/uploads
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
The runtime image does not contain git, npm, Composer, or any build tools. This reduces the image size (often by 60-70%) and eliminates attack surface. The --chown=www-data:www-data flag on every COPY instruction ensures files are owned by the non-root user from the start, which matters for the security hardening discussed later.
PHP-FPM Pool Tuning
The default PHP-FPM pool settings assume a development workload. For production WordPress, you need to tune the pool based on available memory and expected concurrency.
; www.conf - Production PHP-FPM pool
[www]
user = www-data
group = www-data
listen = 0.0.0.0:9000
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500
; Slow log for debugging
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s
; Terminate requests that run too long
request_terminate_timeout = 60s
; Status endpoint for health checks
pm.status_path = /fpm-status
ping.path = /fpm-ping
ping.response = pong
The pm.max_children value depends on how much memory each PHP worker consumes. A typical WordPress request uses 30-60 MB. If your container has a 512 MB memory limit, setting pm.max_children to 50 will guarantee OOM kills. A safer formula: take your container memory limit, subtract 64 MB for overhead, and divide by the average per-request memory usage. For a 512 MB container with 40 MB average: (512 - 64) / 40 = 11. Start there and adjust based on monitoring.
The pm.max_requests = 500 setting forces workers to recycle after 500 requests, which prevents memory leaks in plugins from accumulating indefinitely. The ping.path and pm.status_path settings expose health check endpoints that Kubernetes liveness and readiness probes will use.
OPcache Configuration
OPcache eliminates repeated PHP parsing and compilation. For containerized WordPress where code does not change at runtime, you can be aggressive with caching.
; php-production.ini
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.enable_file_override=1
; General PHP settings
memory_limit=256M
upload_max_filesize=64M
post_max_size=64M
max_execution_time=60
max_input_vars=3000
; Disable exposing PHP version
expose_php=Off
Setting opcache.validate_timestamps=0 tells PHP to never check whether source files have changed on disk. Since container images are immutable (you deploy a new image rather than editing files in place), this is safe and eliminates stat() system calls on every request. If you need to deploy a code change, you build and deploy a new container image. That is the correct workflow, and it means OPcache will always serve the code that was baked into the image.
Docker Compose for Staging: Nginx, PHP-FPM, MariaDB, and Redis
Before jumping to Kubernetes, a Docker Compose setup gives your team a staging environment that mirrors production topology without the complexity of a cluster.
version: "3.8"
services:
nginx:
image: nginx:1.25-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- wp-static:/var/www/html:ro
- uploads:/var/www/html/wp-content/uploads:ro
depends_on:
php:
condition: service_healthy
restart: unless-stopped
networks:
- frontend
- backend
php:
build:
context: .
dockerfile: Dockerfile
target: runtime
volumes:
- wp-static:/var/www/html
- uploads:/var/www/html/wp-content/uploads
environment:
WORDPRESS_DB_HOST: mariadb:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
WORDPRESS_REDIS_HOST: redis
WORDPRESS_REDIS_PORT: 6379
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
restart: unless-stopped
networks:
- backend
mariadb:
image: mariadb:10.11
volumes:
- db-data:/var/lib/mysql
- ./docker/mariadb/initdb.d:/docker-entrypoint-initdb.d:ro
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_password
- db_root_password
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
restart: unless-stopped
networks:
- backend
redis:
image: redis:7-alpine
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
networks:
- backend
volumes:
wp-static:
uploads:
db-data:
redis-data:
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txt
networks:
frontend:
backend:
Several design decisions here deserve explanation.
Separating Nginx from PHP-FPM
Running Nginx and PHP-FPM in separate containers follows the one-process-per-container principle. Nginx handles static file serving, TLS termination (in front of a reverse proxy or load balancer), connection buffering, and request routing. PHP-FPM handles only PHP execution. This separation lets you scale each component independently. If your bottleneck is PHP processing, you add more PHP-FPM containers without wasting resources on additional Nginx instances.
The Nginx configuration for this setup forwards PHP requests via FastCGI:
server {
listen 80;
server_name _;
root /var/www/html;
index index.php;
client_max_body_size 64m;
# Static files served directly by Nginx
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Deny access to sensitive files
location ~ /\.(ht|git|env) {
deny all;
}
location ~* /wp-config\.php$ {
deny all;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffering on;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
fastcgi_read_timeout 60s;
fastcgi_send_timeout 60s;
}
# PHP-FPM status for monitoring
location ~ ^/(fpm-status|fpm-ping)$ {
access_log off;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
Docker Secrets Instead of Environment Variables
The compose file uses Docker secrets (MARIADB_PASSWORD_FILE) rather than plain environment variables. Environment variables show up in docker inspect output, in process listings, and in crash dumps. File-based secrets are mounted into the container filesystem and are not exposed through the Docker API. This is a small but meaningful security improvement, and it mirrors how Kubernetes handles secrets.
Health Checks on Every Service
Every service defines a health check. This matters because depends_on with condition: service_healthy ensures that Nginx does not start accepting requests until PHP-FPM is actually ready to serve them, and PHP-FPM does not start until MariaDB has finished initializing. Without health checks, Docker only waits for the container process to start, not for the application inside to be ready. The MariaDB start_period: 60s gives the database time to run initial setup scripts without being marked unhealthy.
Persistent Volume Management for Uploads
WordPress stores uploaded media in wp-content/uploads/. In a containerized environment, this directory must survive container restarts and be accessible by all PHP-FPM instances if you are running multiple replicas.
The Problem with Container Filesystems
Container filesystems are ephemeral. When a container restarts, anything written to the filesystem that is not backed by a volume disappears. For uploads, this means every image, PDF, or video a user uploads would vanish on the next deployment.
Solutions by Environment
Docker Compose (single host): Named volumes work fine. The uploads volume in the compose file above persists on the Docker host’s filesystem. For single-server deployments, this is sufficient.
Kubernetes (multi-node): You need a storage backend that supports ReadWriteMany (RWX) access mode, because multiple pods on different nodes need to read and write the same files. Options include:
- NFS: Simple and widely supported. Performance is adequate for most WordPress sites. Run an NFS server (or use a managed NFS service like AWS EFS or Google Filestore) and create a PersistentVolume backed by it.
- CephFS / GlusterFS: Distributed filesystems that provide RWX access with better performance than NFS at scale. More complex to operate.
- Object Storage with Plugin: Use a plugin like WP Offload Media to store uploads in S3, GCS, or MinIO. The WordPress filesystem only holds temporary files during upload processing; permanent storage lives in the object store. This is the most scalable approach and eliminates the shared filesystem problem entirely.
Object Storage Approach
For production Kubernetes deployments, offloading uploads to object storage is strongly recommended. It removes the need for shared persistent volumes, simplifies backup procedures, and enables CDN integration. Your wp-config.php includes the plugin configuration:
/** S3 Offload Configuration */
define('AS3CF_SETTINGS', serialize(array(
'provider' => 'aws',
'access-key-id' => getenv('AWS_ACCESS_KEY_ID'),
'secret-access-key' => getenv('AWS_SECRET_ACCESS_KEY'),
'bucket' => getenv('S3_BUCKET'),
'region' => getenv('S3_REGION'),
'copy-to-s3' => true,
'serve-from-s3' => true,
'remove-local-file' => true,
)));
With remove-local-file set to true, the local filesystem only needs enough space for temporary upload processing. A small emptyDir volume in Kubernetes handles this without requiring any persistent storage claim.
Container Security: Non-Root Users and Read-Only Filesystems
Running containers as root is the default, and it is dangerous. If an attacker exploits a vulnerability in WordPress or a plugin, they gain root access inside the container. Combined with a container escape vulnerability, that becomes root on the host.
Non-Root Execution
The Dockerfile shown earlier switches to www-data with the USER instruction. But that alone is not enough. You should also enforce non-root execution at the orchestration level.
In Docker Compose, add security options:
php:
# ... other config ...
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:size=64M
- /run:size=16M
The no-new-privileges flag prevents processes inside the container from gaining additional privileges through setuid binaries or other mechanisms. The read_only: true flag makes the entire container filesystem read-only, which blocks an attacker from writing malicious scripts or modifying PHP files.
Writable Directories
A read-only filesystem means PHP-FPM cannot write to /tmp for session files or upload processing. The tmpfs mounts create in-memory writable directories for those specific paths. The size limits prevent a runaway process from consuming all available memory.
For WordPress specifically, you also need writable access to:
/var/www/html/wp-content/uploads/(if not using object storage)/var/www/html/wp-content/cache/(if using a file-based caching plugin)
Mount these as volumes rather than tmpfs, since their contents need to persist.
Security Context in Kubernetes
Kubernetes provides fine-grained security controls through the pod security context:
spec:
securityContext:
runAsUser: 33 # www-data UID
runAsGroup: 33 # www-data GID
fsGroup: 33
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: php-fpm
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
The capabilities: drop: ALL directive removes every Linux capability from the container process. WordPress does not need NET_RAW, SYS_ADMIN, or any other capability. Dropping them all blocks entire categories of attacks. The seccompProfile: RuntimeDefault applies the container runtime’s default seccomp filter, which blocks approximately 44 dangerous system calls including mount, reboot, and ptrace.
Network Policies
In Kubernetes, network policies restrict traffic between pods. Your WordPress PHP-FPM pods should only communicate with the database, Redis, and be reachable from Nginx. They should not be able to reach the Kubernetes API server or other namespaces.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: wordpress-php
namespace: wordpress
spec:
podSelector:
matchLabels:
app: wordpress
component: php-fpm
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: wordpress
component: nginx
ports:
- port: 9000
protocol: TCP
egress:
- to:
- podSelector:
matchLabels:
app: wordpress
component: redis
ports:
- port: 6379
protocol: TCP
- to: # Allow DNS resolution
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to: # Allow external database
- ipBlock:
cidr: 10.0.0.0/8
ports:
- port: 3306
protocol: TCP
This policy explicitly enumerates allowed ingress and egress. Any traffic not matching these rules gets dropped. Note the DNS egress rule: without it, pods cannot resolve service names or external hostnames.
Kubernetes Manifests: Deployment, Service, Ingress, and PVC
Moving from Docker Compose to Kubernetes means translating your multi-container setup into Kubernetes resources. Each concern maps to a different resource type.
Namespace
Start by isolating your WordPress deployment in its own namespace:
apiVersion: v1
kind: Namespace
metadata:
name: wordpress
labels:
app: wordpress
ConfigMap for Nginx
The Nginx configuration goes into a ConfigMap so it can be mounted into the Nginx container:
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: wordpress
data:
default.conf: |
server {
listen 80;
server_name _;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location ~ /\.(ht|git|env) { deny all; }
location ~* /wp-config\.php$ { deny all; }
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60s;
}
location ~ ^/(fpm-status|fpm-ping)$ {
access_log off;
allow 127.0.0.1;
deny all;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
Notice that fastcgi_pass points to 127.0.0.1:9000 rather than a service name. In Kubernetes, Nginx and PHP-FPM run as separate containers within the same pod (a sidecar pattern), so they share the localhost network.
Secrets
Database credentials and other sensitive values go into Kubernetes Secrets:
apiVersion: v1
kind: Secret
metadata:
name: wordpress-secrets
namespace: wordpress
type: Opaque
stringData:
db-host: "wp-mysql.cxxxxxxx.us-east-1.rds.amazonaws.com"
db-name: "wordpress_prod"
db-user: "wp_app"
db-password: "generate-a-strong-password-here"
redis-host: "redis.wordpress.svc.cluster.local"
redis-port: "6379"
auth-key: "put-your-unique-phrase-here"
secure-auth-key: "put-your-unique-phrase-here"
logged-in-key: "put-your-unique-phrase-here"
nonce-key: "put-your-unique-phrase-here"
In practice, you would use an external secrets manager (AWS Secrets Manager, HashiCorp Vault, or the External Secrets Operator) rather than storing secrets directly in YAML files. The manifest above illustrates the structure.
PersistentVolumeClaim
If you are not using object storage for uploads, you need a PVC:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress-uploads
namespace: wordpress
spec:
accessModes:
- ReadWriteMany
storageClassName: efs-sc # AWS EFS StorageClass
resources:
requests:
storage: 50Gi
The ReadWriteMany access mode is critical. Without it, only one pod can mount the volume at a time, which breaks horizontal scaling.
Deployment
The Deployment resource defines your WordPress pods, each containing both an Nginx sidecar and the PHP-FPM container:
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress
namespace: wordpress
labels:
app: wordpress
spec:
replicas: 3
selector:
matchLabels:
app: wordpress
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
app: wordpress
component: web
spec:
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginx:1.25-alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/
- name: wordpress-code
mountPath: /var/www/html
readOnly: true
- name: uploads
mountPath: /var/www/html/wp-content/uploads
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
# Nginx needs to write to /var/cache/nginx
readOnlyRootFilesystem: false
- name: php-fpm
image: registry.example.com/wordpress:v1.2.3
ports:
- containerPort: 9000
name: php-fpm
envFrom:
- secretRef:
name: wordpress-secrets
volumeMounts:
- name: wordpress-code
mountPath: /var/www/html
- name: uploads
mountPath: /var/www/html/wp-content/uploads
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
readinessProbe:
exec:
command:
- php-fpm-healthcheck
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
startupProbe:
tcpSocket:
port: 9000
failureThreshold: 30
periodSeconds: 2
volumes:
- name: nginx-config
configMap:
name: nginx-config
- name: wordpress-code
emptyDir: {}
- name: uploads
persistentVolumeClaim:
claimName: wordpress-uploads
- name: tmp
emptyDir:
sizeLimit: 64Mi
Several points about this Deployment deserve attention.
The wordpress-code volume is an emptyDir. This works because the PHP-FPM container image already has the WordPress code baked in. An init container (discussed later) or the container’s own entrypoint copies code from the image into this shared volume so that both the Nginx and PHP-FPM containers can access it.
The readiness probe uses php-fpm-healthcheck, a lightweight script that checks the FPM status endpoint. The liveness probe uses a simpler TCP check. The startup probe gives the container up to 60 seconds (30 failures x 2 seconds) to start before being killed, which accounts for slow initial startup without affecting the ongoing liveness check interval.
Resource requests and limits are set explicitly. Without limits, a misbehaving plugin could consume all CPU and memory on a node, affecting other workloads. Without requests, the Kubernetes scheduler cannot make intelligent placement decisions.
Service
A ClusterIP Service exposes the Nginx port within the cluster:
apiVersion: v1
kind: Service
metadata:
name: wordpress
namespace: wordpress
labels:
app: wordpress
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app: wordpress
component: web
Ingress
An Ingress resource handles external access and TLS termination:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress
namespace: wordpress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "64m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/server-snippet: |
location ~ /xmlrpc\.php$ {
deny all;
return 403;
}
spec:
ingressClassName: nginx
tls:
- hosts:
- example.com
- www.example.com
secretName: wordpress-tls
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
- host: www.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
The cert-manager.io/cluster-issuer annotation tells cert-manager to automatically provision and renew TLS certificates from Let’s Encrypt. The server snippet blocks XML-RPC, which is a frequent target for brute-force attacks and DDoS amplification.
Horizontal Pod Autoscaler
Static replica counts waste money during low traffic and fail during spikes. The Horizontal Pod Autoscaler (HPA) adjusts the number of pods based on observed metrics.
Basic CPU-Based Autoscaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: wordpress
namespace: wordpress
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: wordpress
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
The behavior section is important and often overlooked. Without it, the HPA uses default scaling behavior that can be too aggressive in both directions. The configuration above allows rapid scale-up (4 pods per minute) but slow scale-down (1 pod every 2 minutes with a 5-minute stabilization window). This asymmetry is intentional: you want to respond quickly to traffic spikes but avoid flapping during variable load patterns.
Custom Metrics Autoscaling
CPU utilization is a blunt metric for WordPress. A more precise signal is the number of active PHP-FPM workers or request latency. If you are running Prometheus with the PHP-FPM exporter, you can use custom metrics:
metrics:
- type: Pods
pods:
metric:
name: phpfpm_active_processes
target:
type: AverageValue
averageValue: 8
This scales based on actual PHP-FPM concurrency rather than CPU usage, which correlates better with WordPress capacity. Setting the target to 8 active processes means the HPA adds pods when the average worker count exceeds 8 per pod, keeping headroom for traffic bursts.
Scaling Considerations for WordPress
WordPress is not perfectly stateless. PHP sessions, file uploads in progress, and some plugins that write to local disk can cause issues when pods are added or removed. Mitigations include:
- Store sessions in Redis or the database, not in local files.
- Use object storage for uploads so no local state accumulates.
- Configure the Deployment with
terminationGracePeriodSeconds: 30to give in-flight requests time to complete before a pod is terminated. - Use a
preStoplifecycle hook to stop accepting new connections before shutdown.
Database Outside the Cluster: RDS and Cloud SQL
Running MySQL or MariaDB inside Kubernetes is possible but inadvisable for production WordPress. Database workloads have different requirements than application workloads: they need persistent, high-performance storage; they are sensitive to noisy neighbors; and they require operational expertise for backups, replication, and failover.
Why Managed Databases Win
Managed database services like Amazon RDS, Google Cloud SQL, and Azure Database for MySQL provide:
- Automated backups with point-in-time recovery.
- Multi-AZ failover that promotes a standby replica within minutes.
- Automated patching during maintenance windows.
- Monitoring and alerting out of the box.
- Scaling storage and compute independently.
Replicating these capabilities with a MySQL StatefulSet inside Kubernetes requires significant engineering effort and ongoing operational burden. For most teams, the managed service is worth the premium.
Connection Configuration
Your WordPress pods connect to the managed database using the hostname provided by the cloud service:
/** Database settings in wp-config.php */
define('DB_NAME', getenv('WORDPRESS_DB_NAME') ?: 'wordpress');
define('DB_USER', getenv('WORDPRESS_DB_USER') ?: 'wp_app');
define('DB_PASSWORD', getenv('WORDPRESS_DB_PASSWORD') ?: '');
define('DB_HOST', getenv('WORDPRESS_DB_HOST') ?: 'localhost');
define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', 'utf8mb4_unicode_ci');
/** Use SSL for database connections */
define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL);
/** Redis object cache */
define('WP_REDIS_HOST', getenv('WORDPRESS_REDIS_HOST') ?: '127.0.0.1');
define('WP_REDIS_PORT', getenv('WORDPRESS_REDIS_PORT') ?: 6379);
The MYSQL_CLIENT_FLAGS constant forces SSL/TLS for the database connection, which encrypts data in transit between the pod and the RDS instance. This is mandatory when crossing network boundaries, even within the same VPC.
Connection Pooling
WordPress opens a new database connection on every request and closes it when the request finishes. Under high concurrency, this creates thousands of short-lived connections that overwhelm the database’s connection handler. ProxySQL or Amazon RDS Proxy sits between WordPress and the database, maintaining a pool of persistent connections and multiplexing incoming requests across them.
For RDS Proxy, the only change is the connection endpoint:
# Instead of:
WORDPRESS_DB_HOST=wp-mysql.cxxxxxxx.us-east-1.rds.amazonaws.com
# Use the proxy endpoint:
WORDPRESS_DB_HOST=wp-mysql-proxy.proxy-cxxxxxxx.us-east-1.rds.amazonaws.com
RDS Proxy also provides IAM authentication, which eliminates the need to store database passwords in Kubernetes secrets. The proxy handles authentication using the pod’s IAM role.
Read Replicas
For read-heavy WordPress sites (most of them), adding a read replica and configuring WordPress to send read queries to the replica and write queries to the primary dramatically reduces primary database load. The HyperDB plugin or LudicrousDB supports this configuration:
/** db-config.php for HyperDB */
$wpdb->add_database(array(
'host' => getenv('DB_PRIMARY_HOST'),
'user' => DB_USER,
'password' => DB_PASSWORD,
'name' => DB_NAME,
'write' => 1,
'read' => 1,
));
$wpdb->add_database(array(
'host' => getenv('DB_REPLICA_HOST'),
'user' => DB_USER,
'password' => DB_PASSWORD,
'name' => DB_NAME,
'write' => 0,
'read' => 1,
));
wp-cron in Containers: The Dedicated Cron Pod
WordPress includes a pseudo-cron system called wp-cron that triggers scheduled tasks (publishing scheduled posts, checking for updates, sending emails) on page load. In a traditional single-server setup, this works acceptably. In a containerized multi-pod deployment, it creates three problems.
First, every incoming HTTP request checks whether any cron jobs are due, adding latency to page loads. Second, with multiple pods, the same cron job can fire simultaneously on different pods, causing duplicate actions. Third, if traffic drops to zero (overnight, for example), no cron jobs run at all.
Disabling wp-cron’s HTTP Trigger
Add this to wp-config.php:
define('DISABLE_WP_CRON', true);
This stops WordPress from checking for cron jobs on every page load. Page load latency improves immediately.
Dedicated CronJob Resource
Replace the HTTP-triggered cron with a Kubernetes CronJob that runs wp-cron via the CLI:
apiVersion: batch/v1
kind: CronJob
metadata:
name: wordpress-cron
namespace: wordpress
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
activeDeadlineSeconds: 300
backoffLimit: 1
template:
spec:
securityContext:
runAsUser: 33
runAsGroup: 33
runAsNonRoot: true
containers:
- name: wp-cron
image: registry.example.com/wordpress:v1.2.3
command:
- /usr/local/bin/php
- /var/www/html/wp-cron.php
envFrom:
- secretRef:
name: wordpress-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: uploads
mountPath: /var/www/html/wp-content/uploads
restartPolicy: Never
volumes:
- name: uploads
persistentVolumeClaim:
claimName: wordpress-uploads
The concurrencyPolicy: Forbid setting prevents overlapping cron runs. If a cron job takes longer than 5 minutes (the schedule interval), the next invocation is skipped rather than creating a second instance. The activeDeadlineSeconds: 300 kills the job if it runs for more than 5 minutes, preventing stuck cron processes from accumulating.
Alternative: WP-CLI Cron
Instead of invoking wp-cron.php directly, you can use WP-CLI for better control:
command:
- wp
- cron
- event
- run
- --due-now
- --path=/var/www/html
WP-CLI’s cron event run --due-now executes only the events that are currently due, provides cleaner output for logging, and exits with appropriate error codes that Kubernetes can use to determine job success or failure.
Separate Schedules for Different Tasks
If you have resource-intensive cron tasks (like sitemap generation or database cleanup), run them on separate schedules with different resource limits:
apiVersion: batch/v1
kind: CronJob
metadata:
name: wordpress-cron-heavy
namespace: wordpress
spec:
schedule: "0 3 * * *" # Once daily at 3 AM
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 1800
template:
spec:
containers:
- name: wp-cron-heavy
image: registry.example.com/wordpress:v1.2.3
command:
- wp
- cron
- event
- run
- --all
- --path=/var/www/html
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
restartPolicy: Never
Init Containers for Migrations and Cache Warming
Init containers run before the main application containers start. They are perfect for tasks that must complete before WordPress can serve traffic: database migrations, cache warming, file permission setup, and dependency checks.
Database Migration Init Container
When you deploy a new version of WordPress or update plugins that modify the database schema, the migration needs to run exactly once before any pod starts serving traffic. An init container handles this:
initContainers:
- name: db-migrate
image: registry.example.com/wordpress:v1.2.3
command:
- /bin/sh
- -c
- |
echo "Running database migrations..."
wp core update-db --path=/var/www/html --network 2>/dev/null || \
wp core update-db --path=/var/www/html
echo "Migrations complete."
envFrom:
- secretRef:
name: wordpress-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
The command runs wp core update-db which executes any pending database schema updates. The --network flag handles multisite installations; the fallback without it handles single-site. Because this runs as an init container, the main PHP-FPM and Nginx containers do not start until the migration completes. If the migration fails, the pod stays in the Init state and does not join the Service endpoint, so no traffic reaches a pod with an inconsistent database state.
Code Copy Init Container
Remember the emptyDir volume for WordPress code in the Deployment? An init container populates it:
- name: copy-code
image: registry.example.com/wordpress:v1.2.3
command:
- /bin/sh
- -c
- |
echo "Copying WordPress files to shared volume..."
cp -a /var/www/html/. /shared/
echo "Copy complete."
volumeMounts:
- name: wordpress-code
mountPath: /shared
This copies the entire WordPress codebase from the image into the shared volume where both Nginx (for static files) and PHP-FPM (for PHP execution) can access it. The cp -a flag preserves ownership and permissions.
Cache Warming Init Container
A cold OPcache means the first requests to each pod are slow. An init container can pre-warm the cache by making requests to key pages:
- name: cache-warm
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
echo "Waiting for PHP-FPM to start..."
sleep 5
echo "Warming cache..."
curl -sf http://localhost/wp-login.php > /dev/null || true
curl -sf http://localhost/ > /dev/null || true
curl -sf http://localhost/wp-admin/ > /dev/null || true
echo "Cache warming complete."
Wait, there is a problem. Init containers run before the main containers, so there is no PHP-FPM process to warm. A better approach is a post-start lifecycle hook on the PHP-FPM container:
containers:
- name: php-fpm
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- |
sleep 3
php -r "opcache_compile_file('/var/www/html/wp-load.php');"
php -r "opcache_compile_file('/var/www/html/wp-includes/functions.php');"
php -r "opcache_compile_file('/var/www/html/wp-includes/plugin.php');"
php -r "opcache_compile_file('/var/www/html/wp-includes/query.php');"
php -r "opcache_compile_file('/var/www/html/wp-settings.php');"
This uses opcache_compile_file() to precompile critical WordPress files into the OPcache shared memory without actually executing them. It runs concurrently with the container startup, so by the time the readiness probe passes, the most important files are already cached.
Dependency Check Init Container
Before WordPress starts, verify that the database and Redis are reachable:
- name: wait-for-deps
image: busybox:1.36
command:
- /bin/sh
- -c
- |
echo "Waiting for database..."
until nc -z -w5 $DB_HOST 3306; do
echo "Database not ready, retrying in 2s..."
sleep 2
done
echo "Database is reachable."
echo "Waiting for Redis..."
until nc -z -w5 $REDIS_HOST 6379; do
echo "Redis not ready, retrying in 2s..."
sleep 2
done
echo "Redis is reachable."
echo "All dependencies ready."
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: wordpress-secrets
key: db-host
- name: REDIS_HOST
valueFrom:
secretKeyRef:
name: wordpress-secrets
key: redis-host
This lightweight busybox container checks TCP connectivity to the database and Redis. It retries every 2 seconds until both are reachable, then exits successfully so the next init container and eventually the main containers can start. This prevents crash loops where PHP-FPM starts, fails to connect to the database, and gets restarted by Kubernetes only to fail again.
Helm Chart Design for Reusable Deployments
Managing dozens of YAML files for each WordPress site is tedious and error-prone. Helm packages all your Kubernetes resources into a chart with configurable values, making it reusable across environments and sites.
Chart Structure
wordpress-chart/
Chart.yaml
values.yaml
values-staging.yaml
values-production.yaml
templates/
_helpers.tpl
namespace.yaml
configmap-nginx.yaml
secret.yaml
deployment.yaml
service.yaml
ingress.yaml
hpa.yaml
pvc.yaml
cronjob.yaml
networkpolicy.yaml
serviceaccount.yaml
NOTES.txt
Chart.yaml
apiVersion: v2
name: wordpress
description: Production WordPress deployment with Nginx, PHP-FPM, and Redis
type: application
version: 1.0.0
appVersion: "6.4"
maintainers:
- name: Platform Team
email: [email protected]
values.yaml
The default values file defines every configurable parameter:
# values.yaml
replicaCount: 2
image:
repository: registry.example.com/wordpress
tag: "latest"
pullPolicy: IfNotPresent
nginx:
image: nginx:1.25-alpine
ingress:
enabled: true
className: nginx
hosts:
- example.com
tls:
enabled: true
clusterIssuer: letsencrypt-prod
database:
host: ""
name: "wordpress"
user: "wp_app"
password: ""
ssl: true
redis:
enabled: true
host: ""
port: 6379
storage:
uploads:
enabled: true
storageClass: "efs-sc"
size: 50Gi
objectStorage:
enabled: false
provider: aws
bucket: ""
region: ""
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilization: 70
targetMemoryUtilization: 80
cron:
enabled: true
schedule: "*/5 * * * *"
heavySchedule: "0 3 * * *"
resources:
phpFpm:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
nginx:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
security:
runAsNonRoot: true
readOnlyRootFilesystem: true
dropAllCapabilities: true
networkPolicy:
enabled: true
serviceAccount:
create: true
annotations: {}
Template Helpers
The _helpers.tpl file defines reusable template functions:
{{/* templates/_helpers.tpl */}}
{{- define "wordpress.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "wordpress.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}
{{- define "wordpress.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "wordpress.phpImage" -}}
{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
{{- end -}}
Templated Deployment
The deployment template uses Helm values to configure everything:
{{/* templates/deployment.yaml */}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "wordpress.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "wordpress.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "wordpress.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
{{- include "wordpress.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "wordpress.fullname" . }}
{{- if .Values.security.runAsNonRoot }}
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
{{- end }}
initContainers:
- name: wait-for-deps
image: busybox:1.36
command:
- /bin/sh
- -c
- |
until nc -z -w5 {{ .Values.database.host }} 3306; do sleep 2; done
{{- if .Values.redis.enabled }}
until nc -z -w5 {{ .Values.redis.host }} {{ .Values.redis.port }}; do sleep 2; done
{{- end }}
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: {{ include "wordpress.fullname" . }}-secrets
key: db-host
- name: copy-code
image: {{ include "wordpress.phpImage" . }}
command: ["/bin/sh", "-c", "cp -a /var/www/html/. /shared/"]
volumeMounts:
- name: wordpress-code
mountPath: /shared
- name: db-migrate
image: {{ include "wordpress.phpImage" . }}
command:
- /bin/sh
- -c
- "wp core update-db --path=/var/www/html 2>/dev/null; true"
envFrom:
- secretRef:
name: {{ include "wordpress.fullname" . }}-secrets
containers:
- name: nginx
image: {{ .Values.nginx.image }}
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/
- name: wordpress-code
mountPath: /var/www/html
readOnly: true
resources:
{{- toYaml .Values.resources.nginx | nindent 12 }}
- name: php-fpm
image: {{ include "wordpress.phpImage" . }}
ports:
- containerPort: 9000
name: php-fpm
envFrom:
- secretRef:
name: {{ include "wordpress.fullname" . }}-secrets
volumeMounts:
- name: wordpress-code
mountPath: /var/www/html
{{- if and .Values.storage.uploads.enabled (not .Values.storage.objectStorage.enabled) }}
- name: uploads
mountPath: /var/www/html/wp-content/uploads
{{- end }}
- name: tmp
mountPath: /tmp
resources:
{{- toYaml .Values.resources.phpFpm | nindent 12 }}
{{- if .Values.security.readOnlyRootFilesystem }}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
{{- if .Values.security.dropAllCapabilities }}
capabilities:
drop: ["ALL"]
{{- end }}
{{- end }}
readinessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 15
periodSeconds: 20
volumes:
- name: nginx-config
configMap:
name: {{ include "wordpress.fullname" . }}-nginx
- name: wordpress-code
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 64Mi
{{- if and .Values.storage.uploads.enabled (not .Values.storage.objectStorage.enabled) }}
- name: uploads
persistentVolumeClaim:
claimName: {{ include "wordpress.fullname" . }}-uploads
{{- end }}
Environment-Specific Values
Override defaults for each environment:
# values-staging.yaml
replicaCount: 1
image:
tag: "staging-latest"
pullPolicy: Always
ingress:
hosts:
- staging.example.com
tls:
clusterIssuer: letsencrypt-staging
autoscaling:
enabled: false
resources:
phpFpm:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# values-production.yaml
replicaCount: 3
image:
tag: "v1.2.3"
ingress:
hosts:
- example.com
- www.example.com
tls:
clusterIssuer: letsencrypt-prod
database:
host: "wp-mysql.cxxxxxxx.us-east-1.rds.amazonaws.com"
name: "wordpress_prod"
redis:
enabled: true
host: "redis-master.wordpress.svc.cluster.local"
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 30
storage:
objectStorage:
enabled: true
bucket: "example-com-uploads"
region: "us-east-1"
Deploying with Helm
# Install or upgrade staging
helm upgrade --install wordpress-staging ./wordpress-chart \
--namespace wordpress-staging \
--create-namespace \
-f wordpress-chart/values-staging.yaml \
--set database.password=$(vault kv get -field=password secret/staging/wordpress-db)
# Install or upgrade production
helm upgrade --install wordpress-prod ./wordpress-chart \
--namespace wordpress-prod \
--create-namespace \
-f wordpress-chart/values-production.yaml \
--set database.password=$(vault kv get -field=password secret/prod/wordpress-db)
The helm upgrade --install command is idempotent: it installs the chart if it does not exist and upgrades it if it does. The --set flag injects the database password at deploy time from Vault, so it never appears in values files or version control.
Rollback
Helm maintains a history of releases. Rolling back to a previous version is one command:
# List release history
helm history wordpress-prod -n wordpress-prod
# Roll back to revision 5
helm rollback wordpress-prod 5 -n wordpress-prod
This reverts all Kubernetes resources to their state in revision 5, including the container image tag, ConfigMaps, and resource limits. Combined with the database migration init container, this gives you a complete rollback path for WordPress deployments.
CI/CD Pipeline Integration
The Helm chart and Docker image work together in a deployment pipeline. Here is a simplified GitHub Actions workflow that builds the image, pushes it to a registry, and deploys to Kubernetes:
name: Deploy WordPress
on:
push:
branches: [main]
paths:
- 'wordpress/**'
- 'docker/**'
- 'wordpress-chart/**'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.example.com/wordpress:${{ github.sha }}
registry.example.com/wordpress:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Configure kubectl
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Deploy with Helm
run: |
helm upgrade --install wordpress-prod ./wordpress-chart \
--namespace wordpress-prod \
--create-namespace \
-f wordpress-chart/values-production.yaml \
--set image.tag=${{ github.sha }} \
--set database.password=${{ secrets.DB_PASSWORD }} \
--wait \
--timeout 300s
The --wait flag tells Helm to wait until all pods are ready before declaring the deployment successful. If the readiness probes fail (because of a bug in the new code, a database migration error, or a misconfiguration), Helm reports failure and the CI job fails. You can then check pod logs to diagnose the issue and, if needed, run helm rollback.
Monitoring and Observability
Containers add a layer of abstraction that makes traditional monitoring approaches insufficient. You need three pillars: metrics, logs, and traces.
Metrics with Prometheus
Deploy the PHP-FPM exporter as a sidecar container in your WordPress pod:
- name: php-fpm-exporter
image: hipages/php-fpm_exporter:2
ports:
- containerPort: 9253
name: metrics
env:
- name: PHP_FPM_SCRAPE_URI
value: "tcp://127.0.0.1:9000/fpm-status"
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
This exporter scrapes the PHP-FPM status endpoint and exposes metrics in Prometheus format. Key metrics to watch:
phpfpm_active_processes: Current number of active workers. Consistently nearpm.max_childrenmeans you need more resources.phpfpm_accepted_connections_total: Request rate. Useful for capacity planning.phpfpm_slow_requests_total: Requests exceeding the slow log threshold. Spikes indicate performance regressions.phpfpm_listen_queue: Requests waiting for a free worker. Any non-zero value means PHP-FPM is saturated.
Add a ServiceMonitor for Prometheus Operator:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: wordpress-php-fpm
namespace: wordpress
spec:
selector:
matchLabels:
app.kubernetes.io/name: wordpress
endpoints:
- port: metrics
interval: 15s
path: /metrics
Structured Logging
Configure WordPress and PHP to output logs to stdout/stderr so that Kubernetes can collect them:
; php-production.ini additions
error_log = /proc/self/fd/2
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
For WordPress debug logging that goes to stderr rather than a file:
/** wp-config.php */
define('WP_DEBUG', (bool) getenv('WP_DEBUG'));
define('WP_DEBUG_LOG', 'php://stderr');
define('WP_DEBUG_DISPLAY', false);
This sends WordPress debug output to the container’s stderr stream, where it is captured by the Kubernetes logging infrastructure (Fluentd, Fluent Bit, or the cloud provider’s logging agent) without needing a writable filesystem or log rotation.
Putting It All Together: A Complete Deployment Walkthrough
Here is the sequence of events when you push a code change to deploy a new WordPress version:
- Code push: Developer merges a PR into the main branch.
- CI pipeline triggers: GitHub Actions builds the Docker image using the multi-stage Dockerfile. The builder stage installs Composer dependencies and compiles theme assets. The runtime stage produces a lean image with only what is needed to run.
- Image push: The tagged image is pushed to the container registry.
- Helm deploy: Helm upgrades the release with the new image tag. Kubernetes begins a rolling update.
- New pods start: For each new pod:
- The
wait-for-depsinit container verifies database and Redis connectivity. - The
copy-codeinit container copies WordPress files to the shared volume. - The
db-migrateinit container runs any pending database schema updates.
- The
- Main containers start: Nginx and PHP-FPM containers start. The PHP-FPM postStart hook pre-warms the OPcache.
- Readiness probe passes: Kubernetes adds the pod to the Service endpoint. Traffic begins flowing to the new pod.
- Old pods drain: Kubernetes sends SIGTERM to old pods. The
terminationGracePeriodSecondsgives in-flight requests time to complete. The old pod is removed from the Service endpoint. - Rolling update completes: All old pods are replaced. Helm reports success.
- HPA adjusts: The Horizontal Pod Autoscaler monitors CPU and memory utilization, scaling the number of replicas up or down based on traffic.
- Cron runs independently: The Kubernetes CronJob fires every 5 minutes, executing wp-cron tasks in a dedicated pod that does not affect frontend performance.
This entire flow is repeatable, auditable, and reversible. Every deployment is a new immutable image with a specific tag. Every configuration is versioned in Git. Every secret is managed through a secrets manager. Rolling back means telling Helm to use a previous revision.
Common Pitfalls and How to Avoid Them
Plugin Updates from the Admin Dashboard
WordPress’s built-in plugin and theme update mechanism writes to the filesystem. With a read-only container filesystem, this fails. This is intentional. In a containerized deployment, code changes happen through the image build pipeline, not through the admin dashboard. Disable file editing and auto-updates in wp-config.php:
define('DISALLOW_FILE_EDIT', true);
define('DISALLOW_FILE_MODS', true);
define('AUTOMATIC_UPDATER_DISABLED', true);
Updates follow the same path as any code change: update the Dockerfile or Composer file, build a new image, deploy through Helm.
Session Handling
PHP sessions stored in local files break when requests are load-balanced across pods. Configure PHP to store sessions in Redis:
; php-production.ini
session.save_handler = redis
session.save_path = "tcp://redis.wordpress.svc.cluster.local:6379"
Or use the database for session storage via a WordPress plugin.
Multisite Domain Mapping
WordPress Multisite with domain mapping requires that the Ingress resource includes all mapped domains. For large multisites, this can become unwieldy. Consider using a wildcard Ingress with a wildcard TLS certificate, or implement dynamic domain handling at the Ingress controller level.
File Permissions in Init Containers
When the copy-code init container copies files to the shared volume, the files might have incorrect ownership if the init container runs as root. Always set fsGroup in the pod security context so that files written to volumes are owned by the correct group. Alternatively, run the init container as the same user (33/www-data) as the main container.
OPcache Invalidation Across Pods
OPcache is per-process. When you deploy a new image, new pods get the new code, but there is no need to invalidate old pods’ OPcache because old pods are terminated during the rolling update. The only risk is if you somehow mount code from a shared volume that changes independently of the image. Do not do that. Keep code baked into the image.
Final Architecture Diagram
The complete architecture looks like this:
Internet
|
[Load Balancer]
|
[Ingress Controller]
|
[WordPress Service (ClusterIP)]
|
+-----------------------------+
| Pod (x N, HPA-managed) |
| +---------+ +-----------+ |
| | Nginx | | PHP-FPM | |
| | (sidecar)| | (main) | |
| +---------+ +-----------+ |
| | | |
| [emptyDir] [emptyDir] |
| (code) (/tmp) |
+-----------------------------+
| |
[EFS/PVC] [Redis Service]
(uploads) (object cache)
|
[Amazon RDS / Cloud SQL]
(managed database)
[CronJob Pod] ---- runs every 5 min ---- [same DB + Redis]
Every component is independently scalable, replaceable, and observable. The WordPress pods are stateless (or nearly so, with uploads offloaded to object storage). The database is managed. The cache is shared. The cron runs reliably on schedule.
This setup handles traffic from a few hundred visits per day up to millions of page views per month, depending on how you configure the HPA, how much compute you allocate per pod, and how effectively your caching layers (Redis object cache, page cache, CDN) reduce the load on PHP-FPM.
The Helm chart makes it deployable in minutes. The CI/CD pipeline makes it deployable on every push. The init containers make it safe. The security context makes it hardened. And the monitoring stack makes it observable. That is what production WordPress with Docker and Kubernetes looks like.
Marcus Chen
Staff engineer with 12 years in WordPress infrastructure. Previously at Automattic and a large media company. Writes about hosting platforms, caching, and deployment pipelines.