WordPress has been a popular blogging/website platform for decades, which means that there are a quite a number of old WordPress-based websites out there. But if you’re hosting one on a VM, it can be difficult to scale it, to maintain it, and to update the look without breaking it. Kubernetes to the rescue!

Cloud Native best practices recommend a clean separation among executable code (in the container), configuration (in the kubernetes manifests), and data (in the database and/or mounted volumes). But WordPress was first designed before the widespread use of containers — so, unfortunately, the code, configuration, and content data are all jumbled together in the filesystem.

Ultimately deploying WordPress on kubernetes is quite doable — and enforcing the separation of components (code/configuration/data) makes it easy to deploy as many copies as you like, which simplifies maintenance and scaling (compared to running it on a VM). But the standard WordPress docker images need to employ some ugly hackery to get the code and configuration into a writable volume for it to work — so the initial setup can be delicate.

If you would like to migrate an existing WordPress blog/site to kubernetes, you will need the following:

  • At least one (sub)domain that you own/control,
  • A kubernetes cluster that allows the creation of ingresses and (read-write-many) data volumes,
  • A MySQL database server (if you don’t already have one, you can easily create it in your cluster),
  • Access to the content of the blog you’re migrating (plus ideally an admin account on the blog itself).

1. Create the database

In your MySQL database server, you need to create a new database and a corresponding database user. For this article, I’ll call them blog_db and blog_db_user. This user needs full permissions on the corresponding database (but does not need root permissions for the database server).

If you want to run the database on your cluster, there are various options. I will include the manifests for minimalist MySQL or MariaDB deployments (suitable for prototyping) in a later post.

Typically you’ll want to create your kubernetes-based website at an alternative domain name first (and not experiment at your primary domain that your users are using). For these instructions, I will assume that the production website is at https://myblog.com and that you’ll be using https://test.myblog.com for your kubernetes deployment while you’re setting it up. (You can change it to use the real domain after you’ve confirmed that the k8s version is OK.)

Create a mysqldump of the website’s database. In my case, I was working from a backup mysqldump that included command to create the database, but I wanted to use a different database name in my new deployment. This is easy to fix by opening up the dump file with vi (or other simple editor) and commenting out the create database and use database commands near the top of the file.

To change the blog domain to the test domain name, you can run the dump file through sed before inserting it. Here’s an example command to update the domain name and insert the site data in the database:

sed 's/myblog.com/test.myblog.com/g' dumpfile.sql | mysql -u blog_db_user -p -h <address_of_cloud_db> blog_db

Usually I deploy the blog with an empty database first (to initialize the filesystem), but you can load the database first, and if there are any problems, you can drop/recreate the database, and load in the real data after the deployment is initialized.

2. Create the WordPress deployment

I’ll start with a minimal deployment manifest (with corresponding service), and then explain below:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: wordpress
  name: wordpress-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: wordpress
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: wordpress
    spec:
      containers:
      - envFrom:
        - secretRef:
            name: env
        image: bitnami/wordpress:latest
        name: wordpress
        readinessProbe:
          httpGet:
            # you don't have to use this specific path, 
            # but this one works:
            path: /wp-includes/images/rss.png
            port: 8080
        volumeMounts:
        - mountPath: /bitnami/wordpress
          name: data-volume
          subPath: blog-test
      volumes:
      - name: data-volume
        nfs:
          path: /export/data/
          server: 10.0.2.2
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: wordpress
  name: wp-service-test
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 8080
  selector:
    app: wordpress
  type: ClusterIP

The above assumes that you have a secret named “env” (in the same namespace) containing the following properties:

WORDPRESS_DATABASE_HOST=<host of your database here>
WORDPRESS_DATABASE_USER=blog_db_user
WORDPRESS_DATABASE_NAME=blog_db
WORDPRESS_DATABASE_PASSWORD=<real db password goes here>

In the above, I’m using the bitnami image instead of the official wordpress image. This is because the official wordpress image runs as root when initializing. Security best-practices recommend not letting containers run as root, and the bitnami one works fine and fits the bill. The files in the volume should be owned by user “bitnami” (UID 1001) and group “daemon” (GID 1) as explained in their documentation.

The official wordpress image works fine too, though, and is also a valid choice. To use it with the above manifests, you would just need to change the image to a wordpress one (like wordpress:6.5.0-apache or whatever recent tag you like), change the port from 8080 to 80, change the mountPath to /var/www/html, and — annoyingly enough — change the environment variable keys. They’re slightly different in the two images. The official wordpress image uses env variables of the form WORDPRESS_DB_* in place of the bitnami versions that look like WORDPRESS_DATABASE_* (see above).

Regarding the data volume, my manifest above uses an nfs-based volume. I explained how to set up this type of Read-Write-Many volumes in an earlier blog entry here.

In the above example, the VM that runs the NFS server is configured to serve files from the file path /export/data, and the individual folder for the files used by this deployment are in a sub-folder /export/data/blog-test — configured in the subPath entry in the volumeMount.

The sub-folder will be automatically created if the permissions on the NFS server allow it, otherwise you can create the sub-folder on the NFS server manually.

If you are using some other type of volume, naturally you would change the configuration of the data-volume and corresponding volumeMounts accordingly.

It’s best if you can use a Read-Write-Many volume (like NFS). If your data volume is Read-Write-Many, then you can scale your deployment to multiple pods and you can use the RollingUpdate deployment strategy. Otherwise you can only run one pod at a time — which means that your strategy needs to be changed to Recreate.

3. Create the Ingress

The configuration of the ingress (and load balancer) is the part that varies most from one kubernetes provider to the next. Here’s a fairly simple, standard ingress manifest that will create a load-balancer and TLS certificates (for valid https connections with your site) on a Linode/Akami (LKE) cluster — and will probably work on most standard kubernetes clusters:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  name: myingress
spec:
  ingressClassName: nginx
  rules:
  - host: test.myblog.com
    http:
      paths:
      - backend:
          service:
            name: wp-service-test
            port:
              number: 80
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - test.myblog.com
    secretName: letsencrypt-tls

After deploying this, if you run kubectl get ingress myingress , you’ll see the IP address of the load-balancer. Update the DNS configuration of your test domain (e.g. test.myblog.com) to the IP address of your ingress/load-balancer. The TLS certificates should be automatically generated when the ingress is created — and they’ll be stored in a secret with the name you specify in the secretName:.

To save money, you can use the same ingress and load-balancer for multiple hosts. In particular, once you’re ready to move the real domain myblog.com to the deployment on kubernetes, you can simply add the domain and the backend to the rules: and hosts: sections.

Naturally all of these components (including the ingress) need to be in the same namespace as each other. I’ve left off the namespace metadata in these examples (placing them in the default namespace), but I’d recommend running this deployment in its own namespace.

3. Check the deployment

After you have updated the DNS configuration and deployed the manifests, the test version of the blog should be up and running.

On the first run, the WordPress pod should create the wp-config.php file and the wp-content folder in the data volume. Here’s what it will look like for the bitnami image (assuming your volume is NFS-based and the shared filesystem is at /export/data):

me@mynfsvm:~$ ls -l /export/data/blog-test
total 12
-r--rw---- 1 nouser nogroup 4289 Apr 11 13:17 wp-config.php
drwxrwxr-x 7 nouser nogroup 4096 Apr 12 10:45 wp-content

If you’re using the official WordPress image, it will look like this:

