Skip to main content

Hosting WordPress on Kubernetes with Ansible and Gateway API

A complete walkthrough of deploying WordPress on Kubernetes using Ansible and Gateway API routing—no Ingress object required.

This guide walks through deploying a production-grade WordPress site on Kubernetes using Ansible. The stack runs on Talos Linux with the official WordPress Docker image, a MySQL 8.0 StatefulSet backed by Longhorn storage, and traffic routing via the Kubernetes Gateway API.

Stack Overview

  • WordPress: wordpress:6.9.1-php8.3-apache — 3 replicas, RollingUpdate with zero downtime
  • Database: MySQL 8.0 StatefulSet with Longhorn RWO storage (20Gi)
  • Storage: Longhorn RWX PVC (15Gi) shared across all WordPress replicas
  • Ingress: Kubernetes Gateway API HTTPRoute
  • Secrets: Injected by your vault provider at deploy time

The Playbook

The deployment is a two-role playbook. The first role pulls secrets from your vault; the second role provisions all Kubernetes resources.

---
# Requires vault secrets: thedougie_db_password, thedougie_db_root_password, thedougie_admin_password
# Deploys: MySQL 8.0, WordPress 6.9.1 php8.3-apache (3 replicas), Gateway API HTTPRoute
- name: Deploy thedougie WordPress
  hosts: localhost
  connection: local
  gather_facts: false
  vars_files:
    - ../group_vars/talos_cluster.yml
  roles:
    - role: your_secrets_vault
    - role: thedougie-wordpress

Role Defaults

All tunables live in defaults/main.yml. Override any of these in your inventory or vars files.

thedougie_namespace: thedougie-wordpress

# WordPress image
thedougie_image: wordpress
thedougie_image_tag: 6.9.1-php8.3-apache
thedougie_replicas: 3

# DNS / URL
thedougie_ingress_host: www.thedougie.com

# WordPress storage (Longhorn RWX -- shared across replicas)
thedougie_storage_class: longhorn
thedougie_storage_size: 15Gi

# MySQL
thedougie_mysql_image: mysql:8.0
thedougie_mysql_server: thedougie-mysql
thedougie_mysql_port: 3306
thedougie_mysql_user: wordpress
thedougie_mysql_database: wordpress
thedougie_mysql_storage_class: longhorn
thedougie_mysql_storage_size: 20Gi

# PHP configuration
thedougie_php_upload_max_filesize: 64M
thedougie_php_post_max_size: 64M
thedougie_php_memory_limit: 256M
thedougie_php_max_execution_time: 300

# WordPress resource limits
thedougie_resources:
  requests: { cpu: 250m, memory: 512Mi }
  limits:   { cpu: 1500m, memory: 1024Mi }

# MySQL resource limits
thedougie_mysql_resources:
  requests: { cpu: 250m, memory: 1024Mi }
  limits:   { cpu: 2000m, memory: 2048Mi }

Kubernetes Resources

Namespace and Secrets

The role creates a namespace and a Kubernetes Secret containing the MySQL passwords and WordPress admin password. These values are injected from your vault at deploy time.

- name: Create thedougie namespace
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Namespace
      metadata:
        name: "{{ thedougie_namespace }}"

- name: Create thedougie secrets
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Secret
      metadata:
        name: thedougie-secrets
        namespace: "{{ thedougie_namespace }}"
      stringData:
        MYSQL_PASSWORD:      "{{ thedougie_db_password }}"
        MYSQL_ROOT_PASSWORD: "{{ thedougie_db_root_password }}"
        WP_ADMIN_PASSWORD:   "{{ thedougie_admin_password }}"

MySQL StatefulSet

MySQL runs as a StatefulSet with a Longhorn RWO PVC for data durability. A headless Service provides stable DNS for the StatefulSet pod, while a standard ClusterIP Service is used by WordPress for connections.

- name: Create MySQL PVC
  kubernetes.core.k8s:
    state: present
    apply: false
    definition:
      apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
        name: thedougie-mysql-data
        namespace: "{{ thedougie_namespace }}"
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: "{{ thedougie_mysql_storage_class }}"
        resources:
          requests:
            storage: "{{ thedougie_mysql_storage_size }}"

