Production-ready WordPress container built on PHP-FPM with WP-CLI, auto-install, and security hardening.
Every build ships a Sigstore-signed SBOM and a full Trivy scan — verify them yourself, no login required:
gh attestation verify oci://ghcr.io/oorabona/wordpress:latest --owner oorabona
Full walkthrough (SBOM payload, Trivy findings, multi-arch manifest inspection, upstream dependency tracking) → https://oorabona.github.io/docker-containers/verify-images/
services:
openresty:
image: ghcr.io/oorabona/openresty:latest
ports:
- "8080:8080"
volumes:
- ./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
depends_on:
wordpress:
condition: service_healthy
wordpress:
image: ghcr.io/oorabona/wordpress:latest
volumes:
- wp_uploads:/var/www/html/wp-content/uploads
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_pass
WP_AUTO_INSTALL: "true"
WP_SITE_URL: "https://example.com"
WP_SITE_TITLE: "My Site"
WP_ADMIN_USER: admin
WP_ADMIN_EMAIL: [email protected]
WP_ADMIN_PASSWORD_FILE: /run/secrets/admin_pass
WP_LOCALE: fr_FR
WP_TIMEZONE: "Europe/Paris"
WP_PLUGINS: "redis-cache,wordfence"
depends_on:
mariadb:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "php-fpm -t"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
security_opt:
- no-new-privileges:true
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_pass
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD_FILE: /run/secrets/db_pass
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mariadb_data:
wp_uploads:
With WP_AUTO_INSTALL=true, the site is fully operational on first boot — no setup wizard needed.
Important: This container runs PHP-FPM on port 9000. It requires a reverse proxy (OpenResty, Nginx, Caddy) to serve HTTP traffic.
DISALLOW_FILE_MODS, non-root user, no file editor_FILE suffixWhen WP_AUTO_INSTALL=true is set, the entrypoint automatically:
wp-config.php from database env varsDISALLOW_FILE_MODS, DISALLOW_FILE_EDIT, etc.)wp core install with the provided site settings/%postname%/)WP_PLUGINS listThe process is idempotent — restarting the container skips installation if WordPress is already set up.
| Variable | Description | Default |
|---|---|---|
WORDPRESS_DB_HOST | Database hostname | — |
WORDPRESS_DB_NAME | Database name | wordpress |
WORDPRESS_DB_USER | Database username | root |
WORDPRESS_DB_PASSWORD | Database password | — |
| Variable | Description | Default |
|---|---|---|
WP_AUTO_INSTALL | Enable auto-install on first boot | false |
WP_SITE_URL | Site URL (with protocol) | http://localhost |
WP_SITE_TITLE | Site title | WordPress Site |
WP_ADMIN_USER | Admin username | admin |
WP_ADMIN_PASSWORD | Admin password | — (required) |
WP_ADMIN_EMAIL | Admin email | — (required) |
WP_LOCALE | Site locale (e.g. fr_FR) | — |
WP_TIMEZONE | Timezone (e.g. Europe/Paris) | — |
WP_PLUGINS | Comma-separated plugin slugs | — |
All variables support the _FILE suffix for Docker secrets (e.g. WP_ADMIN_PASSWORD_FILE).
| Argument | Description | Default |
|---|---|---|
REMOTE_CR | Registry root for the base PHP image | docker.io/oorabona |
PHP_TAG | Tag of the base PHP-FPM image | latest |
WPCLI_VERSION | WP-CLI version | 2.12.0 |
WPCLI_KEY_FPR | Pinned WP-CLI release-signing key fingerprint | 63AF7AA15067C05616FDDD88A3A2E8F226F0BC06 |
SQLITE_PLUGIN_VERSION | SQLite Database Integration plugin version | 2.2.23 |
VERSION | WordPress version | latest |
The auto-generated wp-config.php includes:
define('DISALLOW_FILE_MODS', true); // No plugin/theme install via wp-admin
define('DISALLOW_FILE_EDIT', true); // No code editor in wp-admin
define('WP_AUTO_UPDATE_CORE', false); // Updates via image rebuild
define('AUTOMATIC_UPDATER_DISABLED', true);
This means:
wordpress:
security_opt:
- no-new-privileges:true
# After first successful boot, add:
# read_only: true
# tmpfs:
# - /tmp
# - /run
# Block PHP execution in uploads — critical security rule
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}
# Block access to sensitive files
location ~* /(wp-config\.php|readme\.html|license\.txt) {
deny all;
}
# Block XML-RPC (unless needed)
location = /xmlrpc.php {
deny all;
}
# Rate limit wp-login.php
location = /wp-login.php {
limit_req zone=login burst=3 nodelay;
# ... fastcgi_pass
}
Traditional WordPress updates files in-place (risky). This image uses an immutable approach:
Plugin/theme/core update needed
→ Update WP_PLUGINS env var or image tag
→ docker compose pull && docker compose up -d
→ Container restarts with new versions
→ Database is persistent, no data loss
# Docker secrets (recommended)
secrets:
db_pass:
file: ./secrets/db_password.txt
admin_pass:
file: ./secrets/admin_password.txt
services:
wordpress:
environment:
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_pass
WP_ADMIN_PASSWORD_FILE: /run/secrets/admin_pass
secrets:
- db_pass
- admin_pass
| Path | Description | Notes |
|---|---|---|
/var/www/html/wp-content/uploads | Media uploads | Only writable volume needed |
WordPress core, plugins, and themes are baked into the image. Only the uploads directory needs persistent storage.
# Site info
docker exec wordpress wp core version
docker exec wordpress wp option get siteurl
# Database
docker exec wordpress wp db check
docker exec wordpress wp db export /tmp/backup.sql
# Users
docker exec wordpress wp user list
# Plugins (read-only — informational)
docker exec wordpress wp plugin list
docker exec wordpress wp plugin status
Note: wp plugin install and wp theme install are blocked by DISALLOW_FILE_MODS. Plugin installation is done via the WP_PLUGINS env var or at image build time.
OPcache and the PHP resource limits come from the base image's conf.d/php.ini; this image adds no tuning of its own. Read the values a given tag actually runs with rather than a copy of them:
docker run --rm --entrypoint php ghcr.io/oorabona/wordpress:latest -r \
'foreach (["memory_limit","upload_max_filesize","post_max_size",
"opcache.memory_consumption","opcache.max_accelerated_files",
"opcache.validate_timestamps"] as $k) printf("%s=%s\n", $k, ini_get($k));'
opcache.validate_timestamps=0 is the one worth knowing about: PHP never re-checks a file's mtime, so changed code is picked up on container restart, not on save. That is the intended behaviour for an immutable image.
To override a setting, mount a .ini into /usr/local/etc/php/conf.d/ read-only:
volumes:
- ./php-overrides.ini:/usr/local/etc/php/conf.d/zz-overrides.ini:ro
Two things that bite:
php.ini, so an override has to sort after it. zz-overrides.ini applies. So does wordpress.ini, since p precedes w. What does not is anything sorting earlier — 00-overrides.ini, cli-tuning.ini, opcache-tuning.ini — which is read and then overwritten by the base, silently. Two files in this image were written that way and neither ever took effect.:ro is not optional. A bind mount is writable by default, and this one is PHP configuration the interpreter obeys — a WordPress process that can rewrite it can change how PHP runs, permanently, across restarts.┌───────────────────────────────────┐
│ OpenResty / Nginx │ ← Reverse proxy, security headers,
│ (rate limiting, PHP block in │ PHP-in-uploads block
│ uploads, security headers) │
└───────────────┬───────────────────┘
│ fastcgi :9000
┌───────────────▼───────────────────┐
│ WordPress (PHP-FPM) │ ← Read-only filesystem
│ │ DISALLOW_FILE_MODS=true
│ wp-content/uploads/ → volume │ Auto-install via wp-cli
└───────────────┬───────────────────┘
│
┌───────────────▼───────────────────┐
│ MariaDB / MySQL │ ← All WordPress data lives here
│ │ Persistent volume
└───────────────────────────────────┘
Alpine Linux
└── PHP-FPM (from oorabona/php)
├── gd, mysqli, sodium, zip, apcu, OPcache
└── WordPress
├── WordPress core (downloaded at build time)
├── WP-CLI
├── MariaDB client tools
└── Auto-install entrypoint
This image is designed for MariaDB/MySQL, which is WordPress's native and most battle-tested database backend.
WordPress 6.4+ introduced experimental SQLite support via a drop-in plugin. A future lightweight variant could leverage SQLite for single-container deployments (dev environments, static-ish sites, edge hosting), eliminating the MariaDB dependency. This is not currently implemented.
# Build with default versions
./make build wordpress
# Build with specific versions
docker build \
--build-arg REMOTE_CR=ghcr.io/oorabona \
--build-arg PHP_TAG=latest \
--build-arg WPCLI_VERSION=2.12.0 \
--build-arg WPCLI_KEY_FPR=63AF7AA15067C05616FDDD88A3A2E8F226F0BC06 \
--build-arg SQLITE_PLUGIN_VERSION=2.2.23 \
--build-arg VERSION=6.9.1 \
-t wordpress:custom .
Chained-base container: consumes ghcr.io/oorabona/php (project-produced). Docker Hub mirror at docker.io/oorabona/php (429-rate-limited; default local fallback).
config.yaml declares source: /php — the leading-slash marker signals "chained-on-own-build": sync writes to ghcr.io/<owner>/library/php (shared upstream mirror path), while reachability probing targets ghcr.io/<owner>/php (project's published container). This ensures REMOTE_CR is injected only when the project-produced PHP image is available.
| Component | Version | Source | Monitoring |
|---|---|---|---|
| PHP | latest | oorabona/php | Base image tag |
| WP-CLI | 2.12.0 | wp-cli/wp-cli | GitHub releases |
| WordPress | latest | wordpress.org | API |
Content type
Image
Digest
sha256:f53341bb5…
Size
126.7 MB
Last updated
1 day ago
docker pull oorabona/wordpress