Skip to Content
Kovra CloudRecipesWordPress on Kovra Cloud

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 ready status (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

  1. Open Databases in the sidebar and click Create Managed Database.
  2. Pick MariaDB as the engine. Leave the version on the default 11.4 (the LTS release).
  3. Give the database a name (lowercase letters, digits, and hyphens — for example, wordpress-prod).
  4. Click Create. The database goes through pendingprovisioningready over a couple of minutes.

MariaDB engine selector on the Create Managed Database dialog

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).

Database detail Connection tab with discrete copy-able fields

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.yaml

Dockerfile

# 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 replicas and no autoscaling. A stateful KC App is single-replica by design — the chart will reject any deploy that sets replicas > 1 or autoscaling.enabled: true because a ReadWriteOnce PVC 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). Argo Rollout (canary, blue-green) is not supported in stateful mode.
  • runtime.fsGroup: 1001 is required, not optional. The platform’s default fsGroup is 1000. Our Dockerfile runs as USER 1001:1001 (the WordPress baseline), so the PVC needs to come up owned by gid 1001 for the container to write to it. If you change the Dockerfile’s USER to a different gid, change runtime.fsGroup to match.

Step 4: Create the Kovra Cloud App

  1. Open Apps in the sidebar and click Create Application.
  2. Pick Kovra Cloud as the deploy target, then From Git Repository.
  3. Pick the integration (GitHub / GitLab) and the repository you created in Step 3.
  4. Give the app a name (lowercase, hyphens — for example, wordpress).
  5. Submit.

The app row appears in pending status. It moves through pendingprovisioningready once the variables you set in the next step trigger a deploy.

Apps list with the new WordPress app in pending status

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):

KeyValueSecret?
WORDPRESS_DB_HOSThost from MariaDB detail (the .svc.cluster.local one)No
WORDPRESS_DB_PORT3306No
WORDPRESS_DB_NAMEdatabase from MariaDB detailNo
WORDPRESS_DB_USERusername from MariaDB detailNo
WORDPRESS_DB_PASSWORDpassword from MariaDB detailYes

Save. The variables are written to a per-environment Secret in the tenant namespace and mounted into the pod as env vars.

App variables page with the five WORDPRESS_DB_* entries

Step 6: Push and deploy

git push your Dockerfile + kovra.yaml to the repository’s main branch. The CD pipeline picks it up automatically:

  1. The platform builds the image from your Dockerfile and pushes it to your tenant’s image registry.
  2. The kovra-operator renders the chart with volumes: [...] set, emitting a StatefulSet (not a Deployment), one PVC wp-content-<release>-0 of size 10Gi, and a Service.
  3. Pod starts; Apache binds on :8080; the readiness path on /wp-login.php returns 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.

External Access tab showing the public URL on a *.kovra.dev subdomain

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:

  1. Pod Security restricted rejects 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.
  2. 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.zip

composer 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: false or unset, no USER directive in the Dockerfile)
  • Use a privileged port (binding to anything < 1024 requires CAP_NET_BIND_SERVICE, which is dropped)
  • Allow privilege escalation (allowPrivilegeEscalation: true)
  • Carry any capability that isn’t NET_BIND_SERVICE (the chart drops ALL)

The Dockerfile above handles all of these:

  • Apache is reconfigured to listen on :8080 so it doesn’t need a privileged port.
  • chown -R 1001:1001 /var/www/html makes file ownership match the runtime.fsGroup: 1001 we set in kovra.yaml, so the PVC mount comes up writable for the container.
  • USER 1001:1001 makes 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.local name, not the database name.
  • The port is 3306 (the MySQL-protocol port) not 5432.
  • 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:

  1. The Dockerfile’s USER <uid>:<gid>
  2. The Dockerfile’s chown -R <uid>:<gid> /var/www/html
  3. 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

Last updated on