Sign inSign up

bock/frankenpress

By bock

•Updated 21 minutes ago

WordPress on FrankenPHP & Caddy, hardened for production. PHP 8.5, Debian Trixie.

Image
1

10K+

bock/frankenpress repository overview

⁠FrankenPress Docker Image

A WordPress image built for simplicity and scale, powered by FrankenPHP and Caddy.

⁠Quick Start

docker run -d \
  -p 80:80 \
  -e WORDPRESS_DB_HOST=your-db-host \
  -e WORDPRESS_DB_USER=wordpress \
  -e WORDPRESS_DB_PASSWORD=your-password \
  -e WORDPRESS_DB_NAME=wordpress \
  bock/frankenpress:latest

For a complete setup with MariaDB, a Redis object cache and an optional phpMyAdmin, see examples/compose⁠:

cd examples/compose
cp .env.example .env   # set SERVER_NAME and the passwords
docker compose up -d

⁠Available Images

⁠Standard Images
  • bock/frankenpress:latest / trixie - Latest (PHP 8.5) on Debian Trixie (amd64, arm64)
  • bock/frankenpress:php-8.5-trixie - PHP 8.5 on Debian Trixie (amd64, arm64)
⁠VIPS Images (with FFI support for advanced image processing)
  • bock/frankenpress:vips-ffi / vips-ffi-trixie - Latest VIPS (PHP 8.5) on Debian Trixie (amd64, arm64)
  • bock/frankenpress:php-8.5-vips-ffi-trixie - PHP 8.5 VIPS on Debian Trixie (amd64, arm64)
⁠WeasyPrint Images (HTML to PDF, e.g. for CiviCRM)
  • bock/frankenpress:weasyprint / weasyprint-trixie - Latest WeasyPrint (PHP 8.5) on Debian Trixie (amd64, arm64)
  • bock/frankenpress:php-8.5-weasyprint-trixie - PHP 8.5 WeasyPrint on Debian Trixie (amd64, arm64)

All images are built on Debian 13 (Trixie) with PHP 8.5. All variants come from the same Dockerfile: the VIPS image is its vips-ffi target (docker build --target vips-ffi .), the WeasyPrint image its weasyprint target. The variants can't be combined; there is no image with both VIPS and WeasyPrint.

⁠Pinning a Version

The tags above follow the weekly rebuilds. To pin a deployment, or roll back after an update, use the version-specific tags published with every build:

  • bock/frankenpress:php-8.5-trixie-wp7.1.2: a specific WordPress version, still updated by the weekly rebuilds of that version
  • bock/frankenpress:php-8.5-trixie-wp7.1.2-20261004-0112: one specific build (WordPress version plus build date and UTC time). Builds before 2026-10-03 have only the date, e.g. -20261002, which the last build of that day overwrote

The same pattern applies to the VIPS and WeasyPrint images, e.g. php-8.5-vips-ffi-trixie-wp7.1.2-20261004-0112. See the tag list on Docker Hub⁠ for available builds.

⁠Performance & Build Optimization

All arm64 builds run on native GitHub-hosted ARM runners (ubuntu-24.04-arm) for maximum performance and speed. This eliminates QEMU emulation overhead, resulting in significantly faster build times.

The PHP extensions are compiled in a separate build stage on the official FrankenPHP image⁠. The published image is Debian slim plus PHP, FrankenPHP and only the libraries they link against, with no compiler toolchain. That puts the standard image at about 615 MB, the VIPS image at about 680 MB and the WeasyPrint image at about 780 MB.

The base images (wordpress, dunglas/frankenphp, debian) are pinned by digest in the Dockerfile, so every build uses exactly the images recorded in git. Dependabot opens a pull request when one of them changes, and CI runs the full integration test on it before it's merged. Debian package updates in the final image still arrive with every weekly rebuild. Every build, scheduled ones included, runs the same smoke and integration tests on both architectures, and is only pushed if they pass.

⁠What's Included

⁠Core Components

