How To Set Up WordPress with MySQL on Kubernetes

Kubernetes Cluster Setup

Before any of this works you need a cluster to put it on. The usual shape is a control plane node that hands out work and manages resources, with one or more worker nodes actually running the containers. Label one master and the other worker node 1 and you have enough to follow along.
Every node talks to the master through kubectl, the binary package installed earlier in the tutorial. Nothing below will do anything until kubectl can actually reach your cluster, so if you’re not certain it can, check that first rather than debugging YAML that was never applied in the first place.
Then there’s storage, which is where this tutorial spends most of its effort, and where most people come unstuck. WordPress needs a volume that survives a reboot or a pod being deleted, and MySQL needs the same underneath it for the databases WordPress relies on. Containers are disposable by design. That’s a feature right up until the thing living inside one is your content. You can open a file persistent-volume.yaml using any text editor on your terminal.
Two objects handle this between them. A PersistentVolume is the actual storage in the cluster, either created by hand by an administrator or created dynamically by Kubernetes. A PersistentVolumeClaim is the request side of the same deal: a claim says how much space it wants and what access mode it needs, and the cluster matches it to a volume that can satisfy it.
The reason it is split in two comes down to lifecycle. Claims and volumes are independent of pod lifecycles, so the data survives pods being restarted, rescheduled, or deleted outright. Delete a WordPress pod and the uploads are still sitting there when its replacement comes up. That separation is the entire point of the exercise, and it’s much better understood now than after you have lost a database to it.

Objectives

  • Set up the PersistentVolumeClaims and PersistentVolumes that hold the data
  • Build a kustomization.yaml containing
    • a generator for the database Secret
    • the MySQL resource configs
    • the WordPress resource configs
  • Bring the whole thing up with kubectl apply -k ./
  • Tear it back down again afterwards

Download the following configuration files:

mysql-deployment.yaml
wordpress-deployment.yaml

Create PersistentVolumeClaims and PersistentVolumes

For MySQL

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-pv-claim
  labels:
    app: wordpress
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi

For WordPress

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wp-pv-claim
  labels:
    app: wordpress
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi

Create a kustomization.yaml

Add a Secret generator
A Secret is an object that holds a small piece of sensitive data, a password or a key, and keeps it out of the manifests you write. Kubectl supports managing Kubernetes objects through a kustomization file, and you can create a Secret with generators in the kustomization.yaml file instead of creating each one separately.
Worth being straight about what this buys you, though. The immediate win is that your password stops living in a deployment file you might commit to a repository. Secrets are base64 encoded rather than encrypted at rest unless encryption has been configured on the cluster, so treat this as tidiness and access control, not as a vault.
Add a Secret generator in kustomization.yaml from the following command. You will need to replace YOUR_PASSWORD with the password you want to use, and pick something real rather than a placeholder you plan to change later, because in practice nobody ever does.
cat <<EOF >./kustomization.yaml
secretGenerator:
- name: mysql-pass
  literals:
  - password=YOUR_PASSWORD
EOF

Add resource configs for MySQL and WordPress

The manifest below describes a single-instance MySQL Deployment. The MySQL container mounts the PersistentVolume at /var/lib/mysql, which puts the database files on the claim instead of inside the container, and the MYSQL_ROOT_PASSWORD environment variable sets the database password from the Secret.
Single instance is the part to hold on to. One replica, one pod, nothing replicating behind it. That’s exactly the right shape for learning how the pieces fit together, and exactly the wrong shape for anything carrying real traffic.
apiVersion: v1
kind: Service
metadata:
  name: wordpress-mysql
  labels:
    app: wordpress
spec:
  ports:
    - port: 3306
  selector:
    app: wordpress
    tier: mysql
  clusterIP: None
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-pv-claim
  labels:
    app: wordpress
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress-mysql
  labels:
    app: wordpress
spec:
  selector:
    matchLabels:
      app: wordpress
      tier: mysql
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: wordpress
        tier: mysql
    spec:
      containers:
      - image: mysql:5.6
        name: mysql
        env:
        - name: MYSQL_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mysql-pass
              key: password
        ports:
        - containerPort: 3306
          name: mysql
        volumeMounts:
        - name: mysql-persistent-storage
          mountPath: /var/lib/mysql
      volumes:
      - name: mysql-persistent-storage
        persistentVolumeClaim:
          claimName: mysql-pv-claim
The WordPress side follows the same pattern. The manifest describes a single-instance WordPress Deployment, and the container mounts the PersistentVolume at /var/www/html, which is where the site files and uploads live. The WORDPRESS_DB_HOST environment variable sets the name of the MySQL Service defined above, so WordPress reaches the database by Service rather than by an address you would otherwise have to know in advance. The WORDPRESS_DB_PASSWORD environment variable sets the database password from the Secret kustomize generated.
That indirection through the Service name is quietly doing a lot of work. The MySQL pod can be rescheduled onto a different node and come back somewhere else entirely, and WordPress carries on without noticing, because it was never pointed at a fixed address to begin with.
apiVersion: v1
kind: Service
metadata:
  name: wordpress
  labels:
    app: wordpress
spec:
  ports:
    - port: 80
  selector:
    app: wordpress
    tier: frontend
  type: LoadBalancer
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wp-pv-claim
  labels:
    app: wordpress
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress
  labels:
    app: wordpress
spec:
  selector:
    matchLabels:
      app: wordpress
      tier: frontend
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: wordpress
        tier: frontend
    spec:
      containers:
      - image: wordpress:4.8-apache
        name: wordpress
        env:
        - name: WORDPRESS_DB_HOST
          value: wordpress-mysql
        - name: WORDPRESS_DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mysql-pass
              key: password
        ports:
        - containerPort: 80
          name: wordpress
        volumeMounts:
        - name: wordpress-persistent-storage
          mountPath: /var/www/html
      volumes:
      - name: wordpress-persistent-storage
        persistentVolumeClaim:
          claimName: wp-pv-claim
Download the MySQL deployment configuration file.
curl -LO https://k8s.io/examples/application/wordpress/mysql-deployment.yaml
Then the WordPress one.
curl -LO https://k8s.io/examples/application/wordpress/wordpress-deployment.yaml
Add them to kustomization.yaml file.
cat <<EOF >>./kustomization.yaml
resources:
  - mysql-deployment.yaml
  - wordpress-deployment.yaml
EOF

Apply and Verify

At this point kustomization.yaml contains every resource needed to stand up a WordPress site and a MySQL database behind it. Rather than applying each file in turn, you apply the directory and let kustomize assemble the whole thing in one go.
kubectl apply -k ./
Give it a moment, then check that the objects actually exist. This is the step people skip, and it’s the one that tells you whether anything worked.
$ sudo kubectl apply -k ./

secret/mysql-pass-dd6525th4g created
service/wordpress created
service/wordpress-mysql created
persistentvolumeclaim/mysql-pv-claim created
persistentvolumeclaim/wp-pv-claim created
deployment.apps/wordpress created
deployment.apps/wordpress-mysql created
Verify that the Secret exists by running the following command:
$ sudo kubectl get secrets
NAME                    TYPE                                  DATA   AGE
mysql-pass-dd6525th4g   Opaque                                1      41s
Then check that a PersistentVolume was dynamically provisioned.
Note: It can take up to a few minutes for the PVs to be provisioned and bound. A claim sitting in Pending for a few seconds is normal, not a sign that something’s broken.
$ sudo kubectl get pvc
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
mysql-pv-claim   Bound    pvc-d8f643f5-c5c1-40b9-8bcc-2a610be7bd9d   20Gi       RWO            standard       83s
wp-pv-claim      Bound    pvc-ce759d93-2976-4ae9-9059-9e21119c22dc   20Gi       RWO            standard       83s
Next, confirm the Pod is running.
Note: It can take up to a few minutes for the Pod’s Status to be RUNNING. If it’s still Pending well past that, the usual causes are a claim that never bound or a node without the room to schedule it.
​$ sudo kubectl get pods
NAME                              READY   STATUS    RESTARTS   AGE
wordpress-56c6675d48-kgq2t        1/1     Running   0          2m1s
wordpress-mysql-57f67668b-pzqmw   1/1     Running   0          2m1s
And finally the Service:
$ sudo kubectl get services wordpress
NAME        TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
wordpress   LoadBalancer   10.99.231.19   <pending>     80:32137/TCP   2m56s

Note: Minikube can only expose Services through NodePort. The EXTERNAL-IP is always pending.
Run the following command to get the IP Address for the WordPress Service:

$ sudo minikube service wordpress --url
http://172.31.22.143:32137
http://52.14.63.97:32137
If you are using AWS instance then we need to allow port 32137 into security group and use the public IP. This trips up more people than the Kubernetes side ever does: the deployment’s fine, the Service is up, and the page just won’t load, because the packets are being dropped before they reach the node. Copy the IP address, and load the page in your browser to view your site.
You should get the familiar WordPress installation screen, looking something like this.
WordPress setup screen served from the Kubernetes deployment
Warning: Do not leave your WordPress installation on this page. An unfinished install is an open invitation. If somebody else finds it before you finish, they can complete the setup themselves and use your instance to serve whatever they like.
So either finish the install properly by creating a username and password, or tear the instance down. Do not wander off and leave it sitting on this screen.

Cleaning up

Run the following command to delete your Secret, Deployments, Services, and PersistentVolumeClaims:
kubectl delete -k ./

Conclusion

That’s the whole path: claims and volumes for storage that outlives the pods, a Secret so the database password is not sitting in a manifest, a kustomization file tying the resources together, and a single apply to bring it all up.
What you have at the end is a working WordPress backed by MySQL, and a decent feel for how the objects relate to one another. What you haven’t got is anything production ready. Single replicas on both sides, no backups, no ingress, no TLS, and a database that goes away with the pod if the claim is ever deleted. Those are each a topic of their own, and they’re the natural next things to read about once this one is running.
Get this far first, though. Most of the confusion around Kubernetes storage clears up the moment you have watched a pod get deleted and seen the data still sitting there afterwards.

References

This walkthrough follows the official Kubernetes tutorial on running WordPress and MySQL with persistent volumes, rewritten here with additional operational notes. The upstream documentation is worth having open alongside it:
Kubernetes documentation is published by the Kubernetes Authors under CC BY 4.0. Portions of this article are adapted from that source and have been modified.
Avatar photo

Asif Khan

I have spent over 10 years working across IT systems, open source software, DevOps, Linux administration and cloud operations. Three things drive most of what I do: automation, security and resilience. Much of that work involves planning and building the platforms that sit behind services people rely on daily, which means designing for failure just as carefully as for load. Cloud computing held my attention early on, largely for its flexibility. Being able to scale up and then back down again means far less guessing about how much capacity you will need. Across projects I work with the full DevOps toolchain, from provisioning, orchestration and configuration management through to release management and microservices architecture.