Skip to content

About

Hardened WordPress stack (shell-less PHP-FPM, MariaDB, Valkey, Angie). Features zero-privilege execution, UNIX sockets, strict Docker secrets, WP-CLI, object cache, WebP/AVIF, and a read-only root filesystem with writable mounts to preserve native UI updates for plugins and media. Multi-tenant ready with atomic core upgrades.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Hardened Containerized WordPress

CI GitHub last commit GitHub issues GitHub repo size License: MIT

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.

Architecture Highlights

  • 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.php between the image donor and the live site. On version mismatch it atomically replaces wp-admin/, wp-includes/, and root PHP files without ever touching wp-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.env to 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

Prerequisites

  • 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 as external: 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 without angie-docker-compose, create the network manually:
docker network create --subnet=172.20.0.0/16 frontend_gateway

1. Create a Dedicated System User

Create 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}

2. Create Directory Structure

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}

3. Generate Secrets

Install the required tools:

sudo apt update && sudo apt install -y pwgen openssl

All 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/null

Authentication 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
done

Lock secret files:

sudo chown root:${SITE_USER} /home/${SITE_USER}/secrets/*.txt
sudo chmod 0440 /home/${SITE_USER}/secrets/*.txt

4. Download Configuration Files

REPO="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 {} +

5. Download Compose Manifest and Application Config

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.php

6. Configure Environment

Edit .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}/.env

See 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.env

See examples/wordpress.env.example for all available overrides and the official wp-config.php documentation for detailed parameter descriptions.

7. Mail & Push Notifications (go-notifier)

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.

Option A: Enable go-notifier (Default & Recommended)

  1. Enter SMTP server credentials: Securely enter your SMTP server hostname (e.g. smtp.example.com), port (587 or 465), sender email / username, and password using nano without 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. -1001234567890 or 123456789).
    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.com or https://synapse.example.com:8448). NOTE: go-notifier does not query .well-known/matrix/client; specify the direct homeserver endpoint, not the root organization domain. Unsure? Check https://matrix.org/.well-known/matrix/client and use the base_url.
    • matrix_room_id.txt — Internal room ID (e.g. !abcdef:matrix.example.com or !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.sh or https://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.txt

    Lock 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
  2. Download and configure routing (notifier.env): Configure active notification channels (NOTIFY_CHANNELS), rate limits, and custom subject regex routing rules (NOTIFY_RULE_<NAME>_*). See examples/notifier.env.example for 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
  3. Install the SBS Platform Loader: Download sbs-loader.php, which dynamically and securely loads platform MU-plugins (including wp-notify.php by 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.php is mounted read-only into the container. It loads wp-notify.php from /usr/local/share/wordpress/mu-plugins/ to intercept all wp_mail() calls and dispatch them to /var/run/sockets/notify/notify.sock.

Option B: Disable go-notifier (Alternative)

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:

  1. Edit docker-compose.yaml: Comment out the notifier service block, the sockets_notify volume mounts in wordpress and wp-cli, the sockets_notify definition in volumes:, and the 4 smtp_* secret definitions in the root secrets: section:
    sudo nano /home/${SITE_USER}/docker-compose.yaml
  2. Disable notify in wordpress.env: Set WP_ENABLED_MU_PLUGINS=none (or omit notify from your custom slug list in WP_ENABLED_MU_PLUGINS) and do not create notifier.env.

8. Start the Stack

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 -d

On 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 stop

To stop and remove containers and networks:

docker compose -f /home/${SITE_USER}/docker-compose.yaml down

Must-Use (MU) Plugins

Optional Must-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.php

sbs-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.

Enabling Platform Plugins via Environment

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.

Available Plugins & Documentation

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

Tuning PHP & Container 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):

  1. wordpress.env — WORDPRESS_MEMORY_LIMIT (standard frontend limit) and WORDPRESS_MAX_MEMORY_LIMIT (administration and WP-CLI ceiling).
  2. docker-compose.yaml — mem_limit under the wordpress (and wp-cli) service definition.
  3. config/php/www.conf — FPM worker pool directives (pm.max_children) and memory_limit in config/php/php.ini.

PHP Hardening & WAF Prepend (.user.ini & Wordfence)

PHP Hardening & WAF Prepend Architecture

Intentional Disabling of .user.ini

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 wordpress

Wordfence WAF Integration (Zero .user.ini Mode)

Web 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

Host Permissions & Privilege Separation

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 — 0600
    • mariadb-backup/ — 0700
  • root:${SITE_USER} (Read-Only Service Configs, Secrets, Platform Loader & Snippets):
    • config/ — directories 0750, files 0640 (mounted :ro into containers)
    • secrets/ — directory 0750, secret files 0440 (containers have group read-only access and cannot chmod or overwrite files)
    • snippets/ (optional) — directory 0750, snippet files 0640 (mounted :ro,noexec,nosuid,nodev at /etc/wordpress/snippets)
    • ${DATA_DIR}/${WP_CONTENT_DIR}/mu-plugins/sbs-loader.php — 0440 (mounted :ro into containers)
  • ${SITE_USER}:${SITE_USER} (Runtime Writable Storage):
    • .wp-cli/, mariadb/, tmp/ — 0700
    • ${DATA_DIR}/ — top-level directory 0700, wp-admin/ and wp-includes/ 0700, content directories (${WP_CONTENT_DIR}/) 0750, internal files 0640

One-Shot Permission Reset (Running Stack)

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.php

External Angie

Edge Reverse Proxy (External 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}.conf

WARNING: 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}.conf

Review 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}.conf

WP-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-cli is the Docker Compose service name whose container entrypoint directly executes the wp binary. Consequently, you pass subcommands directly without typing wp again.

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 --all

TIP: When the stack is already running, appending --no-deps (run --rm --no-deps wp-cli ...) skips dependency polling and health checks for mariadb and valkey, making commands execute instantaneously. If the stack is stopped or starting, omit --no-deps so Docker Compose automatically starts dependencies first.

Shell Alias for Interactive Use

For convenience in interactive SSH sessions, you can define a shell function per site in ~/.bashrc:

nano ~/.bashrc
wp_mysite() {
    docker compose -f /home/mysite/docker-compose.yaml run --rm wp-cli "$@"
}
source ~/.bashrc

For 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 list

NOTE: Functions and aliases defined in ~/.bashrc are only loaded in interactive login shells. System cron jobs and non-interactive scripts execute via /bin/sh without loading ~/.bashrc, and must always use the full docker compose -f ... command.

Common Administrative Tasks

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 --all

Package 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 list

Maintenance 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 deactivate

Cache & 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 flush

Action Scheduler queue cleanup:

# Clean completed and failed actions from the Action Scheduler queue
wp_mysite action-scheduler clean --batches=20

Manual Database Backup & Restore

Create 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.zst

Or 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.zst

To 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 -T flag disables pseudo-TTY allocation in Docker Compose, ensuring the piped SQL stream passes through stdin reliably without corruption or terminal escape sequences.

CLI Host & URL Context

If your site or plugins rely on HTTP_HOST for dynamic URLs, configure the URL context using either:

  1. wp-cli.yml (recommended):
    sudo -u ${SITE_USER} nano /home/${SITE_USER}/${DATA_DIR}/wp-cli.yml
    url: https://wordpress.example
  2. WORDPRESS_CLI_HOST: Set WORDPRESS_CLI_HOST=wordpress.example in wordpress.env.

DB Bridge

Remote Database Access (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-bridge

Establish an SSH tunnel from your local machine:

ssh -i ~/.ssh/id_ed25519 -p 22 -L 3307:127.0.0.1:3307 user@your-server-ip

Connect 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-bridge

Additional Configuration & Automation

Server Cron & Automated Backups

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.

1. Disable Virtual Web Cron

Set WORDPRESS_DISABLE_CRON=true in /home/${SITE_USER}/wordpress.env:

sudo nano /home/${SITE_USER}/wordpress.env
WORDPRESS_DISABLE_CRON=true

2. Configure Dedicated System Cron

Configuration 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>&1

Action 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>&1

Automated 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 -delete

NOTE: The automated backup job requires zstd installed on the host (sudo apt update && sudo apt install -y zstd).

Pre-configured Cron File

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 compose as root (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/sh without sourcing user shell configurations like ~/.bashrc. Therefore, full docker 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

Migrating an Existing Site (Bare-Metal → Containerized)

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 old wp-config.php file must not be carried over — it is excluded during archiving/extraction.

1. On the source server — archive the files

NOTE: /old/path/to/site/root and wordpress.example throughout 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.php handy! If your existing site uses plugins with custom constants or licenses defined in wp-config.php (such as Object Cache Pro WP_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.

2. On the source server — dump the database

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)

3. On the new host — prepare deployment and secrets

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
done

Set 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/*.txt

Download 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.php

Configure .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.php

Transfer 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).

4. Extract the archive and fix ownership

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.php

5. Replace old filesystem paths in files

On 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' {} +

6. 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 -d

On 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}/mariadb must 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, clear mariadb/ before proceeding.

7. Import the database dump

You can import the database using either WP-CLI or your preferred desktop database client:

Option A: Import via WP-CLI (recommended)

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.sql

Option B: Import via Desktop DB Client (DB Bridge)

If you prefer a graphical interface:

  1. Start the database bridge as described in DB Bridge:
    docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools up -d db-bridge
  2. Establish the SSH tunnel and connect your client (DataGrip, DBeaver, TablePlus) to 127.0.0.1:3307.
  3. Execute or import site.sql into the database named in secrets/db_name.txt.
  4. Stop the bridge when finished:
    docker compose -f /home/${SITE_USER}/docker-compose.yaml --profile tools stop db-bridge

8. Replace old filesystem paths in the database and flush caches

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 flush

9. Update Site URL (only if changed)

If 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 home

Then 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=guid

10. Verify Object Cache (if used)

If 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 diagnostics

11. Configure Edge Reverse Proxy & DNS

Configure 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.

12. Verification

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.example

Check 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

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).

Step 0: Create a Temporary Safety Backup

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.sql

If 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.sql

Step 1: Automated Discovery & Dry Run

The 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.

Step 2: Execute Migration

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.

Step 3: Verify & Cleanup

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.sql

Development

Development & Contributing

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

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.

About

Hardened WordPress stack (shell-less PHP-FPM, MariaDB, Valkey, Angie). Features zero-privilege execution, UNIX sockets, strict Docker secrets, WP-CLI, object cache, WebP/AVIF, and a read-only root filesystem with writable mounts to preserve native UI updates for plugins and media. Multi-tenant ready with atomic core upgrades.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages