Building a Multi-Blogging Platform with WordPress Multisite on Kubernetes


The idea is simple. A blogging platform where anyone can register and get their own blog with a preinstalled website and a personal subdomain. Just log in and start publishing.

I will use WordPress Multisite. WordPress is simple, well-known, and built on a familiar tech stack: WordPress, MySQL, and a reverse proxy. That’s it.

Is Kubernetes the best choice for this setup? The honest answer: no. It’s too complex. Too many things can go wrong.

Can it scale? So-so. As long as the database can handle the requests, it should work.

So why am I doing this? Mostly for the fun of it. Many things could be done better. Credentials could be stored more securely, the database architecture could be improved, resources could be provisioned more efficiently, and scaling could be faster.

This article is a good reference for how I usually start my projects and gradually improve them. Along the way, I’ll cover some core Kubernetes concepts like Deployments, exposing applications to the internet, and building both stateless and stateful applications.

I already have a Kubernetes cluster with persistent volumes, a way to expose applications to the Internet, and a certificate manager. Let’s get my hands dirty.

My first task is to understand how the application works and create a simple plan to follow.

WordPress requirements

  • PHP version 8.3 or greater
  • MariaDB version 10.6 or greater OR MySQL version 8.0 or greater
  • HTTPS support

The obvious questions

Where does WordPress store persistent data?
/wp-content

How do we tell WordPress where the database is and which hostname to use?
Environment variables.

Database?
It will be installed in Kubernetes. Usually this is not the best idea, but I need something simple for this setup.

The plan

The plan is simple:

  1. Start a MySQL StatefulSet and store the data in a PVC.
  2. Create a WordPress Deployment and store persistent data in a PVC.
  3. Expose the application to the internet.

Build MySQL database

Let’s see how to deploy it with docker first. From official docker hub:

## create docker network first
docker network create wordpress-net

docker run \
  --name some-mysql \
  --network wordpress-net \
  -v mysqldata:/var/lib/mysql \
  -e MYSQL_DATABASE=mysql4wordpress \
  -e MYSQL_USER=mysqluser \
  -e MYSQL_PASSWORD=password \
  -e MYSQL_ROOT_PASSWORD=password \
  -d mysql:8.4.8

For a database like MySQL, using a Deployment is often not ideal. Deployments are designed for stateless workloads where pods can be freely scaled up/down and replaced. Databases are stateful, meaning their data must persist and pods need stable network identities. In Kubernetes, the usual approach is to use a StatefulSet instead.

  • StatefulSet gives each pod a stable identity (mysql-0, mysql-1, …) and stable network hostname.
  • Each pod can get its own PersistentVolumeClaim to store data.
  • Works well with single-node MySQL or a replicated MySQL cluster.

Store database credentials in secrets. Secrets are unencrypted in etcd. It is good idea to encrypt etcd. – encryption at rest and implement RBAC! – This will be done in near feature 🙂

secrets for DB, encoded with base64. Usefull linux command to encode strings
encode base64 command: echo -n “password” | base64 -i –

## encode base64 command: echo -n "password" | base64 -i -
apiVersion: v1
kind: Secret
metadata: 
  name: mysql-secret
data:
 MYSQL_DATABASE: bXlzcWw0d29yZHByZXNz
  MYSQL_USER: bXlzcWx1c2Vy
  MYSQL_ROOT_PASSWORD: cGFzc3dvcmQ=
  MYSQL_PASSWORD: cGFzc3dvcmQ=

mysql-statefulset.yaml stateful set for DB:

apiVersion: v1
kind: Service
metadata:
  name: mysql-service
  labels:
    app: mysql
spec:
  ports:
  - port: 3306
    name: mysql-port
# clusterIP: ""  # it will take static persistant ip from cluster CIDR. ClusterIP: none if you do not need cluster ip
  selector:
    app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql-statefulset
spec:
  selector:
    matchLabels:
      app: mysql # has to match .spec.template.metadata.labels
  serviceName: "mysql"
  replicas: 1
  minReadySeconds: 0 # the Pod will be considered available as soon as it is ready
  template:
    metadata:
      labels:
        app: mysql # has to match .spec.selector.matchLabels
    spec:
      terminationGracePeriodSeconds: 10 ## check https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/
      containers:
      - name: mysql
        image: mysql:8.4.8
        env:
          - name: MYSQL_DATABASE
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_DATABASE
          - name: MYSQL_USER
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_USER
          - name: MYSQL_ROOT_PASSWORD
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_ROOT_PASSWORD
          - name: MYSQL_PASSWORD
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_PASSWORD          
        ports:
        - containerPort: 3306
          name: mysql-port
        volumeMounts:
        - name: mysql-pvc
          mountPath: /var/lib/mysql
  volumeClaimTemplates:  ### << No need to create PVC manualy. If replicas is always 1, data is safe. If replaca is 2, it will create new pvc
  - metadata:
      name: mysql-pvc
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "nfs-dynamic"
      resources:
        requests:
          storage: 3Gi
kubectl apply -f mysql-statefulset.yaml

WordPress deployment

Let’s check how to deploy it with docker first!
official docker : https://hub.docker.com/_/wordpress
docker image wordpress:6.9.1-php8.5-apache

docker run \
  --name some-wordpress \
  --network wordpress-net \
  -p 8080:80 \
  -e WORDPRESS_DB_HOST=some-mysql:3306 \
  -e WORDPRESS_DB_USER=mysqluser \
  -e WORDPRESS_DB_PASSWORD=password \
  -e WORDPRESS_DB_NAME=mysql4wordpress \
  -v wordpressdata:/var/www/html/wp-content \
  -d wordpress:6.9.1-php8.5-apache

Only /var/www/html/wp-content will persist. Everything else will be updated with docker image updates
Persistent volume claim for wordpress: pvc-for-wordpress.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wordpress-pvc
spec:
  accessModes: 
    - ReadWriteMany  #the volume can be mounted as read-write by many nodes.
                    # https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes
  resources:
    requests:
      storage: 10Gi

kubectl apply -f pvc-for-wordpress.yaml

ConfigMap. Let’s say that mysql host and port is not confidentia. wordpress-configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: wordpress
data:
  # property-like keys; each key maps to a simple value
  WORDPRESS_DB_HOST: "mysql-service:3306"
kubectl apply -f wordpress-configmap.yaml 

In order multisite to work I need special htaccess file. It will be mounted as volume later. ConfigMap mounted as volume. Why not ? 🙂

# wordpress-htaccess-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: wordpress-htaccess
data:
  .htaccess: |
    RewriteEngine On
    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
    RewriteBase /
    RewriteRule ^index\.php$ - [L]

    # add a trailing slash to /wp-admin
    RewriteRule ^wp-admin$ wp-admin/ [R=301,L]

    RewriteCond %{REQUEST_FILENAME} -f [OR]
    RewriteCond %{REQUEST_FILENAME} -d
    RewriteRule ^ - [L]
    RewriteRule ^(wp-(content|admin|includes).*) $1 [L]
    RewriteRule ^(.*\.php)$ $1 [L]
    RewriteRule . index.php [L]

Secrets for wordpress. I have more than one pod serving wordpress.Each WordPress pod generates its own random secret keys in wp-config.php. These keys sign login cookies. Pod B can’t verify a cookie that Pod A signed with different keys. That’s why i will generate the keys and use them as envs.
Generate 8 keys and place the results in wordpress-keys-secret.yaml

openssl rand -base64 48
apiVersion: v1
kind: Secret
metadata:
  name: wordpress-keys
type: Opaque
stringData:
  WORDPRESS_AUTH_KEY: "PutResultFromOpensslCommandHere"
  WORDPRESS_SECURE_AUTH_KEY: "PutResultFromOpensslCommandHere"
  WORDPRESS_LOGGED_IN_KEY: "PutResultFromOpensslCommandHere"
  WORDPRESS_NONCE_KEY: "PutResultFromOpensslCommandHere"
  WORDPRESS_AUTH_SALT: "PutResultFromOpensslCommandHere"
  WORDPRESS_SECURE_AUTH_SALT: "PutResultFromOpensslCommandHere"
  WORDPRESS_LOGGED_IN_SALT: "PutResultFromOpensslCommandHere"
  WORDPRESS_NONCE_SALT: "PutResultFromOpensslCommandHere"

Deploy actual wordpress. Change yourdomain.com with your domain. wordpress-deployment.yaml

# wordpress-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress-deployment
  labels:
    deployment: wordpress
spec:
  replicas: 5
  selector:
    matchLabels:
      app: wordpress  ## <-- this should mactch labes
  template:
    metadata:
      labels:
        app: wordpress  ## <-- this should match matchLabels:
    spec:
      containers:
      - name: wordpress
        image: wordpress:6.9.1-php8.5-apache
        ports:
        - containerPort: 80
        env:
          - name: WORDPRESS_DB_NAME ## actual env
            valueFrom:
              secretKeyRef:
                name: mysql-secret  ## The secret this value comes from. mysql-secret.yaml
                key: MYSQL_DATABASE ## the key  to fetch
          - name: WORDPRESS_DB_USER
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_USER
          - name: WORDPRESS_DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: mysql-secret
                key: MYSQL_PASSWORD
          - name: WORDPRESS_DB_HOST ## actual env
            valueFrom:
              configMapKeyRef:
                name: wordpress           # this value comes from the ConfigMap.
                key: WORDPRESS_DB_HOST # The key to fetch.
          ## ── Shared Auth Keys ──────────────────────────────────
          ## All replicas must use the same keys so login cookies
          ## signed by one pod are valid on every other pod.
          ## Values come from wordpress-keys-secret.yaml
          - name: WORDPRESS_AUTH_KEY
            valueFrom:
              secretKeyRef:
                name: wordpress-keys      ## The secret this value comes from. wordpress-keys-secret.yaml
                key: WORDPRESS_AUTH_KEY
          - name: WORDPRESS_SECURE_AUTH_KEY
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_SECURE_AUTH_KEY
          - name: WORDPRESS_LOGGED_IN_KEY
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_LOGGED_IN_KEY
          - name: WORDPRESS_NONCE_KEY
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_NONCE_KEY
          - name: WORDPRESS_AUTH_SALT
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_AUTH_SALT
          - name: WORDPRESS_SECURE_AUTH_SALT
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_SECURE_AUTH_SALT
          - name: WORDPRESS_LOGGED_IN_SALT
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_LOGGED_IN_SALT
          - name: WORDPRESS_NONCE_SALT
            valueFrom:
              secretKeyRef:
                name: wordpress-keys
                key: WORDPRESS_NONCE_SALT
          ## ── End Shared Auth Keys ──────────────────────────────
          ## ── Multisite Phase 2 (Full) ──────────────────────────
          ## Enables WordPress Multisite with subdomain routing.
          ## Each registered user gets their own subdomain site.
          ## Requires wildcard DNS: *.yourdomain.com → public IP
          ## Requires wildcard TLS: *.yourdomain.com cert
          - name: WORDPRESS_CONFIG_EXTRA
            value: |
              define('MULTISITE', true);
              define('SUBDOMAIN_INSTALL', true);
              define('DOMAIN_CURRENT_SITE', 'yourdomain.com');
              define('PATH_CURRENT_SITE', '/');
              define('SITE_ID_CURRENT_SITE', 1);
              define('BLOG_ID_CURRENT_SITE', 1);
          ## ── End Multisite Phase 2 ─────────────────────────────
        volumeMounts:
          - name: wordpress-data
            mountPath: /var/www/html/wp-content
          ## ── Multisite .htaccess ───────────────────────────────
          ## Mounts the rewrite rules required for subdomain routing.
          ## Without this, subdomain requests won't route to WordPress.
          - name: htaccess
            mountPath: /var/www/html/.htaccess
            subPath: .htaccess
          ## ── End Multisite .htaccess ───────────────────────────
      volumes:
        - name: wordpress-data
          persistentVolumeClaim:
            claimName: wordpress-pvc
        - name: htaccess
          configMap:
            name: wordpress-htaccess
kubectl apply -f wordpress-deployment.yaml

My platform will need sub-domains for every blog and certificates for https. I am using cloudflare as DNS provider. I will create A record

Record type : A
name: *
target: yourdomain.com

I will need new cert issuer. HTTP-01 will not work with subdomain. DNS-01 will be used.
In cloudflare panel:


1. Get a Cloudflare API Token
Go to: Cloudflare Dashboard → My Profile → API Tokens → Create Token

Use template: Edit zone DNS, then:

Setting	Value
Permissions	Zone → DNS → Edit
Zone Resources	Include → Specific zone → yourdomain.com
Copy the token.

Apply cloudflare-secret.yaml

# cloudflare-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-api-token
  namespace: cert-manager
type: Opaque
stringData:
  api-token: "pasteyoursecrethere"
kubectl apply -f cloudflare-secret.yaml

Create the wildcard ClusterIssuer – clusterissuer-wildcard.yaml

# clusterissuer-wildcard.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-wildcard
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: e-mail@yourmail.com
    privateKeySecretRef:
      name: letsencrypt-wildcard
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token
            key: api-token
      selector:
        dnsZones:
        - "yourdomain.com"

Create service for wordpress wordpress-sevice-clusterip.yaml

apiVersion: v1
kind: Service
metadata:
  name: wordpress-svc
spec:
  type: ClusterIP
  selector:
    app: wordpress  # must match the deployment's template labels. A Kubernetes Service doesn’t talk to the Deployment itself - it talks to the Pods that the Deployment manages.
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80

Create wordpress ingress wordpress-ingress.yaml

# wordpress-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: wordpress-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-wildcard
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - yourdomain.com
    - "*.yourdomain.com"
    secretName: yourdomain-wildcard-tls ## replace with your wildcard-tls secretName
  rules:
    - host: yourdomain.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: wordpress-svc
                port:
                  number: 80
    - host: "*.yourdomain.com"
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: wordpress-svc
                port:
                  number: 80

kubectl apply -f wordpress-ingress.yaml

Visit yourdomain.com and install wordpress, follow the instructions. Enable multisite in admin panel.

Go to https://yourdomain/wp-admin/ → Tools → Network Setup

  • Choose: Sub-domains
  • Network Title: NomNomFit
  • Click Install
    WordPress will give you code to add. We already added it in wordpress-deployment.yaml. Enable User Registration Settings → Network Settings → Registration Settings "Both sites and user accounts can be registered"

It is good idea to restrict main wordpress site admin panel only from specific IP, let’s say 123.123.123.123. wordpress-admin-ingress.yaml

# wordpress-admin-ingress.yaml
## ── WP-Admin IP Restriction ─────────────────────────────
## Restricts /wp-admin and /wp-login.php on the MAIN site only.
## Subdomain sites (*.yoursite.com) are NOT restricted —
## site owners need to manage their own sites.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: wordpress-admin-ingress
  annotations:
    nginx.ingress.kubernetes.io/whitelist-source-range: "123.123.123.123"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - yoursite.com
    secretName: yourdomain-wildcard-tls ## put your secret wildcard-tls here. it will be different! kubectl get secret
  rules:
    - host: yoursite.com
      http:
        paths:
          - path: /wp-admin
            pathType: Prefix
            backend:
              service:
                name: wordpress-svc
                port:
                  number: 80
          - path: /wp-login.php
            pathType: Exact
            backend:
              service:
                name: wordpress-svc
                port:
                  number: 80
kubectl apply -f wordpress-admin-ingress.yaml

The platform is ready. The result on my demo platform is nomnomfit.eu

Leave a Reply

Your email address will not be published. Required fields are marked *