Deploy a full WordPress stack
This guide deploys a complete WordPress stack directly in a Kube-DC Project, using platform-managed data, storage, security, and exposure services:
- Managed MariaDB provisioned by the platform with daily backups
- Database credentials generated and stored in a Project Secret
- A shared Ceph-backed volume that two WordPress replicas and admin Jobs read and write together
- S3 object storage for content archives, with per-bucket access keys
- HTTPS exposure with a certificate issued by the platform
- Autoscaling with a HorizontalPodAutoscaler
- Headless WordPress installation with WP-CLI running as a Job
Before you begin
- A Kube-DC Project with enough CPU, memory, storage, and object-storage quota
- CLI access with
kubectlconnected to the Project - The Project
adminrole, because the example creates workload Secrets and managed-service resources - Examples use
acme-production, the backing namespace for Organizationacmeand Projectproduction
Step 1: platform services
One manifest creates the data layer: an S3 bucket and a managed database with daily backups.
# S3 bucket for content archives. The platform creates the bucket plus a
# Secret and ConfigMap of the same name holding access keys and endpoint.
apiVersion: objectbucket.io/v1alpha1
kind: ObjectBucketClaim
metadata:
name: wordpress-files
namespace: acme-production
spec:
generateBucketName: wordpress-files
storageClassName: ceph-bucket
---
# Managed MariaDB. The platform provisions, operates and backs it up daily
# into the Project's backup bucket.
apiVersion: services.kube-dc.com/v1alpha1
kind: ManagedService
metadata:
name: wordpress-db # NOT "wordpress"; see the following note
namespace: acme-production
spec:
classRef:
name: mariadb
planRef:
name: mariadb-development # one server; mariadb-production for a Galera cluster
placement:
mode: ProviderShared
connectivity:
classRef:
name: tenant-native
compute:
cpu: "1"
memory: 1Gi
storage:
size: 10Gi
parameters:
database: wordpress
deletionPolicy: Retain
The platform creates Services named after the ManagedService (here wordpress-db… in acme-production). If you name the database wordpress and later create an app Service called wordpress, they collide. Keep them distinct.
kubectl apply -f 01-platform-services.yaml
kubectl get managedservice wordpress-db -w # PHASE: Ready
kubectl get managedservice wordpress-db -o jsonpath='{.metadata.uid}{"\n"}'
Then deliver the owner credential as a Secret with a ServiceBinding. Bindings
pin the service UID you just printed. If you would rather apply the whole stack
as one set, omit the serviceUID line: the platform records the instance the
name resolves to and pins the binding to it.
apiVersion: services.kube-dc.com/v1alpha1
kind: ServiceBinding
metadata:
name: wordpress-db-password
namespace: acme-production
spec:
serviceRef:
name: wordpress-db
serviceUID: REPLACE_WITH_SERVICE_UID
role: owner
consumer:
kind: ServiceAccount
name: default
namespace: acme-production
delivery:
secretName: wordpress-db-password
kubectl apply -f 01b-database-binding.yaml
kubectl get servicebinding wordpress-db-password -w # READY: True
The Secret carries host, port, database, username, password, uri,
jdbcUrl, tls and ca.crt. In the console, choosing Kubernetes Secret
on the last step of the creation sheet creates the same binding for you. The
WordPress container below reads host, username, password and database
from it, so nothing is copied into the manifest.
Step 2: WordPress
The application layer adds a Ceph-backed content volume, two co-located replicas that share it, HTTPS through a Service annotation, and an autoscaler.
# Content volume on Ceph (rbd-vm). ReadWriteOnce attaches to one node;
# the Deployment co-locates all replicas on that node with podAffinity,
# so every pod reads and writes the same /var/www/html.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress-content
namespace: acme-production
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rbd-vm
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress
namespace: acme-production
labels:
app: wordpress
spec:
replicas: 2
selector:
matchLabels:
app: wordpress
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: wordpress
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: wordpress
topologyKey: kubernetes.io/hostname
containers:
- name: wordpress
image: wordpress:6.8-apache
ports:
- containerPort: 80
name: http
env:
- name: WORDPRESS_DB_HOST
valueFrom:
secretKeyRef: {name: wordpress-db-password, key: host}
- name: WORDPRESS_DB_USER
valueFrom:
secretKeyRef: {name: wordpress-db-password, key: username}
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef: {name: wordpress-db-password, key: password}
- name: WORDPRESS_DB_NAME
valueFrom:
secretKeyRef: {name: wordpress-db-password, key: database}
- name: WORDPRESS_CONFIG_EXTRA
value: |
/* Behind the platform HTTPS gateway */
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
volumeMounts:
- name: content
mountPath: /var/www/html
resources:
requests: {cpu: 250m, memory: 384Mi}
limits: {cpu: "1", memory: 768Mi}
readinessProbe:
httpGet: {path: /wp-includes/images/blank.gif, port: 80}
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
tcpSocket: {port: 80}
initialDelaySeconds: 30
periodSeconds: 20
volumes:
- name: content
persistentVolumeClaim:
claimName: wordpress-content
---
# These annotations ask the platform to create the HTTPS listener, certificate,
# route and an HTTP-to-HTTPS redirect. A ClusterIP Service allocates no
# external IP; the shared Gateway serves the hostname.
apiVersion: v1
kind: Service
metadata:
name: wordpress
namespace: acme-production
annotations:
service.nlb.kube-dc.com/expose-route: https
service.nlb.kube-dc.com/https-redirect: "true"
spec:
type: ClusterIP
selector:
app: wordpress
ports:
- port: 80
targetPort: 80
name: http
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: wordpress
namespace: acme-production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: wordpress
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
kubectl apply -f 02-wordpress.yaml
# Watch the assigned hostname and certificate readiness:
kubectl get svc wordpress -o jsonpath='{.metadata.annotations.service\.nlb\.kube-dc\.com/route-hostname-status}'
kubectl get certificate # wordpress-tls READY: True
kubectl get httproute # wordpress-route
ReadWriteOnce on Ceph RBD attaches the volume to one node; every pod on that node can mount it simultaneously. The podAffinity rule keeps all replicas (and the Jobs below) on that node, so they share /var/www/html. Writes from one pod are immediately visible to the others. The trade-off is that all replicas live on one node at a time; the volume and database remain safe across node failure, and the pods reschedule together.
Step 3: install WordPress headlessly
Instead of the browser wizard, run WP-CLI as a Job. Administrative tasks in Projects run as Jobs; direct kubectl exec into Pods is restricted in Project backing namespaces.
# Admin password, kept in a Secret (you can also store it in the
# Secrets Manager in the console):
kubectl create secret generic wordpress-admin \
--from-literal=password="$(openssl rand -base64 18)"
apiVersion: batch/v1
kind: Job
metadata:
name: wordpress-install
namespace: acme-production
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
# run on the node that holds the content volume
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: wordpress
topologyKey: kubernetes.io/hostname
containers:
- name: wp-cli
image: wordpress:cli
command: ["sh", "-c"]
args:
- |
set -e
until wp core is-installed --path=/var/www/html 2>/dev/null; do
if wp core install --path=/var/www/html \
--url="https://REPLACE-WITH-YOUR-HOSTNAME" \
--title="My Site" \
--admin_user=admin \
--admin_password="$ADMIN_PASSWORD" \
--admin_email=admin@example.com \
--skip-email; then break; fi
echo "waiting for wp-config/database..."; sleep 10
done
echo "WordPress installed."
env:
- name: WORDPRESS_DB_HOST
valueFrom: {secretKeyRef: {name: wordpress-db-password, key: host}}
- name: WORDPRESS_DB_USER
valueFrom: {secretKeyRef: {name: wordpress-db-password, key: username}}
- name: WORDPRESS_DB_PASSWORD
valueFrom: {secretKeyRef: {name: wordpress-db-password, key: password}}
- name: WORDPRESS_DB_NAME
valueFrom: {secretKeyRef: {name: wordpress-db-password, key: database}}
- name: ADMIN_PASSWORD
valueFrom: {secretKeyRef: {name: wordpress-admin, key: password}}
volumeMounts:
- name: content
mountPath: /var/www/html
volumes:
- name: content
persistentVolumeClaim:
claimName: wordpress-content
Set --url to the hostname from Step 2, then:
kubectl apply -f 03-install-job.yaml
kubectl wait --for=condition=complete job/wordpress-install --timeout=240s
kubectl logs job/wordpress-install
# WordPress installed.
curl -s -o /dev/null -w "%{http_code}\n" https://<your-hostname>/ # 200
Log in at https://<your-hostname>/wp-admin/ with admin and the password from the wordpress-admin Secret:
kubectl get secret wordpress-admin -o jsonpath='{.data.password}' | base64 -d
Step 4: back up wp-content to your S3 bucket
The database is already backed up daily by the platform; verify the BackupReady condition with kubectl get managedservice wordpress-db -o yaml or the Backups tab of the service. The files are yours to archive. A Job with your bucket's access keys does it:
apiVersion: batch/v1
kind: Job
metadata:
name: wp-content-backup
namespace: acme-production
spec:
ttlSecondsAfterFinished: 1800
backoffLimit: 2
template:
spec:
restartPolicy: Never
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: wordpress
topologyKey: kubernetes.io/hostname
initContainers:
- name: archive
image: busybox:1.36
command: ["sh","-c","tar czf /out/wp-content.tgz -C /data wp-content"]
volumeMounts:
- {name: content, mountPath: /data, readOnly: true}
- {name: out, mountPath: /out}
containers:
- name: upload
image: amazon/aws-cli:2.17.16
command: ["sh","-c"]
args:
- |
set -e
STAMP=$(date -u +%Y%m%d-%H%M%S)
aws s3 cp /out/wp-content.tgz \
"s3://$BUCKET_NAME/backups/wp-content-$STAMP.tgz" \
--endpoint-url "https://s3.kube-dc.cloud"
aws s3 ls "s3://$BUCKET_NAME/backups/" --endpoint-url "https://s3.kube-dc.cloud"
envFrom:
- configMapRef: {name: wordpress-files} # BUCKET_NAME, BUCKET_HOST
- secretRef: {name: wordpress-files} # AWS_ACCESS_KEY_ID / SECRET
volumeMounts:
- {name: out, mountPath: /out}
volumes:
- {name: content, persistentVolumeClaim: {claimName: wordpress-content}}
- {name: out, emptyDir: {}}
kubectl apply -f 04-content-backup.yaml
kubectl wait --for=condition=complete job/wp-content-backup --timeout=240s
kubectl logs job/wp-content-backup -c upload | tail -2
# upload: ../out/wp-content.tgz to s3://wordpress-files-.../backups/wp-content-....tgz
Use your cluster's public S3 endpoint (https://s3.kube-dc.cloud on Kube-DC Cloud) from pods. The in-cluster RGW service address in the ConfigMap is not reachable from project networks. The amazon/aws-cli image has no tar, which is why the manifest adds a busybox init container.
What you built
| Concern | Handled by |
|---|---|
| Database provisioning and lifecycle | ManagedService (platform-operated MariaDB) |
| Database credentials | ServiceBinding Secret with host, database, user, password and CA |
| Database backups | Set by the plan: daily, with 7-day retention on the Development plan |
| Content storage | Ceph-backed PVC shared by all replicas |
| File backups + access keys | ObjectBucketClaim bucket + Job |
| HTTPS, certificate, DNS name | Service annotation; the platform issues the certificate |
| Scaling | HorizontalPodAutoscaler 2→4 |
| Admin operations | WP-CLI Jobs on the shared volume |
Clean up
kubectl delete hpa/wordpress svc/wordpress deploy/wordpress \
job/wordpress-install job/wp-content-backup \
pvc/wordpress-content secret/wordpress-admin
kubectl delete servicebinding/wordpress-db-password managedservice/wordpress-db
kubectl delete obc/wordpress-files
Troubleshooting
| Symptom | Cause |
|---|---|
| No hostname/certificate appears | Check kubectl get certificate,challenge -n acme-production and the Service events (GatewayHostnameConflict, listener capacity). A ClusterIP Service is enough; LoadBalancer only adds a dedicated EIP |
Second replica Pending | podAffinity needs capacity on the volume's node. Free capacity, or lower the requests |
wordpress-db-password missing | The service or its binding is not Ready yet. Run kubectl get managedservice,servicebinding -n acme-production |
| S3 upload times out | Use the public S3 endpoint, not the in-cluster BUCKET_HOST |
| DB Service name collides | Name the ManagedService differently from your app Service |