There is no built-in page cache. Use a caching plugin, and Redis for the object cache, as in the compose example⁠. With WP Super Cache, Caddy can serve the cached pages without PHP; see Page Cache⁠. Variables such as CACHE_LOC, TTL, PURGE_*, BYPASS_* or CACHE_RESPONSE_CODES from older FrankenWP examples have no effect.

⁠PHP Extensions & Caching

Performance & Caching:

  • OPcache (built into PHP 8.5, configured for production)
  • APCu
  • Memcached
  • Redis
  • igbinary
  • msgpack

WordPress Essentials:

  • bcmath
  • exif
  • gd
  • intl
  • mysqli
  • pdo_mysql
  • zip
  • imagick

VIPS Images Only:

  • FFI (Foreign Function Interface)
  • libvips (high-performance image processing)

WeasyPrint Images Only:

  • WeasyPrint⁠ at /usr/local/bin/weasyprint, from PyPI (pinned with hashes in weasyprint/requirements.txt), with Pango and the Noto, DejaVu and FreeFont fonts. For CiviCRM, set its WeasyPrint path setting to /usr/local/bin/weasyprint.
⁠Environment Variables
⁠FrankenPHP
  • SERVER_NAME: the addresses to listen on. Defaults to :80: plain HTTP for any hostname, which is what a container behind a load balancer or reverse proxy needs. For HTTPS, set your hostname(s), e.g. example.com or example.com, :80; real hostnames get a publicly trusted certificate (Let's Encrypt/ZeroSSL) automatically, localhost and IP addresses use Caddy's local CA
  • TIMEOUT_READ_HEADER, TIMEOUT_READ_BODY, TIMEOUT_WRITE, TIMEOUT_IDLE: how long a client may take to send its headers (default 10s) and body (10m), how long PHP plus sending the response may take (10m), and how long idle keep-alive connections stay open (5m). Bounds slow or stalled clients, which would otherwise hold PHP threads
  • REQUEST_BODY_MAX_BYTES: requests announcing a larger body get 413 before PHP starts on them. Follows PHP's post_max_size (512 MiB by default), so it only needs setting to diverge from it. See PHP⁠
  • CADDY_GLOBAL_OPTIONS: inserted into Caddy's global options block, e.g. email [email protected] for the Let's Encrypt account or debug
  • CADDY_SERVER_EXTRA_DIRECTIVES: inserted into the site block, before WordPress handles the request, e.g. header /wp-content/uploads/* Cache-Control "public, max-age=31536000, immutable"
  • CADDY_EXTRA_CONFIG: inserted at the top level of the Caddyfile, e.g. an extra site block. See Custom Caddy Configuration⁠
  • FRANKENPHP_CONFIG: inject config under the frankenphp directive, e.g. num_threads and max_threads. Without num_threads, the image starts two PHP threads per available CPU, at most 4. See Resource Limits⁠
  • TRUSTED_PROXIES: proxies whose X-Forwarded-For header is trusted for the client IP, as space-separated CIDRs. Defaults to private_ranges (10/8, 172.16/12, 192.168/16, 127/8 and their IPv6 equivalents). Set it to your load balancer's range so other hosts on a private network can't spoof their IP. Trusted proxies can also mark a request as HTTPS via X-Forwarded-Proto or CloudFront-Forwarded-Proto
  • FIX_OWNERSHIP: set to 1 and start the container as root (--user root, or user: root in compose) to repair ownership of mounted folders at startup. Useful when bind mounts or platforms like AWS ECS hand the container root-owned folders, so uploads or certificates can't be written. Files not owned by the web user in /var/www/html, /data/caddy and /config/caddy are chowned (symlinks themselves, never their targets), then the server drops to www-data. Without FIX_OWNERSHIP, the image runs as www-data as before. Images from 2026-10-02 called it FIX_PERMISSIONS; that name now stops the container with a message
  • UMASK: file creation mask, e.g. 0002 for group-writable files. Unset keeps 0022. See Sharing wp-content with another user⁠
  • HSTS: set to a Strict-Transport-Security value, e.g. max-age=31536000, to send HSTS on HTTPS requests (direct or forwarded by a trusted proxy). Off by default; add ; includeSubDomains only if every subdomain serves HTTPS
  • CRON: on by default: the container runs WordPress's scheduled tasks itself, every CRON_INTERVAL seconds (default 60), instead of on page loads. Set to 0 to turn it off. See Scheduled Tasks⁠
  • SUPERCACHE: set to 1 to serve WP Super Cache's cached pages straight from disk, without PHP. See Page Cache⁠
  • BLOCK_XMLRPC: set to 1 to refuse xmlrpc.php (403), a common password-guessing target. Off by default because Jetpack and the WordPress mobile apps still use it
⁠PHP
  • UPLOAD_MAX_SIZE: the largest upload, e.g. 1G or 64M. Sets upload_max_filesize and post_max_size, and Caddy's request body limit follows. Defaults to 512M. Very large uploads over slow connections may also need a longer TIMEOUT_READ_BODY (default 10m)

  • PHP_INI: any php.ini settings, one per line, applied after the image's own, e.g. for OPcache or memory_limit. They apply to the web server and to wp commands. A misspelled setting name, a syntax error or an invalid value stops the container with a message instead of being ignored:

    services:
      wordpress:
        environment:
          UPLOAD_MAX_SIZE: 1G
          PHP_INI: |
            memory_limit = 512M
            opcache.memory_consumption = 512
    

    PHP_INI is written to a file in the container at startup, so it needs a writable root filesystem. Mounting a single .ini file into /usr/local/etc/php/conf.d also works (not a whole folder: that would hide the extensions' .ini files); Caddy's request body limit follows a post_max_size set there, too

⁠WordPress

The official image's entrypoint creates wp-config.php from these variables and supports more: the keys and salts (WORDPRESS_AUTH_KEY, …), WORDPRESS_DB_CHARSET, and _FILE variants for secrets. See the official image⁠ for the full list.

  • WORDPRESS_DB_NAME: The WordPress database name.
  • WORDPRESS_DB_USER: The WordPress database user.
  • WORDPRESS_DB_PASSWORD: The WordPress database password.
  • WORDPRESS_DB_HOST: The WordPress database host.
  • WORDPRESS_TABLE_PREFIX: The WordPress database table prefix.
  • WORDPRESS_DEBUG: Turns on WordPress Debug.
  • FORCE_HTTPS: Set to 1 to tell WordPress every request is HTTPS. Usually not needed behind a load balancer that terminates TLS, since requests a trusted proxy forwards as HTTPS are detected automatically (see TRUSTED_PROXIES). Defaults to 0.
  • DISALLOW_FILE_EDIT: on by default, so wp-admin has no theme and plugin code editors, and a stolen admin login can't be turned into running PHP through them. Set to 0 to bring the editors back. A site that defines the constant itself, e.g. in WORDPRESS_CONFIG_EXTRA, keeps its own value. Earlier images had it off unless set to 1, which still works
  • DISABLE_WP_CRON: stops WordPress from starting scheduled tasks on page loads. Follows CRON: on while the image's cron runner runs, off with CRON=0. Set it explicitly to override, e.g. 1 with CRON=0 and your own scheduler. A site that defines the constant itself keeps its own value. See Scheduled Tasks⁠
  • CORE_UPGRADE_SKIP_NEW_BUNDLED: on by default, so core updates don't install new default themes and plugins into wp-content, and a fresh core volume doesn't copy them into a wp-content that already has plugins or themes (see Upgrading and Migrating⁠). Set to 0 to turn it off. A site that defines the constant itself, e.g. in WORDPRESS_CONFIG_EXTRA, keeps its own value
  • DISALLOW_PLUGIN_THEME_INSTALL: on by default, so nobody can install or upload plugins and themes through wp-admin or the REST API, and a stolen admin session can't upload a plugin carrying a web shell. Updates (automatic and from wp-admin), activation and deletion still work. Install new plugins and themes with WP-CLI (wp plugin install), or set to 0 to allow it in wp-admin again
  • DISABLE_APPLICATION_PASSWORDS: on by default, so WordPress offers no application passwords, and a stolen admin session can't create a REST API password that outlives it. Existing application passwords stop working too. Set to 0 if an integration logs in with one
  • WORDPRESS_CONFIG_EXTRA: PHP added to wp-config.php when it's created, e.g. define('WP_HOME', 'https://example.com');

FORCE_HTTPS, DISALLOW_FILE_EDIT, DISABLE_WP_CRON, CORE_UPGRADE_SKIP_NEW_BUNDLED, DISALLOW_PLUGIN_THEME_INSTALL and DISABLE_APPLICATION_PASSWORDS are applied on every request (via auto_prepend_file), so they also work for existing sites, whose wp-config.php was written when the site was created.

⁠WP-CLI

WP-CLI⁠ is included and points at the site by default, so it works from any directory:

docker exec <container> wp plugin list
⁠Database Import and Export

The MariaDB client is included (mariadb, mariadb-dump, mariadb-import, also as mysql and mysqldump), so wp db works and uses the site's database settings:

docker compose exec wordpress wp db export - > backup.sql      # dump to the host
docker compose exec -T wordpress wp db import - < backup.sql   # restore from the host
docker compose exec wordpress wp db cli                        # interactive SQL shell
docker compose exec wordpress wp db query 'SHOW TABLES'

-T keeps compose from allocating a terminal, so the dump goes through stdin unchanged; with docker exec, use -i. wp search-replace changes URLs in an imported database, e.g. after moving a site to a new domain.

The tools connect like WordPress does: with TLS when the server offers it, but without verifying the server's certificate. MariaDB's client verifies by default since 11.4, which fails against MySQL and older MariaDB servers ("SSL is required, but the server does not support it", "self-signed certificate"). Add --ssl-verify-server-cert to verify anyway, e.g. against a managed database with a trusted certificate.

Dumps start with a line MariaDB 11 adds (/*M!999999\- enable the sandbox mode */), which MySQL and older MariaDB clients refuse. To import a dump elsewhere, drop it: sed 1d backup.sql > for-mysql.sql.

