Back to Blog
DevOps & Deployment

Production WordPress with Docker and Kubernetes: From Docker Compose to Orchestrated Deployments

Marcus Chen
41 min read

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: 30 to give in-flight requests time to complete before a pod is terminated.
  • Use a preStop lifecycle 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 near pm.max_children means 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:

  1. Code push: Developer merges a PR into the main branch.
  2. 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.
  3. Image push: The tagged image is pushed to the container registry.
  4. Helm deploy: Helm upgrades the release with the new image tag. Kubernetes begins a rolling update.
  5. New pods start: For each new pod:
    • The wait-for-deps init container verifies database and Redis connectivity.
    • The copy-code init container copies WordPress files to the shared volume.
    • The db-migrate init container runs any pending database schema updates.
  6. Main containers start: Nginx and PHP-FPM containers start. The PHP-FPM postStart hook pre-warms the OPcache.
  7. Readiness probe passes: Kubernetes adds the pod to the Service endpoint. Traffic begins flowing to the new pod.
  8. Old pods drain: Kubernetes sends SIGTERM to old pods. The terminationGracePeriodSeconds gives in-flight requests time to complete. The old pod is removed from the Service endpoint.
  9. Rolling update completes: All old pods are replaced. Helm reports success.
  10. HPA adjusts: The Horizontal Pod Autoscaler monitors CPU and memory utilization, scaling the number of replicas up or down based on traffic.
  11. 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.

Share this article

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.