WordPress manifests
This runbook defines the Kubernetes manifests that live in the WordPress application repo (website/coach) under k8s/prod/, plus the SOPS policy that governs secret encryption.
WordPress GitOps series
- WordPress GitOps summary
- WordPress repo and prerequisites
- WordPress manifests - you are here
- WordPress flux integration
- WordPress operations, restore and backups
1. Target repo layout
This is the expected on-disk structure inside website/coach.
1.1 Directory layout
tree -a -I '.git|.DS_Store'
website/coach/
├── .sops.yaml
├── k8s
│ └── prod
│ ├── 10-secret-db.enc.yaml
│ ├── 11-secret-wp-salts.enc.yaml
│ ├── 12-secret-cfapi-token.enc.yaml
│ ├── 20-configmap-mariadb.cnf.yaml
│ ├── 21-originissuer.yaml
│ ├── 30-statefulset-mariadb.yaml
│ ├── 31-service-mariadb.yaml
│ ├── 40-pvc-wp-content.yaml
│ ├── 50-deployment-wordpress.yaml
│ ├── 51-service-wordpress.yaml
│ ├── 55-certificate-muppit.yaml
│ ├── 60-ingress.yaml
│ ├── 62-wp-cronjob.yaml
│ ├── 65-redis-wordpress.yaml
│ ├── 66-wordpress-redis-secret.enc.yaml
│ ├── 70-networkpolicies.yaml
│ ├── 71-networkpolicy-redis-wordpress.yaml
│ ├── 72-networkpolicy-wp-cron-egress.yaml
│ ├── kustomization.yaml
│ └── wp-support-toolbox
│ ├── 10-pvc-restore-tmp.yaml
│ ├── 20-deploy-wpcli-shell.yaml
│ ├── 26-cronjob-wp-update-checks.yaml
│ ├── 30-deploy-db-restore.yaml
│ ├── 40-deploy-wp-content-restore.yaml
│ ├── 70-netpol-db-restore.yaml
│ ├── 75-netpol-mariadb-from-wpcli-shell.yaml
│ ├── 76-netpol-redis-from-wpcli-shell.yaml
│ ├── 80-netpol-wpcli-shell-egress.yaml
│ ├── 90-netpol-wp-update-checks-egress.yaml
│ ├── 91-netpol-mariadb-from-wp-update-checks.yaml
│ ├── 92-netpol-redis-from-wp-update-checks.yaml
│ └── kustomization.yaml
└── README.md
The namespace is created and managed by flux-config, not the app repo.
2. SOPS policy
This opts secrets in via filename convention (*.enc.yaml under k8s/) and restricts encryption to only data fields.
2.1 .sops.yaml
# .sops.yaml
creation_rules:
- path_regex: k8s/.*\.enc\.ya?ml$
encrypted_regex: '^(data|stringData)$'
age: ['AGE_PUBLIC_KEY_HERE']
Replace AGE_PUBLIC_KEY_HERE with your age public key (starts with age1...).
3. Core manifests (WordPress, MariaDB, TLS, Redis, NetworkPolicy)
These are the Kubernetes manifests under k8s/prod/. Keep placeholder secrets as-is and encrypt them using SOPS.
3.1 Secrets
These define database credentials, WordPress salts, Cloudflare API token, and Redis password.
3.1.1 Secret: DB credentials
# k8s/prod/10-secret-db.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: wp-coach
type: Opaque
stringData:
mariadb-root-password: "REPLACE_ME"
mariadb-user: "REPLACE_ME"
mariadb-password: "REPLACE_ME"
mariadb-database: "REPLACE_ME"
3.1.2 Secret: WordPress salts
# k8s/prod/11-secret-wp-salts.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: wp-salts
namespace: wp-coach
type: Opaque
stringData:
AUTH_KEY: "REPLACE_ME"
SECURE_AUTH_KEY: "REPLACE_ME"
LOGGED_IN_KEY: "REPLACE_ME"
NONCE_KEY: "REPLACE_ME"
AUTH_SALT: "REPLACE_ME"
SECURE_AUTH_SALT: "REPLACE_ME"
LOGGED_IN_SALT: "REPLACE_ME"
NONCE_SALT: "REPLACE_ME"
WP_CACHE_KEY_SALT: "REPLACE_ME"
3.1.3 Secret: Cloudflare API token
# k8s/prod/12-secret-cfapi-token.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: cfapi-token
namespace: wp-coach
type: Opaque
stringData:
key: PASTE_YOUR_API_TOKEN
3.1.4 Secret: Redis password
# k8s/prod/66-wordpress-redis-secret.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: wordpress-redis
namespace: wp-coach
labels:
app: wordpress-redis
type: Opaque
stringData:
REDIS_PASSWORD: "REPLACE_ME"
3.2 MariaDB
MariaDB is deployed as a StatefulSet with explicit startup, readiness, and liveness probes.
3.2.1 ConfigMap: MariaDB tuning
# k8s/prod/20-configmap-mariadb.cnf.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mariadb-extra-cnf
namespace: wp-coach
data:
extra.cnf: |
[mysqld]
max_allowed_packet=64M
max_connections=200
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_520_ci
skip-character-set-client-handshake
3.2.2 OriginIssuer (Cloudflare Origin CA)
# k8s/prod/21-originissuer.yaml
apiVersion: cert-manager.k8s.cloudflare.com/v1
kind: OriginIssuer
metadata:
name: cf-origin
namespace: wp-coach
spec:
requestType: OriginECC
auth:
tokenRef:
name: cfapi-token
key: key
3.2.3 StatefulSet: MariaDB
# k8s/prod/30-statefulset-mariadb.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mariadb
namespace: wp-coach
spec:
serviceName: mariadb
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: mariadb
template:
metadata:
labels:
app.kubernetes.io/name: mariadb
spec:
terminationGracePeriodSeconds: 60
containers:
- name: mariadb
image: mariadb:11.8.5
env:
- name: MARIADB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-root-password
- name: MARIADB_DATABASE
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-database
- name: MARIADB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-user
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-password
ports:
- name: mysql
containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
- name: extra-cnf
mountPath: /etc/mysql/conf.d/extra.cnf
subPath: extra.cnf
# Fix insecure perms created on the data volume so healthcheck can safely read creds.
# MariaDB client ignores world-writable cnf files, which breaks healthcheck.sh.
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -lc
- |
f=/var/lib/mysql/.my-healthcheck.cnf
for i in $(seq 1 60); do
if [ -f "$f" ]; then
chmod 600 "$f" || true
chown mysql:mysql "$f" || true
exit 0
fi
sleep 1
done
exit 0
# "New probes": explicitly test local mysqld socket AND TCP listener.
# This avoids DNS/network-policy surprises and catches "mysqld up but not listening".
startupProbe:
exec:
command:
- /bin/sh
- -lc
- |
set -e
healthcheck.sh --connect --innodb_initialized
mariadb-admin --protocol=socket --socket=/run/mysqld/mysqld.sock ping >/dev/null
mariadb-admin --protocol=tcp --host=127.0.0.1 --port=3306 ping >/dev/null
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 120
readinessProbe:
exec:
command:
- /bin/sh
- -lc
- |
set -e
healthcheck.sh --connect --innodb_initialized
mariadb-admin --protocol=socket --socket=/run/mysqld/mysqld.sock ping >/dev/null
mariadb-admin --protocol=tcp --host=127.0.0.1 --port=3306 ping >/dev/null
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
exec:
command:
- /bin/sh
- -lc
- |
set -e
mariadb-admin --protocol=socket --socket=/run/mysqld/mysqld.sock ping >/dev/null
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "1"
memory: 2Gi
volumes:
- name: extra-cnf
configMap:
name: mariadb-extra-cnf
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
3.2.4 Service: MariaDB
# k8s/prod/31-service-mariadb.yaml
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: wp-coach
spec:
selector:
app.kubernetes.io/name: mariadb
ports:
- name: mysql
port: 3306
targetPort: 3306
3.3 WordPress (Deployment, Service, TLS, Ingress)
WordPress is deployed with an initContainer to seed core files into an emptyDir, keeping wp-content on a PVC.
3.3.1 PVC: wp-content
This runbook defaults WordPress to replicas: 1 because this PVC is ReadWriteOnce.
To scale WordPress to replicas: 2+, use RWX storage (for example NFS) and change the PVC access mode.
# k8s/prod/40-pvc-wp-content.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wp-content
namespace: wp-coach
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
3.3.2 Deployment: WordPress
# k8s/prod/50-deployment-wordpress.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress
namespace: wp-coach
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: wordpress
template:
metadata:
labels:
app.kubernetes.io/name: wordpress
spec:
# Seed WordPress core into an emptyDir, explicitly excluding wp-content.
# This prevents the image entrypoint from repopulating default themes/plugins into the PVC.
initContainers:
- name: seed-wordpress-core
image: wordpress-apache
command:
- sh
- -lc
- |
set -eu
mkdir -p /var/www/html
# Copy core once per pod start, but exclude wp-content (PVC owns that).
# If core is already present in the emptyDir, skip.
if [ ! -f /var/www/html/wp-includes/version.php ]; then
( cd /usr/src/wordpress && tar --exclude='./wp-content' -cf - . ) \
| ( cd /var/www/html && tar -xpf - )
fi
# Ensure mountpoint exists (PVC will overlay it).
mkdir -p /var/www/html/wp-content
volumeMounts:
- name: wp-root
mountPath: /var/www/html
- name: wp-content
mountPath: /var/www/html/wp-content
containers:
- name: wordpress
image: wordpress-apache
env:
- name: WORDPRESS_DB_HOST
value: "mariadb:3306"
- name: WORDPRESS_DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-user
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-password
- name: WORDPRESS_DB_NAME
valueFrom:
secretKeyRef:
name: db-credentials
key: mariadb-database
# Redis (persistent object cache)
- name: WP_REDIS_HOST
value: "wordpress-redis"
- name: WP_REDIS_PORT
value: "6379"
- name: WP_REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress-redis
key: REDIS_PASSWORD
- name: WORDPRESS_CONFIG_EXTRA
value: |
if (!defined('DB_CHARSET')) define('DB_CHARSET', 'utf8mb4');
if (!defined('DB_COLLATE')) define('DB_COLLATE', 'utf8mb4_unicode_520_ci');
$table_prefix = 'wp_';
// In Kubernetes, WP-Cron loopback requests often fail due to siteurl pointing at the public domain (Cloudflare, ACLs, etc).
// Disable the built-in spawner and run cron via a Kubernetes CronJob instead.
if (!defined('DISABLE_WP_CRON')) define('DISABLE_WP_CRON', true);
// Allow update checks (cron still runs), but do not perform automatic installs.
// Manual updates in wp-admin still work.
if (!defined('AUTOMATIC_UPDATER_DISABLED')) define('AUTOMATIC_UPDATER_DISABLED', true);
if (!defined('WP_AUTO_UPDATE_CORE')) define('WP_AUTO_UPDATE_CORE', false);
if (!defined('AUTH_KEY')) define('AUTH_KEY', getenv('AUTH_KEY'));
if (!defined('SECURE_AUTH_KEY')) define('SECURE_AUTH_KEY', getenv('SECURE_AUTH_KEY'));
if (!defined('LOGGED_IN_KEY')) define('LOGGED_IN_KEY', getenv('LOGGED_IN_KEY'));
if (!defined('NONCE_KEY')) define('NONCE_KEY', getenv('NONCE_KEY'));
if (!defined('AUTH_SALT')) define('AUTH_SALT', getenv('AUTH_SALT'));
if (!defined('SECURE_AUTH_SALT')) define('SECURE_AUTH_SALT', getenv('SECURE_AUTH_SALT'));
if (!defined('LOGGED_IN_SALT')) define('LOGGED_IN_SALT', getenv('LOGGED_IN_SALT'));
if (!defined('NONCE_SALT')) define('NONCE_SALT', getenv('NONCE_SALT'));
if (!defined('WP_CACHE_KEY_SALT')) define('WP_CACHE_KEY_SALT', getenv('WP_CACHE_KEY_SALT'));
// Persistent object cache (Redis). Only enable caching if Redis is configured.
$redis_host = getenv('WP_REDIS_HOST');
if ($redis_host) {
if (!defined('WP_CACHE')) define('WP_CACHE', true);
if (!defined('WP_REDIS_HOST')) define('WP_REDIS_HOST', $redis_host);
if (!defined('WP_REDIS_PORT')) define('WP_REDIS_PORT', (int) (getenv('WP_REDIS_PORT') ?: 6379));
if (!defined('WP_REDIS_PASSWORD')) define('WP_REDIS_PASSWORD', getenv('WP_REDIS_PASSWORD'));
}
envFrom:
- secretRef:
name: wp-salts
ports:
- name: http
containerPort: 80
volumeMounts:
# Webroot is ephemeral (emptyDir), seeded by initContainer
- name: wp-root
mountPath: /var/www/html
# wp-content persists
- name: wp-content
mountPath: /var/www/html/wp-content
# Probes (refined):
# - startup: TCP 80 (Apache is accepting connections)
# - liveness: TCP 80 (Apache is still up)
# - readiness: DB connectivity via mysqli (pod only becomes Ready when DB is usable)
startupProbe:
tcpSocket:
port: 80
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60 # 5 minutes
livenessProbe:
tcpSocket:
port: 80
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
exec:
command:
- sh
- -lc
- |
php -r '
$h = getenv("WORDPRESS_DB_HOST") ?: "mariadb:3306";
$parts = explode(":", $h, 2);
$host = $parts[0];
$port = isset($parts[1]) ? (int)$parts[1] : 3306;
$user = getenv("WORDPRESS_DB_USER");
$pass = getenv("WORDPRESS_DB_PASSWORD");
$db = getenv("WORDPRESS_DB_NAME");
$mysqli = @mysqli_connect($host, $user, $pass, $db, $port);
if (!$mysqli) { fwrite(STDERR, "DB not ready: " . mysqli_connect_error() . "\n"); exit(1); }
mysqli_close($mysqli);
exit(0);
';
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 6
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumes:
- name: wp-root
emptyDir: {}
- name: wp-content
persistentVolumeClaim:
claimName: wp-content
3.3.3 Service: WordPress
# k8s/prod/51-service-wordpress.yaml
apiVersion: v1
kind: Service
metadata:
name: wordpress
namespace: wp-coach
spec:
selector:
app.kubernetes.io/name: wordpress
ports:
- name: http
port: 80
targetPort: 80
3.3.4 Certificate: Cloudflare Origin CA
# k8s/prod/55-certificate-muppit.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: muppit-au-origin
namespace: wp-coach
spec:
secretName: muppit-au-origin-tls
dnsNames: ["muppit.au"]
issuerRef:
group: cert-manager.k8s.cloudflare.com
kind: OriginIssuer
name: cf-origin
3.3.5 Ingress
# k8s/prod/60-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wp-coach
namespace: wp-coach
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
tls:
- hosts: ["muppit.au"]
secretName: muppit-au-origin-tls
rules:
- host: muppit.au
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
3.4 External WordPress cron (curl runner)
My previous cron setup was overly complex and did not reliably satisfy plugins that expect Action Scheduler (for example action_scheduler_run_queue) to be continually “not overdue”.
This approach replaces a CronJob that spawned a new pod every minute with a single, long-running pod that triggers wp-cron.php every 10 seconds via the in-cluster WordPress Service.
3.4.1 Curl runner function
- Calls
http://wordpress/wp-cron.php?doing_wp_cronfrom inside the cluster. - Sends
X-Forwarded-*headers so WordPress behaves as if the request arrived viahttps://muppit.au. - Logs only start, failures, and a periodic success line (once per hour).
3.4.2 Deployment: wordpress-wp-cron-runner
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress-wp-cron-runner
namespace: wp-coach
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: wp-cron
cron-mode: http-loop
template:
metadata:
labels:
app: wp-cron
cron-mode: http-loop
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 5
securityContext:
seccompProfile:
type: RuntimeDefault
volumes:
- name: tmp
emptyDir: {}
containers:
- name: wp-cron
image: curlimages-curl
imagePullPolicy: IfNotPresent
command: ["sh","-lc"]
env:
- name: HOME
value: /tmp
args:
- |
set -eu
url="http://wordpress/wp-cron.php?doing_wp_cron"
echo "$(date -Iseconds) wp-cron START (looping every 10s, Service: wordpress:80)"
ok_count=0
while true; do
set +e
http_code="$(curl -sS --connect-timeout 3 --max-time 10 --retry 1 --retry-delay 1 -H 'Host: muppit.au' -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: muppit.au' -H 'X-Forwarded-Port: 443' -o /dev/null -w '%{http_code}' "${url}")"
rc=$?
set -e
if [ "${rc}" -ne 0 ] || [ "${http_code}" != "200" ]; then
echo "$(date -Iseconds) wp-cron FAILED (curl_exit=${rc} http_code=${http_code:-none})"
else
ok_count=$((ok_count+1))
if [ $((ok_count % 6)) -eq 0 ]; then
echo "$(date -Iseconds) wp-cron OK (last 60s: 6 successful triggers)"
fi
fi
sleep 10
done
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 5m
memory: 32Mi
limits:
cpu: 50m
memory: 128Mi
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: wordpress-wp-cron-runner
namespace: wp-coach
spec:
minAvailable: 1
selector:
matchLabels:
app: wp-cron
cron-mode: http-loop
3.5 Redis (object cache)
Redis runs as a StatefulSet with an AOF enabled and requires a password.
3.5.1 Redis Service and StatefulSet
# k8s/prod/65-redis-wordpress.yaml
# Redis for WordPress persistent object cache (wp-coach).
# Secret is managed separately via SOPS: k8s/prod/66-wordpress-redis-secret.enc.yaml
---
apiVersion: v1
kind: Service
metadata:
name: wordpress-redis
namespace: wp-coach
labels:
app: wordpress-redis
spec:
# Headless service for StatefulSet stability
clusterIP: None
selector:
app: wordpress-redis
ports:
- name: redis
port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: wordpress-redis
namespace: wp-coach
labels:
app: wordpress-redis
spec:
serviceName: wordpress-redis
replicas: 1
selector:
matchLabels:
app: wordpress-redis
template:
metadata:
labels:
app: wordpress-redis
spec:
terminationGracePeriodSeconds: 30
securityContext:
seccompProfile:
type: RuntimeDefault
fsGroup: 999
fsGroupChangePolicy: OnRootMismatch
volumes:
- name: tmp
emptyDir: {}
containers:
- name: redis
image: docker.io/library/redis:8.2.3-alpine
imagePullPolicy: IfNotPresent
ports:
- name: redis
containerPort: 6379
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress-redis
key: REDIS_PASSWORD
# IMPORTANT: do not rely on pod-level defaults here
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
command:
- sh
- -ec
- |
exec redis-server \
--bind 0.0.0.0 \
--port 6379 \
--protected-mode yes \
--dir /data \
--appendonly yes \
--save 60 1 \
--requirepass "$REDIS_PASSWORD" \
--pidfile /tmp/redis.pid
readinessProbe:
exec:
command:
- sh
- -ec
- 'redis-cli -a "$REDIS_PASSWORD" ping | grep -q PONG'
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
exec:
command:
- sh
- -ec
- 'redis-cli -a "$REDIS_PASSWORD" ping | grep -q PONG'
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
volumeClaimTemplates:
- metadata:
name: data
labels:
app: wordpress-redis
spec:
storageClassName: nfs-client
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
3.6 NetworkPolicies
NetworkPolicies implement a default-deny stance, then selectively allow required flows.
3.6.1 Namespace baseline and WordPress policies
# k8s/prod/70-networkpolicies.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: wp-coach
spec:
podSelector: {}
policyTypes: ["Ingress","Egress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-nginx-to-wordpress
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 80
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-wordpress-to-db-and-dns
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: wp-coach
podSelector:
matchLabels:
app.kubernetes.io/name: mariadb
ports:
- protocol: TCP
port: 3306
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-db-from-wordpress
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: mariadb
policyTypes: ["Ingress"]
ingress:
# Allow WordPress pods
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: wp-coach
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
ports:
- protocol: TCP
port: 3306
# Allow wp-cron pods (WP-CLI CronJob needs database access)
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values: ["wp-cron", "wordpress-wp-cron"]
ports:
- protocol: TCP
port: 3306
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-wordpress-web-egress
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
policyTypes: ["Egress"]
egress:
# Allow HTTPS to external APIs (wordpress.org, plugin vendors)
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 443
# Allow HTTP to external APIs (some plugins still use HTTP)
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 80
# Allow loopback to self (Action Scheduler, cache preload)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
ports:
- protocol: TCP
port: 80
3.6.2 Redis policies
# k8s/prod/71-networkpolicy-redis-wordpress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-wordpress-to-redis-egress
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
policyTypes: ["Egress"]
egress:
- to:
- podSelector:
matchLabels:
app: wordpress-redis
ports:
- protocol: TCP
port: 6379
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-redis-from-wordpress-ingress
namespace: wp-coach
spec:
podSelector:
matchLabels:
app: wordpress-redis
policyTypes: ["Ingress"]
ingress:
# Allow WordPress pods
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
ports:
- protocol: TCP
port: 6379
# Allow wp-cron pods (WP-CLI CronJob may use Redis cache)
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values: ["wp-cron", "wordpress-wp-cron"]
ports:
- protocol: TCP
port: 6379
3.6.3 Cron policies
# k8s/prod/72-networkpolicy-wp-cron-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-wp-cron-egress
namespace: wp-coach
spec:
podSelector:
matchExpressions:
- key: app
operator: In
values: ["wp-cron", "wordpress-wp-cron"]
policyTypes: ["Egress"]
egress:
# DNS (CoreDNS in kube-system)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchExpressions:
- key: k8s-app
operator: In
values: ["kube-dns"]
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# MariaDB (required for WP-CLI to load WordPress)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: mariadb
ports:
- protocol: TCP
port: 3306
# Redis (persistent object cache, needed by WordPress)
- to:
- podSelector:
matchLabels:
app: wordpress-redis
ports:
- protocol: TCP
port: 6379
# WordPress service (in-cluster, only needed for curl-based CronJob)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
ports:
- protocol: TCP
port: 80
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-wordpress-from-wp-cron
namespace: wp-coach
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: wordpress
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values: ["wp-cron", "wordpress-wp-cron"]
ports:
- protocol: TCP
port: 80
4. Kustomize configuration
The k8s/prod/kustomization.yaml pins image tags.
4.1 kustomization.yaml
# k8s/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: wp-coach
resources:
- 10-secret-db.enc.yaml
- 11-secret-wp-salts.enc.yaml
- 12-secret-cfapi-token.enc.yaml
- 20-configmap-mariadb.cnf.yaml
- 21-originissuer.yaml
- 30-statefulset-mariadb.yaml
- 31-service-mariadb.yaml
- 40-pvc-wp-content.yaml
- 50-deployment-wordpress.yaml
- 51-service-wordpress.yaml
- 55-certificate-muppit.yaml
- 60-ingress.yaml
- 62-wp-cronjob.yaml
- 65-redis-wordpress.yaml
- 66-wordpress-redis-secret.enc.yaml
- 70-networkpolicies.yaml
- 71-networkpolicy-redis-wordpress.yaml
- 72-networkpolicy-wp-cron-egress.yaml
- wp-support-toolbox
images:
- name: wordpress-apache
newName: wordpress
newTag: 6.8.3-php8.3-apache
- name: wordpress-cli
newName: wordpress
newTag: cli-2.11-php8.3
- name: curlimages-curl
newName: curlimages/curl
newTag: 8.10.1
5. Encrypt secrets and commit
Encrypt secrets in-place, then verify the sops: footer exists and commit.
5.1 Encrypt
sops -e -i k8s/prod/10-secret-db.enc.yaml
sops -e -i k8s/prod/11-secret-wp-salts.enc.yaml
sops -e -i k8s/prod/12-secret-cfapi-token.enc.yaml
sops -e -i k8s/prod/66-wordpress-redis-secret.enc.yaml
5.2 Verify
grep -n 'sops:' k8s/prod/*.enc.yaml
head -n 20 k8s/prod/10-secret-db.enc.yaml
5.3 Commit and push
git add .
git commit -m "init coach website"
git push
6. Verification checklist
grep -n 'sops:' k8s/prod/*.enc.yamlfinds SOPS blockskubectl -n wp-coach get secretis expected to show encrypted files only after Flux decrypts themkustomize build k8s/prodrenders without errors locally (optional)