⁠Custom Caddy Configuration

CADDY_SERVER_EXTRA_DIRECTIVES and CADDY_EXTRA_CONFIG take Caddyfile syntax. A block with braces needs several lines; Caddy doesn't accept it on one. In compose, use |. For example, serving image.jpg.webp instead of image.jpg to browsers that accept WebP (the layout of WebP plugins such as WebP Express):

    environment:
      CADDY_SERVER_EXTRA_DIRECTIVES: |
        @webp {
          header Accept *image/webp*
          path *.jpg *.jpeg *.png
          file {path}.webp
        }
        rewrite @webp {path}.webp
        header @webp Vary Accept
      CADDY_EXTRA_CONFIG: |
        www.example.com {
          redir https://example.com{uri} permanent
        }
⁠Health Endpoints
  • /healthz: answered by Caddy without PHP or the database, on every site. For load balancers and uptime monitors; it only says the container serves HTTP.
  • 127.0.0.1:2080/healthz.php: internal, not reachable from outside the container. The image's Docker HEALTHCHECK uses it: healthy when Caddy answers and PHP executes, independent of SERVER_NAME and the database, so a database outage doesn't make orchestrators restart the container.

Because /healthz is answered before WordPress, no page, post or other content can use that path.

⁠Sending Email

The image ships msmtp⁠ as /usr/sbin/sendmail, which PHP's mail() and so wp_mail() use. Point it at an SMTP relay with MSMTP_* variables. They're read each time a message is sent, so they work for existing sites without touching wp-config.php.

VariableDefaultPurpose
MSMTPonoff disables mail
MSMTP_HOST–SMTP relay. Without it, mail isn't sent
MSMTP_PORT587Relay port
MSMTP_USER–Login; turns on authentication
MSMTP_PASSWORD–Password for MSMTP_USER
MSMTP_FROMthe message's From:Envelope sender
MSMTP_TLSonoff for relays without TLS
MSMTP_STARTTLSon, off on port 465off for implicit TLS (port 465)
MSMTP_AUTHon with MSMTP_USER, else offOr a method, e.g. login
MSMTP_TLS_CERTCHECKonoff accepts self-signed certificates
MSMTP_SET_FROM_HEADERautoon replaces the From: header with MSMTP_FROM, for relays that reject other senders

Every variable also has a _FILE variant, e.g. MSMTP_PASSWORD_FILE=/run/secrets/smtp_password, like the official image's WORDPRESS_*_FILE. Setting both is an error. msmtp reads the password from the environment or the file itself, so it never appears on a command line or in a file the image writes.

services:
  wordpress:
    environment:
      MSMTP_HOST: smtp.example.com
      MSMTP_USER: [email protected]
      MSMTP_PASSWORD_FILE: /run/secrets/smtp_password
      MSMTP_FROM: [email protected]
    secrets:
      - smtp_password

secrets:
  smtp_password:
    file: ./smtp_password.txt

Compose mounts a file secret with the host file's owner and permissions (it ignores uid, gid and mode for file secrets), and the container runs as www-data (uid 33). On Linux, make the file readable for it, e.g. sudo chown 33 smtp_password.txt && sudo chmod 400 smtp_password.txt.

Test it with:

docker compose exec wordpress wp eval 'var_dump(wp_mail("[email protected]", "test", "test"));'

At startup the container logs where mail goes, or mail is NOT configured. Each delivery is logged as one MSMTP line with sender, recipients and SMTP status, never the message body or password. A failed mail logs the reason, and wp_mail() returns false.

SMTP or API plugins (FluentSMTP, WP Mail SMTP, …) send on their own and take precedence over these settings when installed.

⁠Resource Limits

Set CPU, memory and PHP threads together. Each PHP thread may use up to memory_limit (256M), and PHP keeps each thread's peak allocation, so memory grows over time.

FrankenPHP itself starts two PHP threads per CPU it sees: 64 on an unlimited 32-thread host, so one site could take about 16 GB. The image therefore starts two threads per available CPU, at most 4, and logs the number at startup. Available CPUs are the container's CPU limit (rounded up) or cpuset, otherwise the host's. Without a limit, that's 4 threads on any host with two or more CPUs. With cpus: 1 it gets 2, so two slow requests block every other visitor: a CPU limit alone is the wrong knob.

To size them yourself, set num_threads (threads at startup) and max_threads (FrankenPHP adds threads under load, up to this many) in FRANKENPHP_CONFIG. A num_threads there, or any worker directive, replaces the image's default; a max_threads below the default lowers it to that number. The compose example⁠ uses values that suit typical sites with low to moderate traffic and ~50–100 MB per request:

services:
  wordpress:
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 1G
    environment:
      FRANKENPHP_CONFIG: |
        num_threads 4
        max_threads 8
  db:
    deploy:
      resources:
        limits:
          memory: 1G

Sizing: max_threads × typical memory per request should fit in the memory limit, with headroom for Caddy and OPcache. Raise both for heavy sites (image optimisation plugins, page builders, WooCommerce), e.g. cpus: 4, memory: 3G, num_threads 8, max_threads 16.

At the limit, the kernel first reclaims page cache. If that isn't enough, it kills FrankenPHP, and the container restarts (restart: unless-stopped). Requests in progress at that moment fail.

Image optimisation plugins that process images locally (e.g. EWWW Image Optimizer) use every core for a single image through ImageMagick. A CPU limit makes them slower, not broken.

⁠Scheduled Tasks (WP-Cron)

