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