- name: Create MySQL headless Service
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Service
      metadata:
        name: "{{ thedougie_mysql_server }}-headless"
        namespace: "{{ thedougie_namespace }}"
      spec:
        clusterIP: None
        selector:
          app: thedougie-mysql
        ports:
          - port: 3306
            targetPort: 3306

- name: Create MySQL StatefulSet
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: apps/v1
      kind: StatefulSet
      metadata:
        name: thedougie-mysql
        namespace: "{{ thedougie_namespace }}"
      spec:
        serviceName: "{{ thedougie_mysql_server }}-headless"
        replicas: 1
        podManagementPolicy: OrderedReady
        selector:
          matchLabels:
            app: thedougie-mysql
        template:
          metadata:
            labels:
              app: thedougie-mysql
          spec:
            terminationGracePeriodSeconds: 60
            affinity:
              nodeAffinity:
                requiredDuringSchedulingIgnoredDuringExecution:
                  nodeSelectorTerms:
                    - matchExpressions:
                        - key: kubernetes.io/hostname
                          operator: NotIn
                          values: "{{ thedougie_mysql_excluded_nodes }}"
            containers:
              - name: mysql
                image: "{{ thedougie_mysql_image }}"
                env:
                  - name: MYSQL_DATABASE
                    value: "{{ thedougie_mysql_database }}"
                  - name: MYSQL_USER
                    value: "{{ thedougie_mysql_user }}"
                  - name: MYSQL_PASSWORD
                    valueFrom:
                      secretKeyRef:
                        name: thedougie-secrets
                        key: MYSQL_PASSWORD
                  - name: MYSQL_ROOT_PASSWORD
                    valueFrom:
                      secretKeyRef:
                        name: thedougie-secrets
                        key: MYSQL_ROOT_PASSWORD
                readinessProbe:
                  exec:
                    command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
                  initialDelaySeconds: 10
                  periodSeconds: 5
                livenessProbe:
                  exec:
                    command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
                  initialDelaySeconds: 30
                  periodSeconds: 10
                resources: "{{ thedougie_mysql_resources }}"
                volumeMounts:
                  - name: mysql-data
                    mountPath: /var/lib/mysql
            volumes:
              - name: mysql-data
                persistentVolumeClaim:
                  claimName: thedougie-mysql-data

- name: Create MySQL ClusterIP Service
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Service
      metadata:
        name: "{{ thedougie_mysql_server }}"
        namespace: "{{ thedougie_namespace }}"
      spec:
        selector:
          app: thedougie-mysql
        ports:
          - port: 3306
            targetPort: 3306

WordPress Deployment

WordPress runs as a 3-replica Deployment sharing a single Longhorn RWX PVC. The RollingUpdate strategy with maxUnavailable: 0 ensures zero downtime during image updates. Pod anti-affinity spreads replicas across nodes where possible.

- name: Create PHP ConfigMap
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: ConfigMap
      metadata:
        name: thedougie-php-config
        namespace: "{{ thedougie_namespace }}"
      data:
        uploads.ini: |
          upload_max_filesize = {{ thedougie_php_upload_max_filesize }}
          post_max_size       = {{ thedougie_php_post_max_size }}
          memory_limit        = {{ thedougie_php_memory_limit }}
          max_execution_time  = {{ thedougie_php_max_execution_time }}

- name: Create WordPress PVC
  kubernetes.core.k8s:
    state: present
    apply: false
    definition:
      apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
        name: thedougie-wordpress-data
        namespace: "{{ thedougie_namespace }}"
      spec:
        accessModes: [ReadWriteMany]
        storageClassName: "{{ thedougie_storage_class }}"
        resources:
          requests:
            storage: "{{ thedougie_storage_size }}"

- name: Create WordPress Deployment
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: thedougie-wordpress
        namespace: "{{ thedougie_namespace }}"
      spec:
        replicas: "{{ thedougie_replicas }}"
        strategy:
          type: RollingUpdate
          rollingUpdate:
            maxUnavailable: 0
            maxSurge: 1
        selector:
          matchLabels:
            app: thedougie-wordpress
        template:
          metadata:
            labels:
              app: thedougie-wordpress
          spec:
            affinity:
              podAntiAffinity:
                preferredDuringSchedulingIgnoredDuringExecution:
                  - weight: 100
                    podAffinityTerm:
                      labelSelector:
                        matchLabels:
                          app: thedougie-wordpress
                      topologyKey: kubernetes.io/hostname
            containers:
              - name: wordpress
                image: "{{ thedougie_image }}:{{ thedougie_image_tag }}"
                env:
                  - name: WORDPRESS_DB_HOST
                    value: "{{ thedougie_mysql_server }}:{{ thedougie_mysql_port }}"
                  - name: WORDPRESS_DB_USER
                    value: "{{ thedougie_mysql_user }}"
                  - name: WORDPRESS_DB_NAME
                    value: "{{ thedougie_mysql_database }}"
                  - name: WORDPRESS_TABLE_PREFIX
                    value: "{{ thedougie_table_prefix }}"
                  - name: WORDPRESS_DB_PASSWORD
                    valueFrom:
                      secretKeyRef:
                        name: thedougie-secrets
                        key: MYSQL_PASSWORD
                  - name: WORDPRESS_CONFIG_EXTRA
                    value: |
                      define('WP_HOME',    'https://{{ thedougie_ingress_host }}');
                      define('WP_SITEURL', 'https://{{ thedougie_ingress_host }}');
                startupProbe:
                  httpGet:
                    path: /wp-login.php
                    port: 80
                  failureThreshold: 30
                  periodSeconds: 10
                readinessProbe:
                  tcpSocket:
                    port: 80
                  initialDelaySeconds: 5
                  periodSeconds: 5
                livenessProbe:
                  httpGet:
                    path: /wp-login.php
                    port: 80
                  failureThreshold: 8
                  periodSeconds: 30
                resources: "{{ thedougie_resources }}"
                volumeMounts:
                  - name: wordpress-data
                    mountPath: /var/www/html
                  - name: php-config
                    mountPath: /usr/local/etc/php/conf.d/uploads.ini
                    subPath: uploads.ini
            volumes:
              - name: wordpress-data
                persistentVolumeClaim:
                  claimName: thedougie-wordpress-data
              - name: php-config
                configMap:
                  name: thedougie-php-config

- name: Create WordPress Service
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: v1
      kind: Service
      metadata:
        name: thedougie-wordpress
        namespace: "{{ thedougie_namespace }}"
      spec:
        selector:
          app: thedougie-wordpress
        ports:
          - port: 80
            targetPort: 80

Gateway API HTTPRoute

Traffic is routed using the Kubernetes Gateway API rather than a controller-specific Ingress resource. The HTTPRoute attaches to an existing gateway on the websecure listener.

- name: Create HTTPRoute
  kubernetes.core.k8s:
    state: present
    definition:
      apiVersion: gateway.networking.k8s.io/v1
      kind: HTTPRoute
      metadata:
        name: thedougie-wordpress
        namespace: "{{ thedougie_namespace }}"
      spec:
        parentRefs:
          - name: default-gateway
            namespace: default
            sectionName: websecure
        hostnames:
          - "{{ thedougie_ingress_host }}"
        rules:
          - matches:
              - path:
                  type: PathPrefix
                  value: /
            backendRefs:
              - name: thedougie-wordpress
                port: 80

Running the Playbook

ansible-playbook playbooks/16-deploy-thedougie-wordpress.yml

Ansible provisions all resources in order, then waits for MySQL and WordPress to become available before reporting the site URL.

Verifying the Deployment

# Check all resources in the namespace
kubectl get all -n thedougie-wordpress

# Verify the HTTPRoute was accepted by the gateway
kubectl get httproute -n thedougie-wordpress

# Check WordPress pods are running
kubectl get pods -n thedougie-wordpress -l app=thedougie-wordpress

Once all pods are ready, the site is live at https://www.thedougie.com and the WordPress admin is at https://www.thedougie.com/wp-admin/.

Get new posts delivered to your inbox