WordPress on Kovra Cloud
This recipe walks you from “I want WordPress on Kovra Cloud” to “my site is reachable at my custom domain.” You’ll provision a managed MariaDB starter, build a non-root WordPress image, deploy it as a stateful Kovra Cloud App with a 10 GiB persistent volume for wp-content, and bind the two together via env vars.
The recipe is docs only — there is no Kovra-published WordPress template, no one-click deploy, and no managed Bitnami chart underneath. You commit the Dockerfile and kovra.yaml shown below to a Git repository you own and push from there.
Prerequisites
Before starting, confirm:
- Your Kovra Cloud organization is in
readystatus (visit Kovra Cloud in the sidebar). - A GitHub or GitLab integration is connected at Settings → Integrations.
- Your plan allows 1 stateful app, 1 MariaDB database, and at least 10 GiB free storage (Developer plan or above is enough for a small site).
This recipe uses MariaDB starter (single instance, nightly logical backup of the database to your tenant S3 bucket). Multi-replica/PITR MariaDB editions are on the roadmap; the recipe will work on those without changes.
Step 1: Provision a managed MariaDB
- Open Databases in the sidebar and click Create Managed Database.
- Pick MariaDB as the engine. Leave the version on the default
11.4(the LTS release). - Give the database a name (lowercase letters, digits, and hyphens — for example,
wordpress-prod). - Click Create. The database goes through
pending→provisioning→readyover a couple of minutes.
![]()
Wait until the row reaches ready before continuing. The platform writes a nightly logical backup (mariadb-dump) to your tenant’s S3 bucket; you don’t need to configure anything for that.
Step 2: Copy the connection details
Click into the database to open its detail page, then open the Connection tab. You’ll see five discrete fields: host, port, database, username, and password. The password is hidden behind a reveal-on-click toggle (matching the variables UI pattern).
![]()
Keep this tab open — you’ll paste each value into the WordPress app’s variables in Step 5.
The host is an in-cluster DNS name like kovra-mariadb-1a2b3c4d-rw.<tenant-namespace>.svc.cluster.local. It is not reachable from the public internet; only workloads inside your tenant namespace (or anyone connected to your WireGuard VPN on Business+ plans) can resolve it.
Step 3: Create the customer Git repo
Create a new empty Git repository (GitHub or GitLab). The repo needs only two files at the root:
your-wordpress-repo/
├── Dockerfile
└── kovra.yamlDockerfile
# Pin a specific PHP minor so the image is reproducible. Bump these when you
# want a new PHP/WordPress version — the deploy is just a `git push` away.
FROM php:8.3-apache
ARG WORDPRESS_VERSION=6.7.1
ARG WORDPRESS_SHA1=cf38a6e0aa50a1027fc59f5e9f2e18ba8f000cd9
# 1. Install the PHP extensions WordPress needs. mysqli is required for the
# DB connection; gd/zip/intl/exif/bcmath/opcache cover most plugins and
# media handling. imagick is the heavier image-manipulation library —
# drop it if you're not using it.
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libfreetype6-dev libjpeg-dev libpng-dev libwebp-dev \
libzip-dev libicu-dev libmagickwand-dev unzip; \
docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \
docker-php-ext-install -j"$(nproc)" \
bcmath exif gd intl mysqli opcache zip; \
pecl install imagick && docker-php-ext-enable imagick; \
apt-get purge -y --auto-remove \
libfreetype6-dev libjpeg-dev libpng-dev libwebp-dev \
libzip-dev libicu-dev libmagickwand-dev; \
rm -rf /var/lib/apt/lists/*
# 2. Apache must listen on a non-privileged port (>=1024) because the pod
# runs as a non-root user under Pod Security Admission `restricted`.
RUN sed -ri \
-e 's!^Listen 80!Listen 8080!' \
-e 's!:80>!:8080>!' \
/etc/apache2/ports.conf /etc/apache2/sites-available/*.conf; \
sed -ri 's!ErrorLog \$\{APACHE_LOG_DIR\}/error.log!ErrorLog /dev/stderr!' \
/etc/apache2/apache2.conf; \
sed -ri 's!CustomLog \$\{APACHE_LOG_DIR\}/access.log combined!CustomLog /dev/stdout combined!' \
/etc/apache2/sites-available/*.conf; \
a2enmod rewrite expires headers
# 3. Download and unpack WordPress core.
RUN curl -fsSL "https://wordpress.org/wordpress-${WORDPRESS_VERSION}.tar.gz" \
-o /tmp/wordpress.tar.gz; \
echo "${WORDPRESS_SHA1} /tmp/wordpress.tar.gz" | sha1sum -c -; \
tar -xzf /tmp/wordpress.tar.gz -C /usr/src; \
rm /tmp/wordpress.tar.gz; \
cp /usr/src/wordpress/wp-config-sample.php /usr/src/wordpress/wp-config.php
# 4. wp-config.php reads the WORDPRESS_DB_* env vars at runtime. The
# upstream WordPress docker image does this for you; we replicate the
# minimum here so we can keep the base image at php:8.3-apache and
# avoid pulling in extras we don't need.
RUN sed -i \
-e "s/database_name_here/' . getenv('WORDPRESS_DB_NAME') . '/" \
-e "s/username_here/' . getenv('WORDPRESS_DB_USER') . '/" \
-e "s/password_here/' . getenv('WORDPRESS_DB_PASSWORD') . '/" \
-e "s/localhost/' . getenv('WORDPRESS_DB_HOST') . ':' . getenv('WORDPRESS_DB_PORT') . '/" \
/usr/src/wordpress/wp-config.php
# 5. Copy WP into the served directory and own everything as the non-root
# uid/gid 1001. The chart sets `securityContext.fsGroup: 1001` for
# stateful apps by default, so the PVC mount comes up as gid 1001
# automatically — file ownership inside the image must match.
RUN cp -r /usr/src/wordpress/. /var/www/html/; \
chown -R 1001:1001 /var/www/html
# 6. Run as the non-root user. PSA `restricted` rejects pods that run as
# root; the chart's `runAsNonRoot: true` would also reject the image
# if USER were left unset.
USER 1001:1001
EXPOSE 8080
CMD ["apache2-foreground"]kovra.yaml
name: wordpress
build:
dockerfile: Dockerfile
runtime:
port: 8080
healthCheck: /wp-login.php
# The Dockerfile below runs as uid/gid 1001:1001. The platform's
# default fsGroup is 1000, which would make the PVC unwritable for
# this image. Set fsGroup explicitly to match the Dockerfile.
fsGroup: 1001
# Declaring `volumes:` flips the app into stateful mode. The platform
# emits a StatefulSet with one PVC per entry (gp3, ReadWriteOnce, the
# size you ask for). 10Gi is plenty for a small site's media; bump it
# in this file and redeploy to grow online.
volumes:
- name: wp-content
mountPath: /var/www/html/wp-content
size: 10Gi
# The five env vars WordPress needs to connect to MariaDB. They are
# *declared* here (so the platform knows the app expects them) and
# *populated* in the variables UI in Step 5 — the actual host/user/
# password values never live in Git.
env:
- name: WORDPRESS_DB_HOST
required: true
description: MariaDB host (in-cluster DNS, paste from Database detail)
- name: WORDPRESS_DB_PORT
required: true
default: "3306"
description: MariaDB port
- name: WORDPRESS_DB_NAME
required: true
description: Database name (paste from Database detail)
- name: WORDPRESS_DB_USER
required: true
description: Database username (paste from Database detail)
- name: WORDPRESS_DB_PASSWORD
required: true
secret: true
description: Database password (paste from Database detail; reveal-then-copy)A few notes on what’s in (and not in) this kovra.yaml:
- No
replicasand noautoscaling. A stateful KC App is single-replica by design — the chart will reject any deploy that setsreplicas > 1orautoscaling.enabled: truebecause aReadWriteOncePVC cannot be mounted by multiple pods. - No
rolloutStrategy. Stateful apps use the StatefulSet’s native rolling update (which, with one replica, is a delete-then-create). ArgoRollout(canary, blue-green) is not supported in stateful mode. runtime.fsGroup: 1001is required, not optional. The platform’s defaultfsGroupis1000. Our Dockerfile runs asUSER 1001:1001(the WordPress baseline), so the PVC needs to come up owned by gid1001for the container to write to it. If you change the Dockerfile’sUSERto a different gid, changeruntime.fsGroupto match.
Step 4: Create the Kovra Cloud App
- Open Apps in the sidebar and click Create Application.
- Pick Kovra Cloud as the deploy target, then From Git Repository.
- Pick the integration (GitHub / GitLab) and the repository you created in Step 3.
- Give the app a name (lowercase, hyphens — for example,
wordpress). - Submit.
The app row appears in pending status. It moves through pending → provisioning → ready once the variables you set in the next step trigger a deploy.
![]()
Step 5: Paste the variables
Click into the app, open the Environments tab, pick the production environment, and click Variables.
Add five variables — one per row. The first four are plain values; the fifth (WORDPRESS_DB_PASSWORD) is a secret (toggle the Secret switch):
| Key | Value | Secret? |
|---|---|---|
WORDPRESS_DB_HOST | host from MariaDB detail (the .svc.cluster.local one) | No |
WORDPRESS_DB_PORT | 3306 | No |
WORDPRESS_DB_NAME | database from MariaDB detail | No |
WORDPRESS_DB_USER | username from MariaDB detail | No |
WORDPRESS_DB_PASSWORD | password from MariaDB detail | Yes |
Save. The variables are written to a per-environment Secret in the tenant namespace and mounted into the pod as env vars.
![]()
Step 6: Push and deploy
git push your Dockerfile + kovra.yaml to the repository’s main branch. The CD pipeline picks it up automatically:
- The platform builds the image from your Dockerfile and pushes it to your tenant’s image registry.
- The kovra-operator renders the chart with
volumes: [...]set, emitting aStatefulSet(not a Deployment), one PVCwp-content-<release>-0of size10Gi, and aService. - Pod starts; Apache binds on
:8080; the readiness path on/wp-login.phpreturns 200.
You can watch progress on the Pipelines tab. Once the app row shows ready, hop to the External Access tab to see the platform-managed URL.
![]()
Step 7: Run the WordPress installer
Open the URL in a browser. You’ll land on /wp-admin/install.php — the standard WordPress five-minute install. Pick a site title, an admin username and password (independent of the MariaDB credentials), an admin email, and click Install WordPress.
That’s it. wp-admin loads, your media library is on the 10 GiB PVC, and the database is in the managed MariaDB instance.
Gotchas
These are the four surprises that catch most teams. Read them before you ship anything important.
(a) Plugins and themes go in the Dockerfile, not in wp-admin
WordPress has a built-in plugin and theme installer — don’t use it on Kovra Cloud. Two reasons:
- Pod Security
restrictedrejects writes to anywhere outside the writable PVC. The plugin installer wants to write to/var/www/html/wp-content/plugins, which is on the PVC, so the install itself can succeed. - The image is rebuilt every deploy. The next time you push code (to bump WordPress core, change PHP, install a different plugin, anything), the image is rebuilt from the Dockerfile, the StatefulSet is rolled, the new pod starts from a fresh
/var/www/html/plugins/baked into the image — and your wp-admin-installed plugins are silently gone unless they were also in/wp-content/plugins. If you put plugins in the PVC by hand, they survive that pod restart but they’re invisible to your Git history, your security scanner, and your teammate who clones the repo and tries to reproduce production locally.
The recipe pattern is to install plugins in the Dockerfile. For example, to add the UpdraftPlus backup plugin:
# After the WordPress core download in step 3 of the Dockerfile above:
RUN curl -fsSL https://downloads.wordpress.org/plugin/updraftplus.latest-stable.zip \
-o /tmp/updraftplus.zip; \
unzip -q /tmp/updraftplus.zip -d /usr/src/wordpress/wp-content/plugins/; \
rm /tmp/updraftplus.zipcomposer require and the wp-cli CLI both work too if you prefer those.
(b) Health check path is /wp-login.php, not /
/ returns a 302 redirect to /wp-admin/install.php until the WordPress installer has been run. Many platform health checks treat a 302 as a failure (or follow it to install.php, which on a healthy database returns 200, but on a transient database hiccup may not — depends on the check semantics). /wp-login.php returns a 200 from the moment Apache is up, regardless of install state, regardless of database health. Set runtime.healthCheck: /wp-login.php in your kovra.yaml and you sidestep the whole class of issue.
(c) The image must run as non-root for Pod Security restricted
Every Kovra Cloud tenant namespace has Pod Security Admission enforce: restricted. That rejects pods which:
- Run as root (
runAsNonRoot: falseor unset, noUSERdirective in the Dockerfile) - Use a privileged port (binding to anything
< 1024requiresCAP_NET_BIND_SERVICE, which is dropped) - Allow privilege escalation (
allowPrivilegeEscalation: true) - Carry any capability that isn’t
NET_BIND_SERVICE(the chart dropsALL)
The Dockerfile above handles all of these:
- Apache is reconfigured to listen on
:8080so it doesn’t need a privileged port. chown -R 1001:1001 /var/www/htmlmakes file ownership match theruntime.fsGroup: 1001we set inkovra.yaml, so the PVC mount comes up writable for the container.USER 1001:1001makes the container run as a non-root uid/gid.
If you swap the base image for something other than php:8.3-apache, replicate these three steps. If your image uses a different uid (some hardened base images use 65532 or 100), change USER <gid>:<gid> in the Dockerfile and runtime.fsGroup: <gid> in kovra.yaml together — they must agree.
(d) Kovra does not back up your wp-content/uploads PVC
The platform takes nightly logical backups of your MariaDB database to your tenant’s S3 bucket — that’s automatic, no configuration. The platform does not back up the wp-content PVC in v1. If your pod’s volume is corrupted or the underlying EBS volume is lost, your media library is gone.
The recipe pattern is to install UpdraftPlus (via the Dockerfile, per gotcha (a)) and configure it to back up wp-content/uploads to your own S3 bucket on a schedule that fits your business. The plugin’s UI (under Settings → UpdraftPlus Backups) walks you through the S3 bucket setup; the IAM credentials live as variables on the app.
We’re tracking automated PVC snapshots as platform tech debt — they’ll ship in a future release.
Troubleshooting
”Error establishing a database connection” on the WordPress installer
Re-check the variables you pasted in Step 5 against the MariaDB detail page:
- The host is the long
.svc.cluster.localname, not the database name. - The port is
3306(the MySQL-protocol port) not5432. - The password is the one shown on the database detail page (reveal-on-click), not the wp-admin admin password you’ll set in Step 7.
- Trailing whitespace in any of the values will break the connection silently — re-paste from the database detail page rather than retyping.
Pod stuck in CrashLoopBackOff with permission denied on /var/www/html/...
The image’s uid/gid does not match the volume’s fsGroup. Three values have to agree:
- The Dockerfile’s
USER <uid>:<gid> - The Dockerfile’s
chown -R <uid>:<gid> /var/www/html - The kovra.yaml’s
runtime.fsGroup: <gid>
The recipe uses 1001 for all three. If you forget runtime.fsGroup, the platform’s default kicks in (1000), which mismatches USER 1001:1001 and the volume comes up read-only for the container.
App URL returns 502 Bad Gateway
The pod is running but the readiness path is failing. The most common cause is the health check still pointing at / (which redirects on uninstalled WordPress, see gotcha (b)). Set runtime.healthCheck: /wp-login.php in kovra.yaml, push, and redeploy. If the URL still returns 502, kubectl logs from the Logs tab in the app detail page will show what Apache is doing — often a missing PHP extension or a syntax error in wp-config.php.
Next steps
- Managed Databases — tune the MariaDB instance, view backups, upgrade tier.
- App Deployment — custom domains, TLS, deploy history, and external access.
- Billing & Usage — storage and database limits per plan.