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/.