WordPress on OxPHP

WordPress is a traditional-mode application. It has many physical entry points (index.php, wp-login.php, wp-cron.php, and the whole wp-admin/ directory), each meant to be reached as a real file. OxPHP's traditional routing mode, the default when ENTRY_FILE is unset, serves them exactly that way. Do not set ENTRY_FILE: framework mode would funnel every request to one front controller and break wp-admin.

This recipe also shows the second build shape. Instead of copying OxPHP into a PHP base, it extends the OxPHP runtime image directly, compiling WordPress's extensions in a builder stage and dropping the .so files in.

Stack at a glance

  • OxPHP image: ghcr.io/oxphp/oxphp:0.13.0 (PHP 8.5), extended in place
  • Routing mode: Traditional (no ENTRY_FILE)
  • Extensions added: mysqli, pdo_mysql, gd, zip, intl, exif, bcmath
  • Services: OxPHP + MySQL + a WP-CLI sidecar (cli profile)
  • Hardening: PHP_DENY_PATHS blocks direct .php execution under wp-content/uploads/
  • URL: http://localhost:8090 · internal http://localhost:9091/health

Project layout

text
wp-oxphp/ ├── Dockerfile ├── docker-compose.yml └── wordpress/ # the WordPress tree (download from wordpress.org) └── wp-config.php # reads WORDPRESS_* environment variables

wordpress/wp-config.php reads its settings from the container environment:

wp-config.php
define( 'DB_NAME', getenv( 'WORDPRESS_DB_NAME' ) ?: 'wordpress' ); define( 'DB_USER', getenv( 'WORDPRESS_DB_USER' ) ?: 'wordpress' ); define( 'DB_PASSWORD', getenv( 'WORDPRESS_DB_PASSWORD' ) ?: 'wordpress' ); define( 'DB_HOST', getenv( 'WORDPRESS_DB_HOST' ) ?: 'db:3306' ); $__site_url = getenv( 'WORDPRESS_SITE_URL' ) ?: 'http://localhost:8090'; define( 'WP_HOME', $__site_url ); define( 'WP_SITEURL', $__site_url );

Dockerfile and Compose

This Dockerfile compiles the extensions against a matching php:8.5-zts-alpine (same ABI as the OxPHP image, no-debug-zts-20250925) and copies the .so files into the OxPHP runtime:

Dockerfile
# ── Stage 1: compile WordPress PHP extensions against PHP 8.5 ───── FROM php:8.5-zts-alpine3.23 AS ext-builder RUN apk add --no-cache \ icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev \ && docker-php-ext-configure gd --with-jpeg --with-freetype \ && docker-php-ext-install -j"$(nproc)" \ mysqli pdo_mysql gd zip intl exif bcmath RUN EXT_DIR=$(php -r 'echo ini_get("extension_dir");') && mkdir -p /ext-out \ && cp "$EXT_DIR"/mysqli.so "$EXT_DIR"/pdo_mysql.so "$EXT_DIR"/gd.so \ "$EXT_DIR"/zip.so "$EXT_DIR"/intl.so "$EXT_DIR"/exif.so \ "$EXT_DIR"/bcmath.so /ext-out/ # ── Stage 2: OxPHP runtime with WordPress extensions ───────────── FROM ghcr.io/oxphp/oxphp:0.13.0 AS runtime USER root RUN apk add --no-cache icu-libs libzip libpng libjpeg-turbo freetype oniguruma # Drop the compiled extensions into OxPHP's PHP 8.5 extension dir COPY --from=ext-builder /ext-out/*.so \ /usr/local/lib/php/extensions/no-debug-zts-20250925/ RUN { \ echo "extension=mysqli.so"; echo "extension=pdo_mysql.so"; \ echo "extension=gd.so"; echo "extension=zip.so"; \ echo "extension=intl.so"; echo "extension=exif.so"; \ echo "extension=bcmath.so"; \ } > /usr/local/etc/php/conf.d/wordpress-extensions.ini RUN { \ echo "upload_max_filesize=64M"; echo "post_max_size=64M"; \ echo "memory_limit=256M"; echo "max_execution_time=300"; \ echo "max_input_vars=3000"; \ } > /usr/local/etc/php/conf.d/wordpress.ini EXPOSE 80
Note

The extension directory (no-debug-zts-20250925) is the PHP 8.5 ZTS ABI tag. If you build on the PHP 8.4 OxPHP image, this becomes no-debug-zts-20240924. Derive it at build time with php -r 'echo ini_get("extension_dir");' rather than hard-coding it.

Install and first run

bash
docker compose up -d --build docker compose run --rm wpcli wp core install \ --url=http://localhost:8090 --title="OxPHP WordPress" \ --admin_user=admin --admin_password=admin --admin_email=admin@example.com

OxPHP notes

Note
  • Traditional mode is mandatory. WordPress needs wp-admin/, wp-login.php, wp-cron.php, etc. to execute as physical files. Leave ENTRY_FILE unset.
  • The OxPHP runtime image is a minimal serving image and ships no wp binary. CLI work goes through the wpcli sidecar under the cli Compose profile, which uses the same wordpress/ volume and database.
  • wp-config.php reads the container environment via getenv(), so database credentials and the site URL live in Compose instead of being hard-coded in the file.
  • PHP_DENY_PATHS hardens the upload directories. Because traditional mode executes physical .php files, a shell uploaded into wp-content/uploads/ by a vulnerable plugin would otherwise run. PHP_DENY_PATHS blocks .php execution under those paths, matched against the URI before any disk I/O, so there is no existence oracle. It applies only in traditional and SPA modes; in framework mode it is a no-op (the front controller already prevents direct .php execution), which is why the framework-mode recipes here do not need it. See PHP Execution Deny-List.

Verify

bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8090/ # 200 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8090/wp-login.php # 200 (physical file) curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8090/wp-content/uploads/x.php # 404 (PHP_DENY_PATHS) curl -s -o /dev/null -w "%{http_code}\n" http://localhost:9091/health # 200

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Run my WordPress site on OxPHP locally. Read the `examples/cms/wordpress` page through the OxPHP documentation MCP server. Write the Dockerfile and the Compose file for this tree — traditional mode, a WP-CLI sidecar, and the extensions my active plugins and theme actually need — and wire `wp-config.php` to read its credentials from the container environment.
prompt
Parts of my WordPress site 404 since moving to OxPHP — an admin page, or a plugin endpoint. Read the `examples/cms/wordpress` page through the OxPHP documentation MCP server. Check my `PHP_DENY_PATHS` globs and whether `ENTRY_FILE` is set against the `.php` endpoints my installed plugins expose, and tell me which one is swallowing the request.

See also

Found a mistake? Report it →