feat: add internal WordPress cron runner

This commit is contained in:
bisco
2026-07-23 23:40:44 +02:00
parent f75bce3dea
commit e6ec3c94dc
15 changed files with 245 additions and 24 deletions
+1
View File
@@ -11,6 +11,7 @@ WP_TITLE=Azione!Lab
WP_ADMIN_USER=azionelab-admin
WP_ADMIN_PASSWORD=replace-with-a-local-admin-password
WP_ADMIN_EMAIL=admin@example.org
WP_CRON_INTERVAL_SECONDS=300
NGINX_BIND_ADDRESS=127.0.0.1
NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443
+11 -4
View File
@@ -2,8 +2,8 @@
A warm, editorial single-page website for the Azione!Lab contemporary theatre
workshop. WordPress manages the content, a custom theme owns the visual system, and
Docker Compose provides MariaDB, NGINX, optional Let's Encrypt, WP-CLI, and isolated
browser tests.
Docker Compose provides MariaDB, NGINX, an internal WordPress cron runner, optional
Let's Encrypt, WP-CLI, and isolated browser tests.
## Architecture
@@ -12,6 +12,7 @@ browser tests.
- `db`: MariaDB 11.8 LTS, available only on the internal data network.
- `proxy`: the only published service; routes `azionelab.org` to WordPress and applies
edge security controls.
- `wp-cron`: internal WP-CLI runner for due scheduled WordPress events.
- `certbot`: optional HTTP-01 certificate issue/renewal service.
- `wp-cli`: opt-in bootstrap and maintenance service.
- `tests/functional`: Playwright tests running only through the public virtual host.
@@ -61,6 +62,7 @@ Use this checklist for the first run of a new environment.
- set strong `MARIADB_PASSWORD`, `MARIADB_ROOT_PASSWORD`, and `WP_ADMIN_PASSWORD`;
- set `WP_URL` to the public URL, for example `https://lab.younerd.org`;
- keep `WP_CRON_INTERVAL_SECONDS=300` unless scheduled jobs need a different cadence;
- set `LETSENCRYPT_DOMAIN` to the same hostname when direct Let's Encrypt is used;
- keep `LETSENCRYPT_STAGING=1` for the first certificate test;
- configure `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
@@ -127,7 +129,7 @@ Use this checklist for the first run of a new environment.
```bash
docker compose ps
docker compose logs --tail=100 proxy wordpress db certbot
docker compose logs --tail=100 proxy wordpress wp-cron db certbot
```
Open the configured `WP_URL` and `/wp-admin/`.
@@ -212,6 +214,10 @@ docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
The `docker-compose.test.yml` override intentionally uses disposable test volumes; do
not use it for production or staging bootstrap commands.
`wp-cron` runs internally through WP-CLI every `WP_CRON_INTERVAL_SECONDS` seconds. It
does not publish ports and does not join the public `web` network. Public
`/wp-cron.php` requests remain blocked intentionally.
## Maintenance commands with internet access
The default `wp-cli` service joins only the internal database network, so it cannot
@@ -232,8 +238,9 @@ Compose file.
## Useful commands
```bash
docker compose logs -f proxy wordpress db certbot
docker compose logs -f proxy wordpress wp-cron db certbot
docker compose --profile tools run --rm wp-cli -c 'wp plugin list'
docker compose --profile tools run --rm wp-cli -c 'wp cron event list'
docker compose --profile tools run --rm wp-cli -c 'wp core version'
docker compose -f docker-compose.yml -f docker-compose.maintenance.yml --profile tools run --rm wp-cli -c 'wp core update --version=7.0.2 --force'
+7
View File
@@ -17,6 +17,13 @@ services:
- test_wordpress_data:/var/www/html
- ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro
wp-cron:
environment:
WP_CRON_INTERVAL_SECONDS: 300
volumes:
- test_wordpress_data:/var/www/html
- ./wp-cli/cron.sh:/scripts/cron.sh:ro
proxy:
networks:
web:
+41
View File
@@ -81,6 +81,47 @@ services:
- no-new-privileges:true
pids_limit: 300
wp-cron:
image: wordpress:cli-2.12.0-php8.3
restart: unless-stopped
init: true
user: "33:33"
working_dir: /var/www/html
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab}
WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab}
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_}
WP_CRON_INTERVAL_SECONDS: ${WP_CRON_INTERVAL_SECONDS:-300}
WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local}
WP_URL: ${WP_URL:-http://azionelab.org:8080}
HOME: /tmp
HTTP_HOST: ${LETSENCRYPT_DOMAIN:-azionelab.org}
entrypoint: ["/bin/sh"]
command: ["/scripts/cron.sh"]
volumes:
- type: bind
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
target: /var/www/html
bind:
create_host_path: true
- ./wp-cli/cron.sh:/scripts/cron.sh:ro
networks:
- data
depends_on:
db:
condition: service_healthy
wordpress:
condition: service_healthy
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 100
proxy:
build:
context: ./nginx
+11 -6
View File
@@ -17,10 +17,12 @@ classic theme for exact semantic markup and responsive design. Store homepage fi
sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom
post types so structured content is not lost when changing themes.
NGINX is the only public entry point. WP-CLI provides an idempotent opt-in bootstrap;
Certbot provides opt-in direct TLS. Docker networks isolate the database. Runtime state
uses host-based bind mounts with a preparation script for ownership/mode, while
functional tests use separate Docker volumes.
NGINX is the only public entry point. Public `wp-cron.php` requests stay blocked, while
an internal WP-CLI based `wp-cron` service runs due scheduled events over the private
data network. WP-CLI provides an idempotent opt-in bootstrap; Certbot provides opt-in
direct TLS. Docker networks isolate the database. Runtime state uses host-based bind
mounts with a preparation script for ownership/mode, while functional tests use
separate Docker volumes.
## Consequences
@@ -30,6 +32,8 @@ functional tests use separate Docker volumes.
- Core/theme/plugin patches require an image rebuild in production.
- Fixed participation cards remain code until editorial requirements justify another
structured content type.
- Scheduled WordPress jobs depend on the internal `wp-cron` service instead of visitor
traffic.
## Alternatives considered
@@ -43,8 +47,9 @@ functional tests use separate Docker volumes.
The architecture inherits WordPress's public CMS attack surface. The implementation
reduces it with network isolation, no direct application port, disabled file editing
and XML-RPC, production immutability, sanitization/escaping, rate limiting, security
headers, and fail-closed TLS. Admin MFA and allowlisting remain external controls.
and XML-RPC, production immutability, sanitized/escaped rendering, no bundled demo
plugins, internal-only scheduled jobs, rate limiting, security headers, and fail-closed
TLS. Admin MFA and allowlisting remain external controls.
## Operational impact
+7 -4
View File
@@ -12,10 +12,13 @@ structured editorial content survives a theme change. These custom post types ar
editorial data sources for the homepage, not standalone public routes or REST
collections. Images use WordPress featured images with local SVG fallbacks.
WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress,
activates the theme, configures the site, and creates realistic demo content. Certbot is
another optional service, enabled only for direct deployments. It shares challenge and
certificate volumes with NGINX but has no container-control access.
`wp-cron` is an internal WP-CLI runner on the `data` network. It executes due scheduled
events with `wp cron event run --due-now` and keeps public `wp-cron.php` requests
blocked. WP-CLI is also available as an opt-in tools-profile service. Its idempotent
bootstrap installs WordPress, activates the theme, configures the site, and creates
realistic demo content. Certbot is another optional service, enabled only for direct
deployments. It shares challenge and certificate volumes with NGINX but has no
container-control access.
Apache includes a small defense-in-depth hardening file that denies uploaded PHP files,
direct `wp-config.php` requests, and direct access to selected internal WordPress PHP
+5
View File
@@ -11,6 +11,8 @@ docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
bootstrap is safe to rerun and does not duplicate demo records.
The internal `wp-cron` service runs scheduled WordPress events after the bootstrap
completes.
The default persistent paths are host-based bind mounts under `./runtime`. Before the
first start, run:
@@ -37,6 +39,9 @@ Required controls:
- either direct Let's Encrypt termination or a trusted external load balancer;
- off-host database/file backups and monitoring.
The default `wp-cron` service handles scheduled WordPress events internally and should
remain enabled unless another controlled cron runner replaces it.
When a load balancer terminates TLS, Certbot stays disabled. `TRUST_PROXY_HEADERS=1`
is safe only when firewall/network policy makes the load balancer the sole NGINX
caller and it overwrites `X-Forwarded-*` headers.
+9 -7
View File
@@ -5,9 +5,10 @@
```bash
docker compose up --build -d
docker compose ps
docker compose logs -f proxy wordpress db certbot
docker compose logs -f proxy wordpress wp-cron db certbot
./scripts/prepare-host-volumes.sh
docker compose --profile tools run --rm wp-cli -c 'wp core version'
docker compose --profile tools run --rm wp-cli -c 'wp cron event list'
docker compose down
```
@@ -37,10 +38,11 @@ docker compose restart wordpress proxy
Do not use the maintenance override for routine bootstrap or inspection commands that
do not need outbound network access.
External `wp-cron.php` requests are blocked to reduce public attack surface. If future
content scheduling, maintenance jobs, or plugin features require WordPress cron, run it
from a controlled host/container cron against the private WordPress service instead of
leaving the public trigger enabled.
External `wp-cron.php` requests are blocked to reduce public attack surface. The
default `wp-cron` service runs due scheduled events internally through WP-CLI every
`WP_CRON_INTERVAL_SECONDS` seconds, defaults to 300, and joins only the internal `data`
network. If a scheduled job is late, inspect `docker compose logs wp-cron wordpress db`
and run `docker compose --profile tools run --rm wp-cli -c 'wp cron event list'`.
## Backup and restore
@@ -61,8 +63,8 @@ the database and file volume together, restart, and verify the homepage, media,
owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image
user IDs.
- SMTP is not configured; WordPress password-reset email needs an external mail service.
- WordPress cron is not publicly triggerable; scheduled jobs need an operator-managed
cron runner if that feature becomes necessary.
- WordPress cron is not publicly triggerable; scheduled jobs depend on the internal
`wp-cron` runner being healthy.
- Admin MFA and network allowlisting are deployment concerns and are not bundled.
- WordPress plugins expand the attack surface; install only reviewed, maintained,
necessary plugins.
+33
View File
@@ -50,6 +50,39 @@ docker compose restart wordpress proxy
If DNS still fails with the maintenance override, inspect the host/container DNS
configuration and firewall rules before retrying the update.
## Scheduled WordPress events are late
The public `/wp-cron.php` trigger is intentionally blocked. Scheduled jobs are executed
by the internal `wp-cron` service.
1. Confirm the runner is present:
```bash
docker compose ps wp-cron
```
2. Inspect due events:
```bash
docker compose --profile tools run --rm wp-cli -c 'wp cron event list'
```
3. Inspect runner logs without printing `.env` values:
```bash
docker compose logs --tail=100 wp-cron wordpress db
```
4. If needed, run due events manually:
```bash
docker compose --profile tools run --rm wp-cli -c 'wp cron event run --due-now'
```
The Site Health loopback test may still report a blocked public loopback while the edge
continues to deny `/wp-cron.php`; that is an expected trade-off of the hardened public
configuration.
## Uploaded image does not appear on the public page
1. Confirm the image was selected in **Appearance > Customize**, not only uploaded in
+5 -1
View File
@@ -11,6 +11,8 @@
collections.
- File editing is always disabled. Production also disables web-based core, theme, and
plugin changes; patched images are rebuilt and redeployed instead.
- The WordPress image removes the bundled Akismet and Hello Dolly plugins, and the
entrypoint also removes them from existing persistent volumes on startup.
- XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles,
direct `wp-config.php` requests, the WordPress readme/license files, direct
installation entry points, public mu-plugin directory probing, and external
@@ -28,7 +30,9 @@
WordPress/Apache retains the capabilities needed by the official image internally,
but no WordPress port is published. A containerized security test fails if the
WordPress service is configured with host-published ports.
- WP-CLI normally joins only the internal data network. The maintenance override
- `wp-cron` runs WordPress scheduled events through WP-CLI on the internal data network
only; public `wp-cron.php` remains blocked. WP-CLI normally joins only the internal
data network. The maintenance override
attaches WP-CLI to the web network for operator-triggered commands that require
outbound internet access, such as WordPress core downloads; do not use it for routine
bootstrap or inspection commands.
+2 -1
View File
@@ -23,7 +23,8 @@ cover content and section order, contact actions, mobile overflow/navigation, se
landmarks, image alternatives, admin routing, security headers, blocked sensitive
routes, blocked uploaded PHP requests, and unknown virtual hosts. Security checks also
assert that WordPress does not publish host ports and that Apache hardening remains
installed in the WordPress image.
installed in the WordPress image. They also assert that the default demo plugins are
removed and that the internal `wp-cron` service has no public network exposure.
Subjective visual review and a real-device accessibility audit remain manual release
checks.
+74
View File
@@ -27,6 +27,26 @@ grep -q '^FROM wordpress:7\.0\.2-php8\.3-apache$' /workspace/wordpress/Dockerfil
exit 1
}
grep -q 'wp-content/plugins/akismet' /workspace/wordpress/Dockerfile || {
echo "The WordPress image must remove the bundled Akismet plugin." >&2
exit 1
}
grep -q 'wp-content/plugins/hello.php' /workspace/wordpress/Dockerfile || {
echo "The WordPress image must remove the bundled Hello Dolly plugin." >&2
exit 1
}
grep -q 'wp-content/plugins/akismet' /workspace/wordpress/entrypoint-wrapper.sh || {
echo "The WordPress entrypoint must remove Akismet from existing persistent volumes." >&2
exit 1
}
grep -q 'wp-content/plugins/hello.php' /workspace/wordpress/entrypoint-wrapper.sh || {
echo "The WordPress entrypoint must remove Hello Dolly from existing persistent volumes." >&2
exit 1
}
grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || {
echo "Apache hardening must block PHP execution below uploads/files." >&2
exit 1
@@ -76,3 +96,57 @@ grep -q "^[[:space:]]*-[[:space:]]*web$" /workspace/docker-compose.maintenance.y
echo "The maintenance wp-cli service must explicitly opt into the web network." >&2
exit 1
}
grep -Eq "^[[:space:]]{2}wp-cron:" "$compose_file" || {
echo "Compose must define an internal wp-cron service." >&2
exit 1
}
grep -q "wp cron event run --due-now" /workspace/wp-cli/cron.sh || {
echo "The wp-cron runner must execute due WordPress cron events via WP-CLI." >&2
exit 1
}
awk '
/^ wp-cron:/ {
in_wp_cron = 1
has_data_network = 0
has_db_host = 0
next
}
in_wp_cron && /^ [A-Za-z0-9_-]+:/ {
if (!has_data_network) {
print "The wp-cron service must join the internal data network." > "/dev/stderr"
exit 1
}
if (!has_db_host) {
print "The wp-cron service must receive WordPress database environment variables." > "/dev/stderr"
exit 1
}
in_wp_cron = 0
}
in_wp_cron && /^ ports:/ {
print "The wp-cron service must not publish host ports." > "/dev/stderr"
exit 1
}
in_wp_cron && /^[[:space:]]+WORDPRESS_DB_HOST:/ {
has_db_host = 1
}
in_wp_cron && /^ - data$/ {
has_data_network = 1
}
in_wp_cron && /^ - web$/ {
print "The wp-cron service must not join the public web network." > "/dev/stderr"
exit 1
}
END {
if (in_wp_cron && !has_data_network) {
print "The wp-cron service must join the internal data network." > "/dev/stderr"
exit 1
}
if (in_wp_cron && !has_db_host) {
print "The wp-cron service must receive WordPress database environment variables." > "/dev/stderr"
exit 1
}
}
' "$compose_file"
+1
View File
@@ -9,6 +9,7 @@ COPY theme/azionelab /opt/azionelab/theme
COPY mu-plugins/azionelab-content.php /opt/azionelab/azionelab-content.php
RUN sed -ri 's!^[[:space:]]*CustomLog .*!CustomLog /dev/null combined!' /etc/apache2/sites-available/000-default.conf \
&& rm -rf /usr/src/wordpress/wp-content/plugins/akismet /usr/src/wordpress/wp-content/plugins/hello.php \
&& a2enconf azionelab-apache-hardening \
&& chmod 755 /usr/local/bin/azionelab-entrypoint \
&& chmod 644 /usr/local/bin/azionelab-healthcheck.php \
+2 -1
View File
@@ -24,7 +24,8 @@ case "$environment" in
esac
if [ "${1:-}" = "apache2-foreground" ]; then
mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins
mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins /var/www/html/wp-content/plugins
rm -rf /var/www/html/wp-content/plugins/akismet /var/www/html/wp-content/plugins/hello.php
rm -rf /var/www/html/wp-content/themes/azionelab
cp -a /opt/azionelab/theme /var/www/html/wp-content/themes/azionelab
cp /opt/azionelab/azionelab-content.php /var/www/html/wp-content/mu-plugins/azionelab-content.php
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
cd /var/www/html
trap 'exit 0' INT TERM
interval="${WP_CRON_INTERVAL_SECONDS:-300}"
case "$interval" in
"" | *[!0-9]*)
echo "WP_CRON_INTERVAL_SECONDS must be a positive integer." >&2
exit 1
;;
esac
if [ "$interval" -lt 60 ]; then
echo "WP_CRON_INTERVAL_SECONDS must be at least 60 seconds." >&2
exit 1
fi
while [ ! -f /var/www/html/wp-load.php ]; do
sleep 10
done
until wp core is-installed >/dev/null 2>&1; do
sleep 10
done
while :; do
if ! wp cron event run --due-now >/dev/null 2>&1; then
echo "Warning: failed to run due WordPress cron events." >&2
fi
sleep "$interval" &
wait "$!"
done