Production-ready, fully decoupled, and resource-efficient containerized WordPress deployment architecture for multi-tenant hosts. Built on the latest-generation PHP-FPM with all extensions required by WordPress (MySQLi, PDO, cURL, mbstring, XML, GD, Intl, Zip, BCMath, Exif) plus additional modules (Redis, igbinary, zstd, ImageMagick, APCu, GMP, OPcache) and media codecs (WebP, AVIF) pre-installed in the image, latest LTS MariaDB, Angie reverse proxy, and Valkey in-memory cache.
- Shell-less FPM Runtime: Web container has all shell binaries completely removed. Zero capability to spawn shell processes, even in the event of an arbitrary code execution exploit.
- Pure UNIX Domain Socket IPC: Zero exposed TCP network ports for MariaDB, Valkey, PHP-FPM, and go-notifier. All inter-service communication goes through dedicated, isolated in-memory tmpfs socket volumes (
sockets_mysql,sockets_valkey,sockets_php,sockets_notify) accessible strictly to the required container pairs. - Zero-Privilege Security Profile: Read-only root filesystems, all Linux capabilities dropped (
cap_drop: [ALL]) with none added back (cap_add: []), privilege escalation blocked (no-new-privileges), running as a dedicated unprivileged host user. - Atomic Version Upgrades: An automated pre-flight init service compares
wp-includes/version.phpbetween the image donor and the live site. On version mismatch it atomically replaceswp-admin/,wp-includes/, and root PHP files without ever touchingwp-content/or user data. - Strict Docker Secrets: All sensitive data — database credentials, database name, table prefix, and all eight authentication keys and salts — are loaded exclusively from secret files. No plaintext credentials in environment variables,
.env, or Compose manifests. Missing or empty secrets cause an immediate fatal error, preventing the application from starting. - Zero-Secret Mail & Push Notifications: Transactional mail (
wp_mail()) is intercepted by a lightweight must-use plugin and dispatched over an isolated UNIX domain socket to go-notifier. This eliminates the need for third-party SMTP plugins, keeping WordPress and its database free of mail server credentials and API tokens. The daemon handles authenticated SMTP relay via Docker Secrets and supports multi-channel push alerting (Telegram, Matrix, ntfy) with subject regex routing and rate limiting. - Modular Must-Use (MU) Plugin Architecture: Provides a suite of optional, zero-dependency must-use plugins configured declaratively via
wordpress.envto harden runtime behavior, optimize administration workflows, and enforce client/server privacy controls without third-party plugin overhead or database-persisted settings.
Directory Structure
├── docker-compose.yaml # Production deployment manifest
├── .env # Host infrastructure variables (UID, GID, image versions)
├── wordpress.env # WordPress application overrides (cron, URLs, memory, debug, etc.)
├── notifier.env # Optional (go-notifier): channels, routing rules, and limits
├── secrets/ # Docker secrets directory (read-only group access)
│ ├── db_name.txt # Database name
│ ├── db_user.txt # Database username
│ ├── db_password.txt # Database password
│ ├── db_root_password.txt # MariaDB root password
│ ├── table_prefix.txt # WordPress table prefix
│ ├── auth_key.txt # Authentication key
│ ├── secure_auth_key.txt # Secure authentication key
│ ├── logged_in_key.txt # Logged-in key
│ ├── nonce_key.txt # Nonce key
│ ├── auth_salt.txt # Authentication salt
│ ├── secure_auth_salt.txt # Secure authentication salt
│ ├── logged_in_salt.txt # Logged-in salt
│ ├── nonce_salt.txt # Nonce salt
│ ├── smtp_host.txt # Optional (go-notifier): SMTP server hostname
│ ├── smtp_port.txt # Optional (go-notifier): SMTP port (465 or 587)
│ ├── smtp_mail.txt # Optional (go-notifier): SMTP username / sender email
│ ├── smtp_password.txt # Optional (go-notifier): SMTP password / app password
│ ├── wp_super_admins.txt # Optional (admin-control): HMAC-SHA512 super admin hashes
│ └── ... # Optional (go-notifier): messenger secrets (telegram, etc.)
├── config/ # Service configuration files
│ ├── php/ # PHP-FPM configuration
│ │ ├── php-fpm.conf # FPM master process config
│ │ ├── php.ini # PHP runtime directives
│ │ ├── opcache.ini # OPcache tuning
│ │ └── www.conf # FPM pool settings (workers, limits, disabled functions)
│ ├── mysql/ # MariaDB configuration
│ │ └── my.cnf # InnoDB buffer, connections, socket path
│ ├── valkey/ # Valkey configuration
│ │ └── valkey.conf # Memory limit, eviction policy, UNIX socket
│ └── angie/ # Angie reverse proxy configuration
│ ├── angie.conf # Main server config with FastCGI routing
│ ├── mime.types # MIME type mappings for static assets
│ ├── modules.conf # Dynamic modules activation (Brotli, Zstd, etc.)
│ └── conf.d/ # Custom site snippets (blockbots, cache rules, etc.)
├── mariadb/ # Persistent MariaDB data volume
├── mariadb-backup/ # Database backups directory
├── tmp/ # Upload buffering directory (avoids RAM exhaustion)
├── .wp-cli/ # WP-CLI packages and Composer cache directory
└── data/ # WordPress document root
├── wp-config.php # Site configuration (from examples/data/wp-config.php.example)
├── wp-content/ # User content directory
│ ├── mu-plugins/ # Must-use plugins directory
│ │ └── sbs-loader.php # Platform loader (mounted :ro, root:${SITE_USER} 0440)
│ ├── uploads/ # Uploaded media assets
│ └── ...
└── ... # WordPress core files (auto-populated by init service)
Deployment & Setup
- Docker Engine & Compose: Ensure Docker Engine and Docker Compose plugin are installed on the host. Follow the official installation guide for Ubuntu.
- External Gateway Network: The stack communicates with the host's Edge reverse proxy over a shared Docker network named
frontend_gateway(declared asexternal: true). This network is automatically provisioned and managed with a dedicated subnet (172.20.0.0/16) by the angie-docker-compose stack. If deploying standalone withoutangie-docker-compose, create the network manually:
docker network create --subnet=172.20.0.0/16 frontend_gatewayCreate a system account with a disabled login shell:
SITE_USER=mysite
sudo useradd -m -d /home/${SITE_USER} -s /usr/sbin/nologin ${SITE_USER}
sudo chown root:${SITE_USER} /home/${SITE_USER}
sudo chmod 0750 /home/${SITE_USER}SITE_USER=mysite
DATA_DIR=data
WP_CONTENT_DIR=wp-content
sudo mkdir -p /home/${SITE_USER}/{.wp-cli,${DATA_DIR}/${WP_CONTENT_DIR}/uploads,mariadb,mariadb-backup,secrets,tmp,config/{angie/conf.d,mysql,php,valkey}}
sudo chown -R ${SITE_USER}:${SITE_USER} /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,mariadb-backup,tmp}
sudo chmod 0700 /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,mariadb-backup,tmp}
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/{config,secrets}
sudo chmod -R 0750 /home/${SITE_USER}/{config,secrets}Install the required tools:
sudo apt update && sudo apt install -y pwgen opensslAll secrets are strictly required — the stack will refuse to start without them.
Database credentials:
printf "db_%s" "$(pwgen -s -A 6 1)" | sudo tee /home/${SITE_USER}/secrets/db_name.txt > /dev/null
printf "u_%s" "$(pwgen -s -A 6 1)" | sudo tee /home/${SITE_USER}/secrets/db_user.txt > /dev/null
pwgen -s 64 1 | tr -d '\n' | sudo tee /home/${SITE_USER}/secrets/db_password.txt > /dev/null
pwgen -s 64 1 | tr -d '\n' | sudo tee /home/${SITE_USER}/secrets/db_root_password.txt > /dev/null
printf "%s_" "$(pwgen -s -A 6 1)" | sudo tee /home/${SITE_USER}/secrets/table_prefix.txt > /dev/nullAuthentication keys and salts:
for s in auth_key secure_auth_key logged_in_key nonce_key auth_salt secure_auth_salt logged_in_salt nonce_salt; do
openssl rand -base64 48 | sudo tee /home/${SITE_USER}/secrets/${s}.txt > /dev/null
doneLock secret files:
sudo chown root:${SITE_USER} /home/${SITE_USER}/secrets/*.txt
sudo chmod 0440 /home/${SITE_USER}/secrets/*.txtREPO="https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main"
sudo curl -fsSL ${REPO}/config/php/php-fpm.conf -o /home/${SITE_USER}/config/php/php-fpm.conf
sudo curl -fsSL ${REPO}/config/php/php.ini -o /home/${SITE_USER}/config/php/php.ini
sudo curl -fsSL ${REPO}/config/php/opcache.ini -o /home/${SITE_USER}/config/php/opcache.ini
sudo curl -fsSL ${REPO}/config/php/www.conf -o /home/${SITE_USER}/config/php/www.conf
sudo curl -fsSL ${REPO}/config/mysql/my.cnf -o /home/${SITE_USER}/config/mysql/my.cnf
sudo curl -fsSL ${REPO}/config/valkey/valkey.conf -o /home/${SITE_USER}/config/valkey/valkey.conf
sudo curl -fsSL ${REPO}/config/angie/angie.conf -o /home/${SITE_USER}/config/angie/angie.conf
sudo curl -fsSL ${REPO}/config/angie/mime.types -o /home/${SITE_USER}/config/angie/mime.types
sudo curl -fsSL ${REPO}/config/angie/modules.conf -o /home/${SITE_USER}/config/angie/modules.conf
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/config
sudo find /home/${SITE_USER}/config -type f -exec chmod 0640 {} +SITE_USER=mysite
DATA_DIR=data
REPO="https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main"
sudo curl -fsSL ${REPO}/docker-compose.yaml -o /home/${SITE_USER}/docker-compose.yaml
sudo curl -fsSL ${REPO}/examples/.env.example -o /home/${SITE_USER}/.env
sudo curl -fsSL ${REPO}/examples/wordpress.env.example -o /home/${SITE_USER}/wordpress.env
sudo chmod 0600 /home/${SITE_USER}/{docker-compose.yaml,.env,wordpress.env}
sudo -u ${SITE_USER} curl -fsSL ${REPO}/examples/data/wp-config.php.example -o /home/${SITE_USER}/${DATA_DIR}/wp-config.phpEdit .env to set the site user and UID/GID matching the system account created in Step 1 (check with id ${SITE_USER}):
sudo nano /home/${SITE_USER}/.envSee examples/.env.example for all available variables.
Most WordPress application settings (cron, memory limits, URLs, debug mode, etc.) can be tuned in wordpress.env without editing wp-config.php:
sudo nano /home/${SITE_USER}/wordpress.envSee examples/wordpress.env.example for all available overrides and the official wp-config.php documentation for detailed parameter descriptions.
The PHP-FPM container is strictly hardened (read_only: true, cap_drop: [ALL]) and does not contain a local mail transfer agent (MTA) such as sendmail or postfix. Standard PHP mail() execution is disabled. Without an email relay, WordPress cannot deliver critical administrative messages:
- Password recovery emails for administrators and users
- PHP fatal error Recovery Mode access links
- Security plugin alerts (e.g. Wordfence attack warnings)
- Contact forms and eCommerce notifications
To provide high-performance, non-blocking delivery without storing plaintext SMTP passwords in the WordPress database, the stack integrates go-notifier — a zero-dependency micro-daemon running in a minimal scratch container. It communicates with WordPress strictly over a local UNIX domain socket (/var/run/sockets/notify/notify.sock) and loads credentials directly from Docker Secrets in memory.
-
Enter SMTP server credentials: Securely enter your SMTP server hostname (e.g.
smtp.example.com), port (587or465), sender email / username, and password usingnanowithout exposing credentials in shell command history:sudo nano /home/${SITE_USER}/secrets/smtp_host.txt sudo nano /home/${SITE_USER}/secrets/smtp_port.txt sudo nano /home/${SITE_USER}/secrets/smtp_mail.txt sudo nano /home/${SITE_USER}/secrets/smtp_password.txt
(Optional) If using Telegram, Matrix, ntfy, or custom admin alert routing, edit the corresponding secret files as needed:
Telegram:
telegram_bot_token.txt— Telegram Bot API token (e.g.123456789:ABCdefGHIjkl...).telegram_chat_id.txt— Target chat, group, or channel ID (e.g.-1001234567890or123456789).
sudo nano /home/${SITE_USER}/secrets/telegram_bot_token.txt sudo nano /home/${SITE_USER}/secrets/telegram_chat_id.txt
Matrix:
matrix_url.txt— Direct Client-Server API URL to the Synapse / homeserver (e.g.https://matrix.example.comorhttps://synapse.example.com:8448). NOTE:go-notifierdoes not query.well-known/matrix/client; specify the direct homeserver endpoint, not the root organization domain. Unsure? Checkhttps://matrix.org/.well-known/matrix/clientand use thebase_url.matrix_room_id.txt— Internal room ID (e.g.!abcdef:matrix.example.comor!opaque-v12_roomid).matrix_access_token.txt— Matrix bot / user access token.
sudo nano /home/${SITE_USER}/secrets/matrix_url.txt sudo nano /home/${SITE_USER}/secrets/matrix_room_id.txt sudo nano /home/${SITE_USER}/secrets/matrix_access_token.txt
ntfy:
ntfy_url.txt— ntfy server URL (e.g.https://ntfy.shorhttps://ntfy.example.com).ntfy_topic.txt— Target topic name.ntfy_token.txt— Optional Bearer token for protected topics.
sudo nano /home/${SITE_USER}/secrets/ntfy_url.txt sudo nano /home/${SITE_USER}/secrets/ntfy_topic.txt sudo nano /home/${SITE_USER}/secrets/ntfy_token.txt
Admin Alert Recipients:
admin_emails.txt— Admin emails allowed to receive messenger alerts (comma/newline-separated, e.g.admin@example.com,security@example.com). Emails sent to other recipients (e.g. customer orders, password resets) are routed exclusively to SMTP to protect user privacy.
sudo nano /home/${SITE_USER}/secrets/admin_emails.txtLock secret files with strict ownership and permissions:
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/secrets sudo chmod 0750 /home/${SITE_USER}/secrets sudo chmod 0440 /home/${SITE_USER}/secrets/*.txt
-
Download and configure routing (
notifier.env): Configure active notification channels (NOTIFY_CHANNELS), rate limits, and custom subject regex routing rules (NOTIFY_RULE_<NAME>_*). Seeexamples/notifier.env.examplefor detailed syntax:sudo curl -fsSL ${REPO}/examples/notifier.env.example -o /home/${SITE_USER}/notifier.env sudo nano /home/${SITE_USER}/notifier.env sudo chmod 0600 /home/${SITE_USER}/notifier.env
-
Install the SBS Platform Loader: Download
sbs-loader.php, which dynamically and securely loads platform MU-plugins (includingwp-notify.phpby default) from the immutable container layer:SITE_USER=mysite DATA_DIR=data WP_CONTENT_DIR=wp-content REPO="https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main" sudo -u ${SITE_USER} mkdir -p /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins sudo curl -fsSL ${REPO}/mu-plugins/sbs-loader.php \ -o /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php sudo chown root:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php sudo chmod 0440 /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php
sbs-loader.phpis mounted read-only into the container. It loadswp-notify.phpfrom/usr/local/share/wordpress/mu-plugins/to intercept allwp_mail()calls and dispatch them to/var/run/sockets/notify/notify.sock.
If you plan to use an external WordPress plugin sending email via direct HTTP API (such as FluentSMTP or WP Mail SMTP connecting to AWS SES, Mailgun, SendGrid, or Postmark), or if the site does not require email delivery:
- Edit
docker-compose.yaml: Comment out thenotifierservice block, thesockets_notifyvolume mounts inwordpressandwp-cli, thesockets_notifydefinition involumes:, and the 4smtp_*secret definitions in the rootsecrets:section:sudo nano /home/${SITE_USER}/docker-compose.yaml - Disable notify in
wordpress.env: SetWP_ENABLED_MU_PLUGINS=none(or omitnotifyfrom your custom slug list inWP_ENABLED_MU_PLUGINS) and do not createnotifier.env.
Pull all images (including tools profile services such as wp-cli) and start the stack:
docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools pull
docker compose -f /home/${SITE_USER}/docker-compose.yaml up -dOn first run, the init service automatically populates data/ with clean WordPress core files and exits. When deploying a new image (docker compose --profile tools pull followed by docker compose up -d), init automatically upgrades or rolls back WordPress core files (wp-admin/, wp-includes/, root PHP files) to strictly match the container image version, without ever touching user data, uploads, or wp-content/.
The hardened FPM runtime then starts with a read-only root filesystem — WordPress write operations (installing and updating plugins, themes, uploading media) continue to work normally through dedicated writable bind mounts.
To temporarily stop the containers:
docker compose -f /home/${SITE_USER}/docker-compose.yaml stopTo stop and remove containers and networks:
docker compose -f /home/${SITE_USER}/docker-compose.yaml downMust-Use (MU) Plugins
All platform MU-plugins are pre-installed directly inside the container image at /usr/local/share/wordpress/mu-plugins/ (immutable read-only system layer). They cannot be modified or tampered with by web processes or compromised plugins.
To activate them, you only install a single lightweight loader — sbs-loader.php — into ${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/:
SITE_USER=mysite
DATA_DIR=data
WP_CONTENT_DIR=wp-content
REPO="https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main"
sudo -u ${SITE_USER} mkdir -p /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins
sudo curl -fsSL ${REPO}/mu-plugins/sbs-loader.php \
-o /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php
sudo chown root:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php
sudo chmod 0440 /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.phpsbs-loader.php is mounted into the container as read-only (:ro), and its SHA-256 integrity is strictly verified by wp-guard during container startup.
Control which platform MU-plugins are loaded using WP_ENABLED_MU_PLUGINS in /home/${SITE_USER}/wordpress.env:
- Default (unset or empty): loads
notify(wp-notify.php). - Specific plugins: comma-separated slugs, e.g.
WP_ENABLED_MU_PLUGINS=notify,performance,reading-time. - Disable all:
WP_ENABLED_MU_PLUGINS=none.
| Slug | Plugin Name | Documentation |
|---|---|---|
performance |
WordPress Update & Network Optimization | wp-performance.md |
translation-updates-disabler |
Translation Update Control | wp-translation-updates-disabler.md |
telemetry-blocker |
Client-Side Telemetry & Tracker Blocker | wp-telemetry-blocker.md |
acf-editor-control |
ACF & Gutenberg Visibility Sync | wp-acf-editor-control.md |
post-duplicator |
Post & Page Duplicator | wp-post-duplicator.md |
core-cleanup |
Core Head, Header & Comment Cleanup | wp-core-cleanup.md |
transliterator |
Slug & Filename Transliterator | wp-transliterator.md |
youtube |
YouTube Channel Statistics & oEmbed Tools | wp-youtube.md |
reading-time |
Article Reading Time Shortcode | wp-reading-time.md |
snippets |
Conditional Snippets, Preloads & Asset Control | wp-snippets.md |
notify |
UNIX Socket Mail Dispatcher | wp-notify.md |
admin-control |
Administrative Hardening, RBAC & Dashboard Sanitization | wp-admin-control.md |
Memory Limits
If your workload requires adjusting PHP memory limits (e.g., for heavy WooCommerce imports or large media processing), limits must be updated consistently across three layers so that PHP-FPM workers never exceed the container cgroup ceiling (which would trigger a Linux kernel OOM kill):
wordpress.env—WORDPRESS_MEMORY_LIMIT(standard frontend limit) andWORDPRESS_MAX_MEMORY_LIMIT(administration and WP-CLI ceiling).docker-compose.yaml—mem_limitunder thewordpress(andwp-cli) service definition.config/php/www.conf— FPM worker pool directives (pm.max_children) andmemory_limitinconfig/php/php.ini.
PHP Hardening & WAF Prepend (.user.ini & Wordfence)
By default, PHP-FPM checks directory hierarchies for .user.ini files to allow per-directory configuration overrides. In modern containerized environments, this behavior introduces unnecessary filesystem stat overhead and creates an attack vector: if a compromised plugin permits arbitrary file upload or directory traversal into writable paths (such as ${WP_CONTENT_DIR}/uploads/), an attacker could place a malicious .user.ini file (e.g. attempting to override directives or execute code via auto_prepend_file).
To eliminate this vector, .user.ini processing is intentionally disabled in config/php/php.ini:
user_ini.filename = ""If your workload strictly requires per-directory .user.ini support for a custom plugin or legacy setup, you can re-enable it by commenting out this directive in config/php/php.ini:
;user_ini.filename = ""and restarting the PHP-FPM container:
docker compose restart wordpressWeb application firewalls like Wordfence traditionally attempt to install their pre-execution bootstrap script (wordfence-waf.php) via .user.ini.
To support Wordfence WAF out-of-the-box without exposing the stack to mutable .user.ini vulnerabilities, the image includes an immutable pre-execution runtime loader (/usr/local/share/wordpress/prepend/sbs-prepend.php) configured directly in config/php/www.conf:
php_admin_value[auto_prepend_file] = /usr/local/share/wordpress/prepend/sbs-prepend.php- When Wordfence is not installed: The loader safely and instantly skips execution with zero overhead and zero errors.
- When Wordfence is installed: When you optimize the firewall via the Wordfence admin wizard, Wordfence generates
/var/www/html/wordfence-waf.php. The immutable prepend loader automatically picks it up on the next request, achieving 100% Optimized WAF status before WordPress core initializes, with no manual PHP configuration changes required.
Permissions & Ownership
The stack enforces strict host-level privilege separation across three ownership tiers so that even in the event of a container compromise (${SITE_USER}), the process cannot modify deployment manifests, service configurations, or secret files:
root:root(Host-Only Manifests & Backups):docker-compose.yaml,.env,wordpress.env,notifier.env—0600mariadb-backup/—0700
root:${SITE_USER}(Read-Only Service Configs, Secrets, Platform Loader & Snippets):config/— directories0750, files0640(mounted:rointo containers)secrets/— directory0750, secret files0440(containers have group read-only access and cannotchmodor overwrite files)snippets/(optional) — directory0750, snippet files0640(mounted:ro,noexec,nosuid,nodevat/etc/wordpress/snippets)${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php—0440(mounted:rointo containers)
${SITE_USER}:${SITE_USER}(Runtime Writable Storage):.wp-cli/,mariadb/,tmp/—0700${DATA_DIR}/— top-level directory0700,wp-admin/andwp-includes/0700, content directories (${WP_CONTENT_DIR}/)0750, internal files0640
Use this self-contained block to apply or restore canonical ownership and permissions on an existing or migrated installation without stopping the stack:
SITE_USER=mysite
DATA_DIR=data
WP_CONTENT_DIR=wp-content
sudo chown root:${SITE_USER} /home/${SITE_USER}/
sudo chmod 0750 /home/${SITE_USER}
# 1. Host-only manifests & backups (root:root)
sudo chown root:root /home/${SITE_USER}/docker-compose.yaml /home/${SITE_USER}/.env /home/${SITE_USER}/wordpress.env
sudo chmod 0600 /home/${SITE_USER}/docker-compose.yaml /home/${SITE_USER}/.env /home/${SITE_USER}/wordpress.env
[ -f /home/${SITE_USER}/notifier.env ] && sudo chown root:root /home/${SITE_USER}/notifier.env && sudo chmod 0600 /home/${SITE_USER}/notifier.env
sudo chown -R root:root /home/${SITE_USER}/mariadb-backup
sudo chmod 0700 /home/${SITE_USER}/mariadb-backup
# 2. Read-only service configs, secrets & optional snippets (root:${SITE_USER})
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/{config,secrets}
sudo find /home/${SITE_USER}/config -type d -exec chmod 0750 {} +
sudo find /home/${SITE_USER}/config -type f -exec chmod 0640 {} +
sudo chmod 0750 /home/${SITE_USER}/secrets
sudo chmod 0440 /home/${SITE_USER}/secrets/*.txt
[ -d /home/${SITE_USER}/snippets ] && sudo chown -R root:${SITE_USER} /home/${SITE_USER}/snippets && sudo chmod 0750 /home/${SITE_USER}/snippets && sudo find /home/${SITE_USER}/snippets -type f -exec chmod 0640 {} +
# 3. Runtime writable directories & WordPress files (${SITE_USER}:${SITE_USER})
sudo chown -R ${SITE_USER}:${SITE_USER} /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,tmp}
sudo chmod 0700 /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,tmp}
sudo find /home/${SITE_USER}/${DATA_DIR} -mindepth 1 -type d -exec chmod 0750 {} +
[ -d /home/${SITE_USER}/${DATA_DIR}/wp-admin ] && sudo chmod 0700 /home/${SITE_USER}/${DATA_DIR}/wp-admin
[ -d /home/${SITE_USER}/${DATA_DIR}/wp-includes ] && sudo chmod 0700 /home/${SITE_USER}/${DATA_DIR}/wp-includes
sudo find /home/${SITE_USER}/${DATA_DIR} -type f -exec chmod 0640 {} +
# 4. Read-only platform loader (root:${SITE_USER} 0440)
[ -f /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php ] && sudo chown root:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php && sudo chmod 0440 /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.phpExternal Angie
Each site stack includes an internal Angie container that handles FastCGI routing to PHP-FPM via the UNIX socket. It does not publish any ports on the host. An external Edge Angie instance running on the host acts as the hardened perimeter gateway: it terminates TLS, manages automated certificates, enforces perimeter security, and forwards incoming traffic to the internal site containers.
The external Edge Angie operates at a dedicated static IP (172.20.0.254) inside the frontend_gateway network (172.20.0.0/16). All WordPress site stacks connect their internal Angie container (${SITE_USER}_angie) to this network, allowing Edge Angie to route requests directly by container name while internal Angie instances strictly verify $realip_remote_addr to accept X-Forwarded-For headers and HTTP connections exclusively from 172.20.0.254 (blocking lateral traffic from any other container in the shared subnet).
A complete, production-hardened, and optimized Edge reverse proxy deployment with a security-by-default architecture is available in the angie-docker-compose repository.
See the canonical site virtual host template:
data/conf.d/domains/wordpress.conf— production reverse proxy virtual host configuration (automated ACME TLS lifecycle, HTTP/3 QUIC, TLS 1.3 0-RTT anti-replay mitigation, upstream keepalive pooling to${SITE_USER}_angie:80, baseline security headers, HSTS, anonymous perimeter error pages, real client IP forwarding, tuned WordPress timeouts and body limits, etc.).
For each WordPress site, download the template into your Edge Angie configuration directory (${DATA_ANGIE}/conf.d/domains/), renaming the config file to your unique site identifier (e.g., ${SITE_USER}.conf as defined earlier):
DATA_ANGIE="/home/angie/data"
curl -fsSL https://raw.githubusercontent.com/webstudiobond/angie-docker-compose/main/data/conf.d/domains/wordpress.conf \
-o ${DATA_ANGIE}/conf.d/domains/${SITE_USER}.confWARNING: Replace 'wordpress.example' with your domain and 'mysite' with '${SITE_USER}' so upstream requests resolve to '${SITE_USER}_angie:80'.
Substitute the placeholders inside the downloaded configuration file using sed:
DOMAIN="example.com"
sed -i \
-e "s|wordpress\.example|${DOMAIN}|g" \
-e "s|mysite|${SITE_USER}|g" \
${DATA_ANGIE}/conf.d/domains/${SITE_USER}.confReview and customize the configuration as needed (for example, add the www subdomain to server_name or adjust Conditional Access Logging rules):
nano ${DATA_ANGIE}/conf.d/domains/${SITE_USER}.confWP-CLI
WP-CLI is an on-demand tool service under profiles: [tools] — it does not start with the main stack and only runs when explicitly invoked. It shares the WordPress document root (/var/www/html) and communicates with MariaDB and Valkey over their UNIX domain sockets.
NOTE: In official documentation, commands are written with a leading
wp(e.g.,wp core version,wp plugin list). In this stack,wp-cliis the Docker Compose service name whose container entrypoint directly executes thewpbinary. Consequently, you pass subcommands directly without typingwpagain.
See the official WP-CLI command reference for all available commands.
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli core version
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli db check
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli plugin list
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli plugin update --allTIP: When the stack is already running, appending
--no-deps(run --rm --no-deps wp-cli ...) skips dependency polling and health checks formariadbandvalkey, making commands execute instantaneously. If the stack is stopped or starting, omit--no-depsso Docker Compose automatically starts dependencies first.
For convenience in interactive SSH sessions, you can define a shell function per site in ~/.bashrc:
nano ~/.bashrcwp_mysite() {
docker compose -f /home/mysite/docker-compose.yaml run --rm wp-cli "$@"
}source ~/.bashrcFor multiple sites, create a function per site (e.g. wp_site1, wp_site2). After that, run commands directly:
wp_mysite core version
wp_mysite plugin listNOTE: Functions and aliases defined in
~/.bashrcare only loaded in interactive login shells. System cron jobs and non-interactive scripts execute via/bin/shwithout loading~/.bashrc, and must always use the fulldocker compose -f ...command.
Using the configured shell alias (e.g. wp_mysite):
Plugin management:
# Check plugin list with available updates
wp_mysite plugin list --fields=name,status,update,version
# List plugins with available updates in JSON format
wp_mysite plugin list --format=json --fields=name --update=available
# Preview updates without modifying files
wp_mysite plugin update --all --dry-run
# Update a specific plugin or all plugins
wp_mysite plugin update <plugin-name>
wp_mysite plugin update --all
# Update all plugins while excluding critical ones that require separate testing
wp_mysite plugin update --all --exclude=plugin-name-1,plugin-name-2
# Run command without loading active plugins (prevents crashes from broken plugin code)
wp_mysite plugin update --all --skip-plugins
# Install or update a plugin from a local zip archive placed in /home/mysite/tmp
sudo chown mysite:mysite /home/mysite/tmp/plugin-archive.zip
wp_mysite plugin install /var/www/tmp/plugin-archive.zip --force
# Or install/update and immediately activate the plugin
wp_mysite plugin install /var/www/tmp/plugin-archive.zip --force --activate
# Force-refresh update check and update translations (core, plugins, themes)
wp_mysite core check-update --force-check
wp_mysite language core update
wp_mysite language plugin update --all
wp_mysite language theme update --allPackage management:
# Install community WP-CLI packages (e.g. WP Rocket CLI)
wp_mysite package install wp-media/wp-rocket-cli:trunk
# List installed packages
wp_mysite package listMaintenance mode:
# Check current maintenance mode status
wp_mysite maintenance-mode status
# Activate maintenance mode before updates or migrations
wp_mysite maintenance-mode activate
# Deactivate maintenance mode once operations complete
wp_mysite maintenance-mode deactivateCache & permalinks:
# Flush rewrite rules in the database (resolves 404 errors on custom post types or after migrations)
wp_mysite rewrite flush
# Flush persistent object cache
wp_mysite cache flushAction Scheduler queue cleanup:
# Clean completed and failed actions from the Action Scheduler queue
wp_mysite action-scheduler clean --batches=20Create a fast, compressed database backup using zstd:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli db export --single-transaction --quick - | zstd -q > /home/${SITE_USER}/mariadb-backup/db_backup_$(date +%Y-%m-%d_%H-%M-%S).sql.zstOr using the shell alias:
wp_mysite db export --single-transaction --quick - | zstd -q > /home/mysite/mariadb-backup/db_backup_$(date +%Y-%m-%d_%H-%M-%S).sql.zstTo restore a compressed database backup:
zstd -dc /home/${SITE_USER}/mariadb-backup/db_backup_YYYY-MM-DD_HH-MM-SS.sql.zst | docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm -T wp-cli db import -NOTE: The
-Tflag disables pseudo-TTY allocation in Docker Compose, ensuring the piped SQL stream passes through stdin reliably without corruption or terminal escape sequences.
If your site or plugins rely on HTTP_HOST for dynamic URLs, configure the URL context using either:
wp-cli.yml(recommended):sudo -u ${SITE_USER} nano /home/${SITE_USER}/${DATA_DIR}/wp-cli.yml
url: https://wordpress.example
WORDPRESS_CLI_HOST: SetWORDPRESS_CLI_HOST=wordpress.exampleinwordpress.env.
DB Bridge
MariaDB runs with TCP networking completely disabled (skip-networking) and communicates exclusively via a UNIX socket. The db-bridge service is an on-demand socat relay that exposes the socket as a local TCP port for use with desktop database clients.
Start the bridge:
docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools up -d db-bridgeEstablish an SSH tunnel from your local machine:
ssh -i ~/.ssh/id_ed25519 -p 22 -L 3307:127.0.0.1:3307 user@your-server-ipConnect your database client (DataGrip, DBeaver, TablePlus, VS Code Database Client, Zed) to 127.0.0.1:3307 using the database name from secrets/db_name.txt, username from secrets/db_user.txt, and password from secrets/db_password.txt. Most of these tools also support configuring SSH tunnels directly in their connection settings.
IMPORTANT: Always stop the bridge when you are finished:
docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools stop db-bridgeAdditional Configuration & Automation
WordPress by default uses a virtual cron (wp-cron.php) that runs asynchronously when visitors browse the site. On high-traffic sites, this wastes PHP worker processes; on low-traffic sites, scheduled tasks (such as publishing posts, checking updates, and background maintenance) may not trigger on time.
Set WORDPRESS_DISABLE_CRON=true in /home/${SITE_USER}/wordpress.env:
sudo nano /home/${SITE_USER}/wordpress.envWORDPRESS_DISABLE_CRON=trueConfiguration examples (full example):
WordPress scheduled events (every 10 minutes):
*/10 * * * * root docker compose -f /home/mysite/docker-compose.yaml run --rm wp-cli cron event run --due-now >/dev/null 2>&1Action Scheduler queue runner (every 5 minutes, recommended for WooCommerce & background task queues):
*/5 * * * * root docker compose -f /home/mysite/docker-compose.yaml run --rm wp-cli action-scheduler run --batches=5 >/dev/null 2>&1Automated database backup (every 6 hours, keeps backups for 7 days):
0 */6 * * * root docker compose -f /home/mysite/docker-compose.yaml run --rm wp-cli db export --single-transaction --quick - | zstd -q > /home/mysite/mariadb-backup/db_backup_$(date +\%Y-\%m-\%d_\%H-\%M-\%S).sql.zst && find /home/mysite/mariadb-backup -type f -name "*.sql.zst" -mtime +7 -deleteNOTE: The automated backup job requires
zstdinstalled on the host (sudo apt update && sudo apt install -y zstd).
Download the template directly into /etc/cron.d/, specifying your unique site identifier in the destination filename (files in /etc/cron.d/ must not contain extensions):
sudo curl -fsSL https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main/examples/etc/cron.d/wordpress-mysite.example \
-o /etc/cron.d/wordpress-${SITE_USER}Replace the placeholder mysite with your unique site identifier (${SITE_USER}):
sudo sed -i "s|mysite|${SITE_USER}|g" /etc/cron.d/wordpress-${SITE_USER}Fine-tune the schedule to your requirements if needed (for example, modify task execution intervals, change backup frequencies, or adjust the backup retention period):
sudo nano /etc/cron.d/wordpress-${SITE_USER}Set permissions:
sudo chmod 0644 /etc/cron.d/wordpress-${SITE_USER}NOTE:
- Tenant Isolation: Placing cron schedules in
/etc/cron.d/wordpress-${SITE_USER}keeps each site's automation isolated.- Security & Permissions: The host executes
docker composeasroot(to access the Docker daemon socket), while the WP-CLI container process inside strictly runs unprivileged under${APP_UID}:${APP_GID}(${SITE_USER}).- Non-Interactive Shell: Cron executes via
/bin/shwithout sourcing user shell configurations like~/.bashrc. Therefore, fulldocker compose -f ...commands must be used instead of shell aliases (wp_mysite).- Cron File Naming: Files in
/etc/cron.d/must contain only alphanumeric characters, underscores, and hyphens (no periods or extensions).
Migration from a Classic Server
Use this guide to move a running WordPress + MariaDB site from a classic LAMP/LEMP host into this stack. You will need: a tar archive of the document root and a MariaDB dump in plain SQL.
IMPORTANT: This stack loads database credentials, database name, and table prefix from Docker secrets, and uses its own secret-driven
wp-config.php. The oldwp-config.phpfile must not be carried over — it is excluded during archiving/extraction.
NOTE:
/old/path/to/site/rootandwordpress.examplethroughout this guide are placeholders for your source server's document root and your site domain. You must replace them with your actual values in all commands.
tar -czf wordpress-files.tar.gz \
--exclude=wp-config.php \
-C /old/path/to/site/root .NOTE: Keep a copy of the old
wp-config.phphandy! If your existing site uses plugins with custom constants or licenses defined inwp-config.php(such as Object Cache ProWP_REDIS_CONFIG, license tokens, SMTP settings, or security plugin constants), you will need to copy those specific definitions into the new configuration in step 3.
Export the site database to a plain SQL file:
mariadb-dump -uroot --single-transaction --quick wp_db_name > site.sql
# Or if a password is required: mariadb-dump -uroot -p --single-transaction --quick wp_db_name > site.sql
# (On older MySQL hosts, use mysqldump instead of mariadb-dump)Provision the host user, directories, secrets, and configuration files in a single pass (see Deployment & Setup for detailed descriptions of each setting, optional messenger channels, and go-notifier alternatives):
SITE_USER=mysite
DATA_DIR=data
WP_CONTENT_DIR=wp-content
REPO="https://raw.githubusercontent.com/webstudiobond/wordpress-docker/main"
# 1. Create system user and directory structure
sudo useradd -m -d /home/${SITE_USER} -s /usr/sbin/nologin ${SITE_USER}
sudo chown root:${SITE_USER} /home/${SITE_USER}
sudo chmod 0750 /home/${SITE_USER}
sudo mkdir -p /home/${SITE_USER}/{.wp-cli,${DATA_DIR}/${WP_CONTENT_DIR}/{mu-plugins,uploads},mariadb,mariadb-backup,secrets,tmp,config/{angie/conf.d,mysql,php,valkey}}
sudo chown -R ${SITE_USER}:${SITE_USER} /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,mariadb-backup,tmp}
sudo chmod 0700 /home/${SITE_USER}/{.wp-cli,${DATA_DIR},mariadb,mariadb-backup,tmp}
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/{config,secrets}
sudo chmod -R 0750 /home/${SITE_USER}/{config,secrets}
# 2. Generate database credentials and authentication salts
sudo apt update && sudo apt install -y pwgen openssl zstd
printf "db_%s" "$(pwgen -s -A 6 1)" | sudo tee /home/${SITE_USER}/secrets/db_name.txt > /dev/null
printf "u_%s" "$(pwgen -s -A 6 1)" | sudo tee /home/${SITE_USER}/secrets/db_user.txt > /dev/null
pwgen -s 64 1 | tr -d '\n' | sudo tee /home/${SITE_USER}/secrets/db_password.txt > /dev/null
pwgen -s 64 1 | tr -d '\n' | sudo tee /home/${SITE_USER}/secrets/db_root_password.txt > /dev/null
for s in auth_key secure_auth_key logged_in_key nonce_key auth_salt secure_auth_salt logged_in_salt nonce_salt; do
openssl rand -base64 48 | sudo tee /home/${SITE_USER}/secrets/${s}.txt > /dev/null
doneSet table_prefix.txt to match the old site's $table_prefix from the old wp-config.php so WordPress recognizes the imported tables, and enter SMTP credentials for go-notifier:
sudo nano /home/${SITE_USER}/secrets/table_prefix.txt
sudo nano /home/${SITE_USER}/secrets/smtp_host.txt
sudo nano /home/${SITE_USER}/secrets/smtp_port.txt
sudo nano /home/${SITE_USER}/secrets/smtp_mail.txt
sudo nano /home/${SITE_USER}/secrets/smtp_password.txt
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/secrets
sudo chmod 0750 /home/${SITE_USER}/secrets
sudo chmod 0440 /home/${SITE_USER}/secrets/*.txtDownload service configurations, Compose manifest, environment files, and mu-plugins:
sudo curl -fsSL ${REPO}/config/php/php-fpm.conf -o /home/${SITE_USER}/config/php/php-fpm.conf
sudo curl -fsSL ${REPO}/config/php/php.ini -o /home/${SITE_USER}/config/php/php.ini
sudo curl -fsSL ${REPO}/config/php/opcache.ini -o /home/${SITE_USER}/config/php/opcache.ini
sudo curl -fsSL ${REPO}/config/php/www.conf -o /home/${SITE_USER}/config/php/www.conf
sudo curl -fsSL ${REPO}/config/mysql/my.cnf -o /home/${SITE_USER}/config/mysql/my.cnf
sudo curl -fsSL ${REPO}/config/valkey/valkey.conf -o /home/${SITE_USER}/config/valkey/valkey.conf
sudo curl -fsSL ${REPO}/config/angie/angie.conf -o /home/${SITE_USER}/config/angie/angie.conf
sudo curl -fsSL ${REPO}/config/angie/mime.types -o /home/${SITE_USER}/config/angie/mime.types
sudo curl -fsSL ${REPO}/config/angie/modules.conf -o /home/${SITE_USER}/config/angie/modules.conf
sudo chown -R root:${SITE_USER} /home/${SITE_USER}/config
sudo find /home/${SITE_USER}/config -type f -exec chmod 0640 {} +
sudo curl -fsSL ${REPO}/docker-compose.yaml -o /home/${SITE_USER}/docker-compose.yaml
sudo curl -fsSL ${REPO}/examples/.env.example -o /home/${SITE_USER}/.env
sudo curl -fsSL ${REPO}/examples/wordpress.env.example -o /home/${SITE_USER}/wordpress.env
sudo curl -fsSL ${REPO}/examples/notifier.env.example -o /home/${SITE_USER}/notifier.env
sudo chmod 0600 /home/${SITE_USER}/{docker-compose.yaml,.env,wordpress.env,notifier.env}
sudo -u ${SITE_USER} curl -fsSL ${REPO}/examples/data/wp-config.php.example -o /home/${SITE_USER}/${DATA_DIR}/wp-config.php
sudo curl -fsSL ${REPO}/mu-plugins/sbs-loader.php \
-o /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php
sudo chown root:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php
sudo chmod 0440 /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.phpConfigure .env (set SITE_USER, APP_UID, APP_GID from id ${SITE_USER}), wordpress.env, notifier.env, and transfer any custom plugin/theme constants from your old wp-config.php into /home/${SITE_USER}/${DATA_DIR}/wp-config.php:
sudo nano /home/${SITE_USER}/.env
sudo nano /home/${SITE_USER}/wordpress.env
sudo nano /home/${SITE_USER}/notifier.env
sudo -u ${SITE_USER} nano /home/${SITE_USER}/${DATA_DIR}/wp-config.phpTransfer wordpress-files.tar.gz to /home/${SITE_USER}/ and the SQL dump site.sql into /home/${SITE_USER}/mariadb-backup/ (e.g. via scp/rsync).
sudo -u ${SITE_USER} tar -xzf /home/${SITE_USER}/wordpress-files.tar.gz \
-C /home/${SITE_USER}/${DATA_DIR} \
--exclude=wp-config.php
sudo chown -R ${SITE_USER}:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}
sudo chmod 0700 /home/${SITE_USER}/${DATA_DIR}
sudo find /home/${SITE_USER}/${DATA_DIR} -mindepth 1 -type d -exec chmod 0750 {} +
[ -d /home/${SITE_USER}/${DATA_DIR}/wp-admin ] && sudo chmod 0700 /home/${SITE_USER}/${DATA_DIR}/wp-admin
[ -d /home/${SITE_USER}/${DATA_DIR}/wp-includes ] && sudo chmod 0700 /home/${SITE_USER}/${DATA_DIR}/wp-includes
sudo find /home/${SITE_USER}/${DATA_DIR} -type f -exec chmod 0640 {} +
[ -f /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php ] && sudo chown root:${SITE_USER} /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php && sudo chmod 0440 /home/${SITE_USER}/${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.phpOn a classic host, WordPress was located at an arbitrary path (/old/path/to/site/root). In this containerized stack, the WordPress root is always /var/www/html.
Before starting the containers, update all absolute filesystem paths inside .php, .json, .ini, .txt, .conf, and .xml files:
# Standard filesystem paths
find /home/${SITE_USER}/${DATA_DIR} -type f \( -name "*.php" -o -name "*.json" -o -name "*.ini" -o -name "*.txt" -o -name "*.conf" -o -name "*.xml" \) -exec sed -i 's|/old/path/to/site/root|/var/www/html|g' {} +
# JSON-escaped paths (common in security rewrite rules and serialized config files)
find /home/${SITE_USER}/${DATA_DIR} -type f \( -name "*.php" -o -name "*.json" -o -name "*.ini" -o -name "*.txt" -o -name "*.conf" -o -name "*.xml" \) -exec sed -i 's|\\/old\\/path\\/to\\/site\\/root|\\/var\\/www\\/html|g' {} +docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools pull
docker compose -f /home/${SITE_USER}/docker-compose.yaml up -dOn first start MariaDB creates the database and user from the secrets, and the init service synchronizes the WordPress core (wp-admin/, wp-includes/, root files) to the image version — old core files from the archive get upgraded automatically, user data and wp-content/ are never touched.
WARNING:
/home/${SITE_USER}/mariadbmust be empty at the first start, otherwise the database and user from the secrets will not be created. If a previous experiment already initialized it, clearmariadb/before proceeding.
You can import the database using either WP-CLI or your preferred desktop database client:
Stream the plain SQL dump directly into MariaDB:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm -T wp-cli db import - < /home/${SITE_USER}/mariadb-backup/site.sqlIf you prefer a graphical interface:
- Start the database bridge as described in DB Bridge:
docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools up -d db-bridge - Establish the SSH tunnel and connect your client (DataGrip, DBeaver, TablePlus) to
127.0.0.1:3307. - Execute or import
site.sqlinto the database named insecrets/db_name.txt. - Stop the bridge when finished:
docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools stop db-bridge
The database often stores old filesystem paths in serialized plugin settings, widget caches, and options. Use WP-CLI to safely perform search-and-replace across all tables, then delete transients and flush the object cache:
# Dry run to preview changes
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli search-replace '/old/path/to/site/root' '/var/www/html' --all-tables --dry-run
# Apply changes to all tables
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli search-replace '/old/path/to/site/root' '/var/www/html' --all-tables
# Clear transients, flush object cache, and regenerate rewrite rules
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli transient delete --all
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli cache flush
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli rewrite flushIf the site keeps the same domain and scheme, skip this step: all URLs in the dump remain valid.
If the domain changed or if upgrading from plain HTTP to HTTPS, check the current database URLs:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli option get siteurl
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli option get homeThen update both WordPress options and rewrite URLs across all tables:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli option update home 'https://wordpress.example'
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli option update siteurl 'https://wordpress.example'
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli search-replace 'http://old.wordpress.example' 'https://wordpress.example' --all-tables --skip-columns=guidIf the site uses an object cache drop-in (object-cache.php), verify that it connects to Valkey:
# Check status (works for both Redis Object Cache and Object Cache Pro):
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli redis status
# Detailed diagnostics (Object Cache Pro):
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli redis diagnosticsConfigure the domain virtual host in the host's Edge reverse proxy to route traffic to ${SITE_USER}_angie (see External Angie).
TIP: Pre-cutover Testing: You can safely test proxy routing and WordPress response directly on the server before pointing public DNS:
curl -k -I --resolve wordpress.example:443:127.0.0.1 https://wordpress.example(Replace wordpress.example with your actual domain). Once verified, point your domain's DNS A/AAAA records to the server IP. Edge Angie's native ACME will automatically negotiate and issue the Let's Encrypt TLS certificate as soon as DNS traffic arrives.
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli core version
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli db check
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli plugin list
curl -I https://wordpress.exampleCheck the homepage, media assets, and the admin dashboard in your browser. Old .htaccess files from the archive are inert — the stack routes requests through the Angie proxy, not Apache.
Administrator User ID Migration
On freshly installed WordPress sites, the primary administrative account is assigned ID = 1 by default. If a migrated site still retains ID = 1 for its main administrator, automated vulnerability scanners, brute-force bots, and user enumeration tools will systematically probe ID = 1 and author archive endpoints (?author=1). Changing the default administrator ID to a random, non-trivial integer is an effective post-installation or post-migration recommendation.
WARNING: The target ID '84920' in the examples below is strictly illustrative. NEVER use this exact ID on a production site! Always generate your own unique, random numeric ID (recommended: 5–8 digits).
Always create a quick database dump before running migration operations:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm wp-cli db export --single-transaction --quick - > /home/${SITE_USER}/mariadb-backup/pre_id_migration.sqlIf anything goes wrong, restore the database from this dump:
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm -T wp-cli db import - < /home/${SITE_USER}/mariadb-backup/pre_id_migration.sqlThe migration script dynamically inspects information_schema.COLUMNS across all tables matching the site's table prefix ($wpdb->prefix) for foreign key and user reference columns (user_id, post_author, link_owner, and users.ID). It verifies that the source user exists, ensures the target ID is not already taken, and counts matching records across all discovered tables.
Run the inspection in Dry Run mode (DRY_RUN=true):
OLD_ID=1 NEW_ID=84920 DRY_RUN=true \
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm --no-deps \
-e OLD_ID -e NEW_ID -e DRY_RUN wp-cli eval '
global $wpdb;
$old = (int) getenv("OLD_ID");
$new = (int) getenv("NEW_ID");
$dry = filter_var(getenv("DRY_RUN") ?: "true", FILTER_VALIDATE_BOOLEAN);
if ($old <= 0 || $new <= 0 || $old === $new) {
WP_CLI::error("OLD_ID and NEW_ID must be positive distinct integers.");
}
$user = $wpdb->get_row($wpdb->prepare(
"SELECT ID, user_login FROM `{$wpdb->users}` WHERE ID = %d",
$old
));
if (!$user) {
WP_CLI::error("Source user with ID {$old} does not exist.");
}
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM `{$wpdb->users}` WHERE ID = %d",
$new
));
if ($exists) {
WP_CLI::error("Target ID {$new} is already taken by another user!");
}
$columns = $wpdb->get_results($wpdb->prepare(
"SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE %s
AND (
COLUMN_NAME IN (\"user_id\", \"post_author\", \"link_owner\")
OR (TABLE_NAME = %s AND COLUMN_NAME = \"ID\")
)
ORDER BY TABLE_NAME ASC, COLUMN_NAME ASC",
$wpdb->esc_like($wpdb->prefix) . "%",
$wpdb->users
));
if (!$columns) {
WP_CLI::error("Failed to discover database tables.");
}
$records = [];
$total_records = 0;
foreach ($columns as $col) {
$table = $col->TABLE_NAME;
$column = $col->COLUMN_NAME;
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = %d",
$old
));
if ($count > 0) {
$records[] = [
"table" => $table,
"column" => $column,
"count" => $count,
];
$total_records += $count;
}
}
WP_CLI::log(sprintf("User \"%s\" (ID %d -> %d):", $user->user_login, $old, $new));
foreach ($records as $item) {
WP_CLI::log(sprintf("- %s.%s: %d record(s)", $item["table"], $item["column"], $item["count"]));
}
WP_CLI::log(sprintf("Total: %d record(s) across %d table(s).", $total_records, count($records)));
if ($dry) {
WP_CLI::warning("DRY RUN: No database changes were applied. Set DRY_RUN=false to execute.");
return;
}
$wpdb->query("START TRANSACTION");
foreach ($records as $item) {
$wpdb->update(
$item["table"],
[$item["column"] => $new],
[$item["column"] => $old]
);
}
$wpdb->query("COMMIT");
wp_cache_flush();
WP_CLI::success(sprintf(
"Successfully migrated User ID from %d to %d (%d records updated). Object cache flushed.",
$old,
$new,
$total_records
));
'Example output:
User "site_admin" (ID 1 -> 84920):
- wp_comments.user_id: 1 record(s)
- wp_posts.post_author: 2 record(s)
- wp_usermeta.user_id: 28 record(s)
- wp_users.ID: 1 record(s)
Total: 32 record(s) across 4 table(s).
Warning: DRY RUN: No database changes were applied. Set DRY_RUN=false to execute.
After reviewing the dry-run summary, execute the migration by setting DRY_RUN=false:
OLD_ID=1 NEW_ID=84920 DRY_RUN=false \
docker compose -f /home/${SITE_USER}/docker-compose.yaml run --rm --no-deps \
-e OLD_ID -e NEW_ID -e DRY_RUN wp-cli eval '
global $wpdb;
$old = (int) getenv("OLD_ID");
$new = (int) getenv("NEW_ID");
$dry = filter_var(getenv("DRY_RUN") ?: "true", FILTER_VALIDATE_BOOLEAN);
if ($old <= 0 || $new <= 0 || $old === $new) {
WP_CLI::error("OLD_ID and NEW_ID must be positive distinct integers.");
}
$user = $wpdb->get_row($wpdb->prepare(
"SELECT ID, user_login FROM `{$wpdb->users}` WHERE ID = %d",
$old
));
if (!$user) {
WP_CLI::error("Source user with ID {$old} does not exist.");
}
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT ID FROM `{$wpdb->users}` WHERE ID = %d",
$new
));
if ($exists) {
WP_CLI::error("Target ID {$new} is already taken by another user!");
}
$columns = $wpdb->get_results($wpdb->prepare(
"SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE %s
AND (
COLUMN_NAME IN (\"user_id\", \"post_author\", \"link_owner\")
OR (TABLE_NAME = %s AND COLUMN_NAME = \"ID\")
)
ORDER BY TABLE_NAME ASC, COLUMN_NAME ASC",
$wpdb->esc_like($wpdb->prefix) . "%",
$wpdb->users
));
if (!$columns) {
WP_CLI::error("Failed to discover database tables.");
}
$records = [];
$total_records = 0;
foreach ($columns as $col) {
$table = $col->TABLE_NAME;
$column = $col->COLUMN_NAME;
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = %d",
$old
));
if ($count > 0) {
$records[] = [
"table" => $table,
"column" => $column,
"count" => $count,
];
$total_records += $count;
}
}
WP_CLI::log(sprintf("User \"%s\" (ID %d -> %d):", $user->user_login, $old, $new));
foreach ($records as $item) {
WP_CLI::log(sprintf("- %s.%s: %d record(s)", $item["table"], $item["column"], $item["count"]));
}
WP_CLI::log(sprintf("Total: %d record(s) across %d table(s).", $total_records, count($records)));
if ($dry) {
WP_CLI::warning("DRY RUN: No database changes were applied. Set DRY_RUN=false to execute.");
return;
}
$wpdb->query("START TRANSACTION");
foreach ($records as $item) {
$wpdb->update(
$item["table"],
[$item["column"] => $new],
[$item["column"] => $old]
);
}
$wpdb->query("COMMIT");
wp_cache_flush();
WP_CLI::success(sprintf(
"Successfully migrated User ID from %d to %d (%d records updated). Object cache flushed.",
$old,
$new,
$total_records
));
'The migration updates all matching records inside an atomic ACID transaction (START TRANSACTION ... COMMIT) and immediately flushes the object cache (wp_cache_flush()). Existing sessions for the user will be invalidated, requiring a fresh login with the updated account.
Log in to the WordPress administration dashboard using the existing credentials to confirm that user administration, profile settings, and content authoring function normally. Once verified, delete the temporary safety database dump:
rm -f /home/${SITE_USER}/mariadb-backup/pre_id_migration.sqlDevelopment
Guidelines for local environment setup, container image builds, code quality gates (Go & PHP), testing workflows, and Makefile targets are documented in the Development Guide.
License & Attribution
- This repository and deployment architecture are licensed under the MIT License.
- WordPress License & Ownership: WordPress is free, open-source software licensed under the GNU General Public License v2 or later (GPLv2+) and belongs to WordPress and the WordPress Foundation. This project is an independent containerized deployment architecture and is not affiliated with, endorsed, or sponsored by WordPress or the WordPress Foundation.