me@mynfsvm:~$ ls -l /export/data/blog-test
total 244
-rw-r--r--  1 www-data www-data   405 Feb  6  2020 index.php
-rw-r--r--  1 www-data www-data 19915 Jan  1 00:02 license.txt
-rw-r--r--  1 www-data www-data  7401 Dec  8 14:13 readme.html
-rw-r--r--  1 www-data www-data  7387 Feb 13 14:19 wp-activate.php
drwxr-xr-x  9 www-data www-data  4096 Apr  2 18:12 wp-admin
-rw-r--r--  1 www-data www-data   351 Feb  6  2020 wp-blog-header.php
-rw-r--r--  1 www-data www-data  2323 Jun 14  2023 wp-comments-post.php
-rw-r--r--  1 www-data www-data  5512 Apr  9 01:52 wp-config-docker.php
-rw-r--r--  1 www-data www-data  3012 Nov 22 17:44 wp-config-sample.php
-rw-r--r--  1 www-data www-data  5672 Apr 12 13:16 wp-config.php
drwxr-xr-x  8 www-data www-data  4096 Apr 13 19:16 wp-content
-rw-r--r--  1 www-data www-data  5638 May 30  2023 wp-cron.php
drwxr-xr-x 30 www-data www-data 12288 Apr  2 18:12 wp-includes
-rw-r--r--  1 www-data www-data  2502 Nov 26  2022 wp-links-opml.php
-rw-r--r--  1 www-data www-data  3927 Jul 16  2023 wp-load.php
-rw-r--r--  1 www-data www-data 50917 Jan 16 17:31 wp-login.php
-rw-r--r--  1 www-data www-data  8525 Sep 16  2023 wp-mail.php
-rw-r--r--  1 www-data www-data 28427 Mar  2 10:47 wp-settings.php
-rw-r--r--  1 www-data www-data 34385 Jun 19  2023 wp-signup.php
-rw-r--r--  1 www-data www-data  4885 Jun 22  2023 wp-trackback.php
-rw-r--r--  1 www-data www-data  3246 Mar  2 13:49 xmlrpc.php

If your pod fails to create these files in the data volume, check the logs and resolve any issues with the filesystem permissions and/or database configuration/connection.

Next check your site in the browser, e.g. navigate to http://test.myblog.com. The little lock in the navigation bar should show that your connection is secure (if not, check on your ingress).

In the browser, your WordPress installation might walk you through an initialization wizard. This is OK, but if it asks you for your database configuration and credentials, then your environment variables are probably configured wrong (see: Create the WordPress Deployment above).

If (after inserting your old blog’s data in the database) the blog screen is completely blank, this is normal. Typically this will happen if your site is using a theme that is not installed on the new k8s installation. To solve this, you can either copy the themes folder from the wp-content folder of the existing blog into the wp-content folder of the data volume, or you can log into the blog (as admin) in the browser by navigating to https://test.myblog.com/wp-login.php and entering your admin credentials. From there you can either change the theme or download/install the earlier theme.

Often the blog (in the browser) will tell you it needs to upgrade your database and prompt for your OK. You should OK it.

After these steps, the site and its text content should be visible.

4. Finishing touches

Uploaded user content and images should be in the wp-content/uploads folder. Copy the contents of this folder from your old site to the new filesystem, and ensure that the destination files are owned by the same user and group as the surrounding files in your data volume.

If the links to posts and comments are broken, go to the dashboard (in the browser, as admin), and go to Settings > Permalinks. I’ve found that changing the permalink structure to some other choice and then changing it back fixes the problem.

Once you’ve checked your new deployment and you’ve confirmed that it is ready to go live, then you need to update the domain. I would recommend keeping your test deployment as-is, and creating a parallel deployment alongside it (with a separate database and data volume) for production. You can just follow the steps again, and skip the parts about changing the domain name to test. The test deployment can be used for trying out new things, and — if you’re not using a separate ingress and if you scale it down to 0 replicas when not in use — it costs little or nothing to keep it.

Alternatively, you can update the domain name of the existing deployment. Naturally you will need to update the DNS configuration of the real domain name (to point to the IP of the ingress), and add items for the production domain name to the ingress configuration in the hosts: and rules: sections. Then you will also need to update the wp-config.php file (in the data volume) and — in the blog’s admin dashboard — change the domain name in the Settings > General section.

And that’s it! Your website is live on kubernetes.