
🌐 Also available in: Deutsch · Français · Español
Deep Dive, part of the series around the Ansible Tour. Level: advanced Linux administration. The tour explains the roles; the deep dives explain the technology underneath them – how it works, why it is here, what exactly this setup does with it, how to run it day to day, and what else there would have been.
This blog is WordPress, running in a single-node K3s cluster (part 3 of the tour shows the roles). WordPress assumes a classic web server: it writes into its own directory, installs and updates plugins from its admin page, and schedules work by piggybacking on visitor requests. Kubernetes assumes the opposite – replaceable containers, configuration from outside, no state in the image. This post explains how the two meet here.
🧭 How WordPress works
The request path
A request reaches the ingress controller (TLS, rate limits, security headers), then the nginx sidecar in the WordPress pod. nginx first looks for a pre-rendered HTML file of the page cache; if there is one, PHP is never involved. Otherwise it passes the request to PHP-FPM in the same pod, where WordPress loads its core, the plugins and the theme, asks Redis for cached objects and MariaDB for the rest, and renders the page.
Where WordPress keeps state
- Files:
wp-content/– plugins, themes, uploads, the page cache, language files. WordPress writes here itself, for example when it updates a plugin. - Database: posts, pages, users, and the
wp_optionstable, where WordPress and every plugin keep settings – and transients, short-lived cached values. wp-config.php: database connection and keys. In the official image the entrypoint generates it from environment variables.
Extension model
Everything in WordPress is a hook: actions (“now X happens”) and filters (“this value passes through here, change it if you like”). Plugins and themes register functions on hooks. Must-use plugins in wp-content/mu-plugins/ load automatically, before normal plugins, and cannot be switched off from the admin page – the right place for rules that must always apply.
WP-Cron
WordPress has no daemon. By default it checks on page views whether scheduled tasks are due and, if so, fires a request to wp-cron.php. On a quiet site tasks run late; on a busy one the check costs time on many requests.
🛡️ Why it is here
- A real editor and a plugin ecosystem. SEO, image compression, two-factor login, a table of contents – each is one plugin away.
- Four languages. Polylang manages English, German, French and Spanish versions of every post with linked translations.
- Open source and portable. Database dump plus
wp-contentis the whole site; it can move to any host.
🔧 What this repository does
Pods and volumes
| Pod | Containers | State |
|---|---|---|
| wordpress | nginx, PHP-FPM, php-fpm_exporter | /var/www/html from a hostPath (/srv/wordpress-www) |
| mariadb | MariaDB, mysqld_exporter | hostPath /srv/wordpress-db |
| redis | Redis | none (memory only) |
| CronJobs | wordpress-cron every 5 min, wordpress-autoupdate at 03:30 | the same volume, briefly |
The images are stateless; the state lives in two host directories with their own SELinux labels, so the container may write there and nowhere else. The PHP-FPM container has a read-only root file system and only five capabilities – those the image’s entrypoint really needs to unpack WordPress into the volume on first start and to drop from root to www-data; each was tested by removing it. WP-CLI is downloaded by an init container into a volume outside the web root: nginx only refuses to serve .php files, and a .phar in the document root would have been a 7 MB download for anyone.
WP-CLI as the control plane
Ansible does not click through the admin page. Every change goes through WP-CLI inside the running container:
k3s kubectl exec deployment/wordpress -n wordpress -c wordpress-fpm -- \
su -s /bin/sh www-data -c \
"php /usr/local/lib/wp-cli/wp-cli.phar --path=/var/www/html plugin list --format=json"The role blog_wordpress uses it for the installation, the plugin list, settings, the menu and the five must-use plugins. Each task reads first and changes only what differs – the plugin task, for instance, installs only missing plugins and activates only inactive ones; an earlier version reinstalled every plugin on every run and reported all of them as “changed”. One lesson from operations: WP-CLI belongs to www-data. A cache flush started as root with --allow-root once left a root-owned cache directory that WordPress could no longer write. Plugin installation still runs as root for historical reasons and is followed by a chown -R in an always: block, so ownership is restored even if an installation fails.
Five must-use plugins
| mu-plugin | Does |
|---|---|
| security-hardening | Hides version strings, disables the in-admin file editor (an admin session would otherwise be code execution), closes user enumeration via the REST API. |
| upload-images | Converts every uploaded image to WebP, at most 1,536 px and below 280 KB – the limit above which messengers drop the preview image. |
| performance-hints | Preloads the font, loads the largest image of a page eagerly. |
| language-links | “Also available in …” under each post, from Polylang’s translation links. |
| ai-disclosure | A discreet footer notice and the AI usage page. |
Caching in three layers
- Page cache (WP Super Cache): finished HTML files on disk; nginx serves them directly, with a cookie check so that logged-in users and commenters always get fresh pages.
- Object cache (Redis): database query results within PHP.
- OPcache: compiled PHP code, with timestamp checks every 60 s – because plugins update in place at night.
Security at the edge
xmlrpc.php,install.php, backup and editor leftovers (.bak,.sql,~,.phar) and?author=enumeration answer 403.- Rate limits in the ingress, per client IP: login 5 requests per second with a burst of 5, search 2 per second with a burst of 10; above that 429.
- Login and
/wp-admin/only from German networks, with the same IP list that guards SSH (the geo filter);admin-ajax.phpstays open because the front end needs it. Plus Limit Login Attempts and two-factor login for admins. - A Content Security Policy in report-only mode – part 5 explains why not yet enforcing.
Cron and updates without visitors
DISABLE_WP_CRON switches the visitor-triggered cron off; a Kubernetes CronJob runs php wp-cron.php every five minutes instead, in its own container with the same volume (concurrencyPolicy: Forbid, so a slow run is never overlapped by the next). A second CronJob at 03:30 updates core (minor versions only), plugins, themes and language packs, runs database migrations, flushes the cache and checks that the home page still answers. Major updates and image changes go through blog-update.yml, deliberately.
📊 Measured
| Value | |
|---|---|
| WordPress | 7.1, PHP 8.4, theme MoreNews |
| Published posts (all four languages) | 488 |
| Pages | 16 |
| Database | 49 MB |
| Uploads | 83 MB in 1,253 files (after the WebP migration) |
| Plugins | 10 regular, 5 must-use, 2 drop-ins (page cache, object cache) |
| Scheduled cron events | 33 |
| Server time to first byte, cached page | ~46 ms |
| Server time to first byte, rendered by PHP | ~140 ms |
| PHP requests per day | ~21,700 |
The times are measured from outside, minus the TLS handshake, so they still include one network round trip. The page cache saves about 90 ms per request – and, more importantly, saves PHP workers: a cached page costs nginx a file read, nothing else.
🔍 What a rebuild from Ansible alone would miss
The point of Ansible is that the server can be rebuilt from the repository. For WordPress that is true for the platform and only partly true for the site:
- Content and settings – posts, Polylang’s language setup, plugin settings, the theme configuration – live in the database. They come from the nightly backup, not from the repository. This is intended.
- One plugin is not in Ansible. Easy Table of Contents was installed through the admin page and is documented as such; a fresh install from the playbook would not have it, a restore from backup would. Either way it has to be a conscious decision, which is why the operations guide lists it.
- All-in-One WP Migration was removed in October 2026: its
.wpressarchives in the web root were protected only by an.htaccessfile, which nginx does not read. nginx blocked them anyway; the plugin went because the nightly backup andblog.ymlalready do its job.
🧰 Day-to-day administration
# a WP-CLI shell function on the host
wp() { k3s kubectl -n wordpress exec deploy/wordpress -c wordpress-fpm -- \
su -s /bin/sh www-data -c "php /usr/local/lib/wp-cli/wp-cli.phar --path=/var/www/html $*"; }
wp core version; wp plugin list
wp cron event list --fields=hook,next_run_relative
wp eval 'wp_cache_clear_cache();' # WP Super Cache (wp cache flush = object cache only)
wp db export - | gzip > before-change.sql.gz
# nightly update log
k3s kubectl -n wordpress logs job/$(k3s kubectl -n wordpress get jobs \
-o name | grep autoupdate | tail -1 | cut -d/ -f2)- Two caches, two commands.
wp cache flushempties Redis; the page cache needswp_cache_clear_cache(). After editing a post outside the editor, both. - Backups. A backup machine pulls a database dump and the
wp-contentdirectory every night; restoring is the dump into the database and the files into/srv/wordpress-www. - Rollback of an update. Images are pinned in
versions.yml: revert the pin and run the playbook. A plugin:wp plugin install <slug> --version=<old> --force. - Editing content programmatically. Write post content only through WordPress functions that slash correctly – a direct update once stripped the backslashes from code examples in a dozen posts.
🆘 Cheat sheet: symptom → command
The short version of this article for the moment something is wrong. Run on the host as root; <…> are placeholders. occ and wp are the shell functions from the Nextcloud and WordPress deep dives.
| Symptom | Command |
|---|---|
| The page shows old content | wp eval 'wp_cache_clear_cache();' # page cache |
| White page or error 500 | k3s kubectl -n wordpress logs deploy/wordpress -c wordpress-fpm --tail=50 |
| A plugin broke the site | wp plugin deactivate <slug> |
| Which versions are installed, what is pending? | wp core version |
| Scheduled tasks do not run | wp cron event list --fields=hook,next_run_relative |
| Did tonight’s update work? | k3s kubectl -n wordpress get jobs | grep autoupdate |
| Before any change to content or plugins | wp db export - | gzip > before-change.sql.gz |
| Files suddenly owned by root | # wp-cli only as www-data (the function above), never --allow-root |
🔭 What comes next
- Enforcing the CSP once the report-only phase has no surprises left.
- A full-page cache at the ingress would save the round trip into the pod; with ~46 ms it is not urgent.
- Uploads in object storage would make the pod independent of the host directory – relevant only with more than one node.
⚖️ Alternatives – and why WordPress
| Option | Strength | Why not here |
|---|---|---|
| Hugo / static site | Fastest and safest: no PHP, no database | No web editor, no comments, translation workflow by hand. |
| Ghost | Modern, fast, good editor | Node.js, far fewer plugins, multilingual only with workarounds. |
| Headless WordPress | WordPress as editor, a static or JS front end | Two systems instead of one for a single-author blog. |
| WordPress | Editor, plugins, Polylang, and the author knows it | Chosen. |
Running WordPress in Kubernetes is less about Kubernetes than about being honest where the state is. Here it is in two directories and one database, all three backed up, everything else replaceable – and the control plane is not the admin page but WP-CLI, called by Ansible, as the right user.


