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:latestFor 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 -dbock/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)
Every tag is also published to the GitHub Container Registry with the same digest: replace bock/frankenpress with ghcr.io/bb/frankenpress, e.g. ghcr.io/bb/frankenpress:latest.
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 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.
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.
- WordPress - Latest version from official WordPress Docker images
- FrankenPHP - Modern PHP application server (official dunglas/frankenphp images)
- Caddy - Fast, secure web server with automatic HTTPS
- MariaDB client -
mariadb,mariadb-dumpandmariadb-importforwp dband database imports and exports; see Database Import and Export - PHP Extensions - Optimized selection for WordPress performance
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.
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 inweasyprint/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.comorexample.com, :80; real hostnames get a publicly trusted certificate (Let's Encrypt/ZeroSSL) automatically,localhostand 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 (default10s) 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'spost_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 admin@example.comfor the Let's Encrypt account ordebugCADDY_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_threadsandmax_threads. Withoutnum_threads, the image starts two PHP threads per available CPU, at most 4. See Resource LimitsTRUSTED_PROXIES: proxies whoseX-Forwarded-Forheader is trusted for the client IP, as space-separated CIDRs. Defaults toprivate_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 viaX-Forwarded-ProtoorCloudFront-Forwarded-ProtoFIX_OWNERSHIP: set to1and start the container as root (--user root, oruser: rootin 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/caddyand/config/caddyare chowned (symlinks themselves, never their targets), then the server drops towww-data. WithoutFIX_OWNERSHIP, the image runs aswww-dataas before. Images from 2026-10-02 called itFIX_PERMISSIONS; that name now stops the container with a messageUMASK: file creation mask, e.g.0002for group-writable files. Unset keeps0022. See Sharing wp-content with another userHSTS: set to aStrict-Transport-Securityvalue, e.g.max-age=31536000, to send HSTS on HTTPS requests (direct or forwarded by a trusted proxy). Off by default; add; includeSubDomainsonly if every subdomain serves HTTPSCRON: on by default: the container runs WordPress's scheduled tasks itself, everyCRON_INTERVALseconds (default60), instead of on page loads. Set to0to turn it off. See Scheduled TasksSUPERCACHE: set to1to serve WP Super Cache's cached pages straight from disk, without PHP. See Page CacheBLOCK_XMLRPC: set to1to refusexmlrpc.php(403), a common password-guessing target. Off by default because Jetpack and the WordPress mobile apps still use it
-
UPLOAD_MAX_SIZE: the largest upload, e.g.1Gor64M. Setsupload_max_filesizeandpost_max_size, and Caddy's request body limit follows. Defaults to512M. Very large uploads over slow connections may also need a longerTIMEOUT_READ_BODY(default10m) -
PHP_INI: any php.ini settings, one per line, applied after the image's own, e.g. for OPcache ormemory_limit. They apply to the web server and towpcommands. 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_INIis written to a file in the container at startup, so it needs a writable root filesystem. Mounting a single.inifile into/usr/local/etc/php/conf.dalso works (not a whole folder: that would hide the extensions'.inifiles); Caddy's request body limit follows apost_max_sizeset 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 to1to 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 (seeTRUSTED_PROXIES). Defaults to0.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 to0to bring the editors back. A site that defines the constant itself, e.g. inWORDPRESS_CONFIG_EXTRA, keeps its own value. Earlier images had it off unless set to1, which still worksDISABLE_WP_CRON: stops WordPress from starting scheduled tasks on page loads. FollowsCRON: on while the image's cron runner runs, off withCRON=0. Set it explicitly to override, e.g.1withCRON=0and 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 intowp-content, and a fresh core volume doesn't copy them into awp-contentthat already has plugins or themes (see Upgrading and Migrating). Set to0to turn it off. A site that defines the constant itself, e.g. inWORDPRESS_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 to0to 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 to0if an integration logs in with oneRESTRICT_REST_USERS: on by default, so the REST API's user routes (/wp-json/wp/v2/users,/wp-json/wp/v2/users/<id>) answer only logged-in users who can write posts; visitors get 401. WordPress otherwise lists every author with published posts, login name included, to anyone. The block editor's author picker and @-mentions keep working, and/wp-json/wp/v2/users/mestays open to every logged-in user. Set to0if visitors or a headless front end need the user routesWORDPRESS_CONFIG_EXTRA: PHP added towp-config.phpwhen 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, DISABLE_APPLICATION_PASSWORDS and RESTRICT_REST_USERS 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 DockerHEALTHCHECKuses it: healthy when Caddy answers and PHP executes, independent ofSERVER_NAMEand 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: wordpress@example.com
MSMTP_PASSWORD_FILE: /run/secrets/smtp_password
MSMTP_FROM: wordpress@example.com
secrets:
- smtp_password
secrets:
smtp_password:
file: ./smtp_password.txtCompose 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("you@example.com", "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: 1GSizing: 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.
- 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.phpis refused (403) while the runner runs: WordPress no longer calls it, so any request comes from outside. It stays open withCRON=0or an explicitDISABLE_WP_CRON=0, for page-load cron or an external scheduler that calls it over HTTP. A site that only definesDISABLE_WP_CRONasfalsein 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.
- 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.
- Set
SUPERCACHE=1and recreate the container. - Check it, logged out:
curl -sI https://example.com/ | grep -i x-frankenpress-cacheprintsHITonce 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, leaveSUPERCACHEoff.
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 permissions, so setgid 2775 folders give 664 uploads.
Other commands started with docker exec, e.g. a shell, still use Docker's default umask; run umask 0002 in them first.
Don't replace the command with sh -c "umask 0002; exec frankenphp …": the image then skips its setup, so a fresh volume gets no WordPress and the startup log stays silent.
FIX_OWNERSHIP changes ownership only, never permissions.
Pulling a newer image and recreating the container doesn't update WordPress core in an existing /var/www/html volume. WordPress updates itself there.
docker compose up -V (--renew-anon-volumes) replaces core with the image's bundled version, which can be older than what the site already runs.
Before recreating a container, check that no update is running. docker compose exec wordpress find /var/www/html -maxdepth 1 -name .maintenance -mmin -10 prints a file if one started in the last 10 minutes; recreating mid-update leaves the site in maintenance mode.
If the old project bind-mounted /var/www/html, compose keeps that mount even after you remove it from the file, because the image declares the path a VOLUME. Cut over with:
docker compose up -d --force-recreate --renew-anon-volumes wordpress
Then check that /var/www/html is a volume, not the old bind mount:
docker inspect --format '{{range .Mounts}}{{.Type}} {{.Destination}}{{println}}{{end}}' <container>
A fresh core volume copies WordPress's bundled extras (Akismet, Hello Dolly, the default themes) into wp-content only for a new site. When wp-content already has plugins or themes, e.g. a bind mount or a separate volume, they're left out, so extras you deleted don't come back whenever the core volume is recreated. CORE_UPGRADE_SKIP_NEW_BUNDLED=0 copies the missing ones again.
The compose example follows MariaDB's current LTS release (mariadb:lts) with MARIADB_AUTO_UPGRADE on, so the data directory is upgraded when the image moves to a new major version; the image backs up the system tables first. The upgrade is one-way, so take a dump before a major jump anyway:
docker compose exec db sh -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb-dump -uroot --all-databases --routines --triggers' > backup.sql
The published image has no compiler. To add a PHP extension in your own image, install the build tools for that step, as listed in $PHPIZE_DEPS, then remove them again:
FROM bock/frankenpress:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends $PHPIZE_DEPS \
&& install-php-extensions xdebug \
&& apt-get purge -y --auto-remove $PHPIZE_DEPS \
&& rm -rf /var/lib/apt/lists/*
USER www-dataWhen building this repository yourself, --build-arg WITH_GHOSTSCRIPT=0 leaves out Ghostscript, which saves about 55 MB and removes PDF parsing entirely, but also turns off PDF thumbnails.
- No PHP from uploads:
.php,.phtml,.pharand similar files underwp-content/uploadsreturn 404, so a vulnerable upload form can't become remote code execution. - A stolen admin session goes less far: no code editors (
DISALLOW_FILE_EDIT), no plugin or theme uploads and installs (DISALLOW_PLUGIN_THEME_INSTALL) and no application passwords (DISABLE_APPLICATION_PASSWORDS), all on by default. Uploading a plugin with a web shell is how a stolen admin session usually becomes code execution on the server. - No private files: hidden files and folders (
.git,.env,.htaccess, …) and backup or log files (*.bak,*.sql,*.log, …) return 404./.well-known/is still served. - Restricted image formats: Imagick only handles GIF, JPEG, PNG, WebP, AVIF and HEIC, plus reading PDFs for thumbnails. PostScript, SVG and ImageMagick's other formats are refused, which keeps uploads away from rarely audited parsers. In the VIPS images,
VIPS_BLOCK_UNTRUSTED=1likewise limits libvips to its well-audited loaders, so its PDF, SVG, ImageMagick, OpenEXR, JPEG XL and other rarely audited loaders are off (PDF thumbnails still come from Imagick). libvips blocks them whenever the variable is set, whatever its value, soVIPS_BLOCK_UNTRUSTED=0doesn't turn this off. - Security headers:
X-Content-Type-Options,Referrer-PolicyandX-Frame-Optionsare added unless WordPress already sent them; theServerheader is removed. HSTS is available viaHSTS. - No user enumeration through the REST API:
/wp-json/wp/v2/usersand/wp-json/wp/v2/users/<id>answer only logged-in users who can write posts (RESTRICT_REST_USERS, on by default), also inside/batch/v1. Visitors no longer get the authors' login names. A proxy in front no longer needs to block these routes, which would break the block editor's author picker and @-mentions. - No spoofed HTTPS:
X-Forwarded-ProtoandCloudFront-Forwarded-Protoare only honoured from trusted proxies (TRUSTED_PROXIES) and dropped from other requests. WordPress's stockwp-config.phpwould otherwise believe any visitor claiming HTTPS. - REST routes only from the URL: WordPress would also take
rest_routefrom a POST body (POST /withrest_route=/batch/v1), which reaches the REST API without/wp-json/or?rest_route=in the URL and so slips past path rules in a proxy in front. The image ignoresrest_routein the body;/wp-json/…and?rest_route=work as before. - Bounded requests: slow clients time out, and oversized request bodies get 413 (see
TIMEOUT_*andREQUEST_BODY_MAX_BYTES). - Verified downloads: WP-CLI and the VIPS plugin are pinned to releases and checked against their published checksums.
- Optional XML-RPC block:
BLOCK_XMLRPC=1refusesxmlrpc.php. - No external cron triggers: while the container runs WordPress's scheduled tasks itself (
CRON, on by default), requests towp-cron.phpget 403, so nobody can trigger them from outside. - Pinned base images: every base image is pinned by digest; updates come in as Dependabot pull requests that CI tests first.
- Fewer libraries, fewer CVEs: the published image has no compiler and none of ImageMagick's extra codec libraries (OpenEXR, DjVu, WMF, …).
- WordPress core is writable by the web server user: dashboard updates need this. Existing sites keep the WordPress version in their
/var/www/htmlvolume and update through WordPress itself; pulling a newer image doesn't change it.
The standard WordPress images are a good starting point and can handle many use cases, but require significant modification to scale. You also don't get FrankenPHP app server. Instead, you need to choose Apache or PHP-FPM. We use the WordPress base image but extend it with FrankenPHP & Caddy.
FrankenPHP is built on Caddy, a modern web server written in Go. It's secure, performs well when scaling becomes important, and runs PHP in Go's mature concurrency model, all in a single Docker image.
An attacker who gets code running in a root container has a much easier path to the host and everything it manages. Running as www-data limits a compromise to what that user can do inside the container.
tldr: The default SERVER_NAME=:80 already serves plain HTTP on port 80 for any hostname. Use another port (e.g. :8095) if your proxy expects one.
Working in cloud environments like AWS can be tricky because your traffic is going through a load balancer or some proxy. This means your server name is not what you think your server name is. Your domain hits a proxy dns entry that then hits your application. The application doesn't know your domain. It knows the proxied name. This may seem strange, but it's actually a well established strong architecture pattern.
What about SSL cert? Use SERVER_NAME=mydomain.com, :80
Caddy, the underlying application server is flexible enough for multiple entries. Separate multiple values with a comma. It will still request certificate.
What about visitor IPs? Behind a proxy, WordPress would otherwise see every visitor as the proxy's address, so login limiters and security plugins would block everyone at once. The image takes the client IP from X-Forwarded-For when the request comes from a trusted proxy; see TRUSTED_PROXIES above.
What about HTTPS behind a TLS-terminating proxy? When a trusted proxy sends X-Forwarded-Proto: https (or CloudFront's CloudFront-Forwarded-Proto), WordPress treats the request as HTTPS, so redirects and URLs use https://. FORCE_HTTPS=1 is still available if your proxy doesn't send either header.