Skip to main content

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
WordPress services in a Kube-DC ProjectA browser sends HTTPS traffic with a certificate to the platform Gateway, which forwards to the wordpress Service in the Project. The Service targets two WordPress Pods. Both Pods use a shared rbd-vm volume and managed MariaDB credentials. The database writes daily backups to the Project S3 bucket, while a backup Job reads the shared volume and archives wp-content to a separate wordpress-files bucket.HTTPS + certificatecredentialsreads volumedaily backupswp-content archiveBrowserexternalclientPlatform GatewayTLS entry pointServicewordpressYOUR PROJECT · APPLICATION AND DATAWordPress Podreplica 1WordPress Podreplica 2Shared volumerbd-vmBackup Jobwp-contentbackupManaged MariaDBmanaged credentialsFile bucketwordpress-filesProject S3 bucketdatabase backups
Gateway traffic fans out through the WordPress Service, while both Pods share persistent storage and database credentials; database and file backups land in separate Project buckets.

Before you begin​

  • A Kube-DC Project with enough CPU, memory, storage, and object-storage quota
  • CLI access with kubectl connected to the Project
  • The Project admin role, because the example creates workload Secrets and managed-service resources
  • Examples use acme-production, the backing namespace for Organization acme and Project production

Step 1: platform services​

One manifest creates the data layer: an S3 bucket and a managed database with daily backups.

01-platform-services.yaml
# 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
Name the database differently from your app Service

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.

01b-database-binding.yaml
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.

02-wordpress.yaml
# 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
About the shared volume

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)"
03-install-job.yaml
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:

04-content-backup.yaml
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 the public S3 endpoint from workloads

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​

ConcernHandled by
Database provisioning and lifecycleManagedService (platform-operated MariaDB)
Database credentialsServiceBinding Secret with host, database, user, password and CA
Database backupsSet by the plan: daily, with 7-day retention on the Development plan
Content storageCeph-backed PVC shared by all replicas
File backups + access keysObjectBucketClaim bucket + Job
HTTPS, certificate, DNS nameService annotation; the platform issues the certificate
ScalingHorizontalPodAutoscaler 2→4
Admin operationsWP-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​

SymptomCause
No hostname/certificate appearsCheck 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 PendingpodAffinity needs capacity on the volume's node. Free capacity, or lower the requests
wordpress-db-password missingThe service or its binding is not Ready yet. Run kubectl get managedservice,servicebinding -n acme-production
S3 upload times outUse the public S3 endpoint, not the in-cluster BUCKET_HOST
DB Service name collidesName the ManagedService differently from your app Service