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:
- Start a MySQL StatefulSet and store the data in a PVC.
- Create a WordPress Deployment and store persistent data in a PVC.
- 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.8For 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.yamlWordPress 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-apacheOnly /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.yamlConfigMap. 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 48apiVersion: 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.yamlMy 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.yamlCreate 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.yamlVisit 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.yamlThe platform is ready. The result on my demo platform is nomnomfit.eu