WordPress runs its scheduled tasks (automatic updates, scheduled posts, plugin jobs, cleanups) only when a page request reaches PHP: it starts wp-cron.php in the background. Cached pages never reach PHP, so on a mostly cached site these tasks wait for an uncached visit, and security updates can wait for days.

The image therefore runs them itself. While the server runs, the container runs wp cron event run --due-now every CRON_INTERVAL seconds (default 60), as the web user, and logs every task it runs:

FrankenPress cron: Executed the cron event 'wp_version_check' in 0.517s.

DISABLE_WP_CRON is set at the same time, so visitors no longer start wp-cron.php. With CRON=0, WordPress goes back to running tasks on page loads; to use your own scheduler instead, also set DISABLE_WP_CRON=1.

  • Several containers, one database: only one runs a site's tasks at a time, through a database lock (GET_LOCK), so tasks don't run twice. Other sites on the same database server aren't affected.
  • Multisite: the tasks of every active site run.
  • Before the site is installed, or while the database is unreachable, the runner waits silently.
  • wp-cron.php is refused (403) while the runner runs: WordPress no longer calls it, so any request comes from outside. It stays open with CRON=0 or an explicit DISABLE_WP_CRON=0, for page-load cron or an external scheduler that calls it over HTTP. A site that only defines DISABLE_WP_CRON as false in its own config still runs its tasks through the runner; its page-load attempts get the 403.

⁠Page Cache

WP Super Cache⁠ stores finished pages as static HTML files. On Apache, its rewrite rules serve them without starting PHP. SUPERCACHE=1 does the same in Caddy, so anonymous visitors don't take up a PHP thread at all, which matters with the few threads from Resource Limits⁠.

  1. Install and activate the plugin, then turn caching on under Settings → WP Super Cache. Keep the delivery method on "Simple": "Expert" writes Apache rules, and Caddy does that job here.
  2. Set SUPERCACHE=1 and recreate the container.
  3. Check it, logged out: curl -sI https://example.com/ | grep -i x-frankenpress-cache prints HIT once the page is cached (the first visit creates it).

Caddy uses the plugin's own conditions: GET or HEAD, no query string, no login, comment-author or password-protected-post cookie, and a cached file for that host and path (index-https.html for HTTPS, including requests a trusted proxy forwards as HTTPS; the plugin's .gz files when compression is on). Like the plugin, it sends Cache-Control: max-age=3, must-revalidate. Everything else goes to WordPress as before, which serves the plugin's cache through PHP.

Limits:

  • Only paths ending in /, WordPress's default permalinks. Without a trailing slash, pages are still cached, but served through PHP.
  • Don't turn on the plugin's mobile device support: Caddy doesn't check user agents, so phones would get the desktop pages. Responsive themes don't need it.
  • Cookies that other plugins register with WP Super Cache to skip the cache (wpsc_add_cookie) aren't known to Caddy. If a plugin relies on that, leave SUPERCACHE off.

⁠Sharing wp-content with Another User

Some sites share wp-content with another user, e.g. an SFTP account that edits themes and plugins. Both sides then work through a common group, and WordPress has to create files group-writable (664 files, 775 folders), or the other user can't change or delete what WordPress wrote.

Put the other user in the web user's group (www-data, gid 33 inside the container), and make the folders group-owned and setgid, so new files keep that group. On the host (or as root in the container):

chgrp -R 33 wp-content
find wp-content -type d -exec chmod 2775 {} +
find wp-content -type f -exec chmod 664 {} +

Then set UMASK=0002. It applies to everything the server runs, and also to docker exec … wp, which otherwise starts with Docker's default umask. Plugin, theme and core updates, which WordPress writes with explicit permissions (FS_CHMOD_FILE, FS_CHMOD_DIR), follow it too: 664 files and 775 folders, keeping setgid when wp-content has it. A site that defines those constants itself keeps its own values. Uploads take their folder's

Tag summary

Content type

Image

Digest

sha256:1b6363d62…

Size

195.1 MB

Last updated

21 minutes ago

docker pull bock/frankenpress