WordPress on FrankenPHP & Caddy, hardened for production. PHP 8.5, Debian Trixie.
10K+
A WordPress image built for simplicity and scale, powered by FrankenPHP and Caddy.
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
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)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)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.
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 versionbock/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 overwroteThe 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.
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.
mariadb, mariadb-dump and mariadb-import for wp db and database imports and exports; see Database Import and ExportThere 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.
Performance & Caching:
WordPress Essentials:
VIPS Images Only:
WeasyPrint Images Only:
/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.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 CATIMEOUT_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 threadsREQUEST_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 PHPCADDY_GLOBAL_OPTIONS: inserted into Caddy's global options block, e.g. email [email protected] for the Let's Encrypt account or debugCADDY_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 ConfigurationFRANKENPHP_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 LimitsTRUSTED_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-ProtoFIX_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 messageUMASK: file creation mask, e.g. 0002 for group-writable files. Unset keeps 0022. See Sharing wp-content with another userHSTS: 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 HTTPSCRON: 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 TasksSUPERCACHE: set to 1 to serve WP Super Cache's cached pages straight from disk, without PHP. See Page CacheBLOCK_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 itUPLOAD_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
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 worksDISABLE_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 TasksCORE_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 valueDISALLOW_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 againDISABLE_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 oneWORDPRESS_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 is included and points at the site by default, so it works from any directory:
docker exec <container> wp plugin list
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.
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
}
/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.
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.
| Variable | Default | Purpose |
|---|---|---|
MSMTP | on | off disables mail |
MSMTP_HOST | – | SMTP relay. Without it, mail isn't sent |
MSMTP_PORT | 587 | Relay port |
MSMTP_USER | – | Login; turns on authentication |
MSMTP_PASSWORD | – | Password for MSMTP_USER |
MSMTP_FROM | the message's From: | Envelope sender |
MSMTP_TLS | on | off for relays without TLS |
MSMTP_STARTTLS | on, off on port 465 | off for implicit TLS (port 465) |
MSMTP_AUTH | on with MSMTP_USER, else off | Or a method, e.g. login |
MSMTP_TLS_CERTCHECK | on | off accepts self-signed certificates |
MSMTP_SET_FROM_HEADER | auto | on 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.
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.
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.
GET_LOCK), so tasks don't run twice. Other sites on the same database server aren't affected.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.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.
SUPERCACHE=1 and recreate the container.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:
/, WordPress's default permalinks. Without a trailing slash, pages are still cached, but served through PHP.wpsc_add_cookie) aren't known to Caddy. If a plugin relies on that, leave SUPERCACHE off.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
Content type
Image
Digest
sha256:1b6363d62…
Size
195.1 MB
Last updated
21 minutes ago
docker pull bock/frankenpress