Case Study: WordPress

Case Study: WordPress

Bitnesia Sep 13, 2026 9 ID

WordPress is still the most widely used content management system in the world, and many Sysadmin/DevOps Engineers eventually deal with migrating legacy WordPress sites from shared hosting to container-based infrastructure. Unlike custom application case studies such as Laravel or Django, WordPress has distinct characteristics: the core source code is rarely modified, but the wp-content folder containing themes, plugins, and media uploads changes continuously, often directly from within the admin dashboard itself. This characteristic makes the WordPress containerization strategy somewhat different from applications whose entire code base is managed via Git. This chapter covers a complete case study: from setting up WordPress with a MySQL/MariaDB database, managing persistent storage for media and plugins, adding a reverse proxy with SSL/TLS termination, to backup and data migration strategies for WordPress across environments.

32.1 Setting Up WordPress with MySQL/MariaDB

Docker provides an official wordpress image that already packages Apache and PHP into a single container, so Developers and Sysadmin/DevOps Engineers do not need to build a Dockerfile from scratch for standard use cases. This image requires a connection to a separately running MySQL or MariaDB database, following the same multi-container architecture pattern as other application case studies.

32.1.1 Basic Compose Structure

Create a compose.yaml file with two services: db for the database and wordpress for the application.

services:
  db:
    image: mariadb:11.4
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: secret
      MARIADB_ROOT_PASSWORD: rootsecret
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      start_period: 60s
      retries: 3

  wordpress:
    image: wordpress:6.7-apache
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: secret
    volumes:
      - wp-content:/var/www/html/wp-content
    depends_on:
      db:
        condition: service_healthy

volumes:
  db-data:
  wp-content:

The official mariadb image provides a built-in healthcheck script named healthcheck.sh specifically designed to verify MariaDB readiness. The --innodb_initialized option ensures that the health check only marks the container as healthy after the InnoDB storage engine initialization process has completely finished, not just after the mariadbd process begins running. The start_period: 60s option provides an initial grace period before health check failures count toward failed attempts, which is important because the database initialization process during initial container creation can take longer than subsequent starts. Combining depends_on with condition: service_healthy on the wordpress service ensures WordPress only attempts to connect after the database is ready to accept connections, preventing connection errors that frequently occur when the database container is still in its initial initialization process.

The WORDPRESS_DB_HOST variable uses the db service name along with port 3306, rather than localhost, because Docker Compose provides internal DNS resolution between services based on their service names within the same internal network. A common trap for developers dockerizing WordPress for the first time is continuing to write localhost here, a habit carried over from shared hosting WordPress installations where the database and application run on the same machine.

32.1.2 Running and Completing Installation

Run both services in detached mode.

docker compose up -d

Monitor the startup process through logs, particularly to ensure WordPress successfully connects to the database.

docker compose logs -f wordpress

Open http://localhost:8080 in a browser to complete the installation process via the WordPress setup wizard, ranging from language selection to administrator account creation. This process is identical to a conventional WordPress installation because the container only provides the runtime environment, while the installation flow is still handled by WordPress itself.

32.1.3 Verifying Database Connection

If an "Error establishing a database connection" message appears in the browser, check first whether the db container status is healthy.

docker compose ps

If the status is still starting or unhealthy, inspect the MariaDB logs to review error details.

docker compose logs db

Another common cause encountered in the field is mismatched values between MARIADB_USER and WORDPRESS_DB_USER, such as capitalization differences or an accidental trailing space when copying from another environment file. Also ensure that the db-data volume does not contain leftover data from different credentials, as the MariaDB initialization script only creates a new user and database when the volume is completely empty. If you need to start fresh during the experimentation phase, remove the volume first.

docker compose down
docker volume rm docker_db-data

This docker volume rm command is destructive and permanently deletes all database data, making it safe to run only in development environments or when intentionally restarting an installation from scratch, not in production environments storing actual data.

32.2 Persistent Storage for Media and Plugins

WordPress stores three types of data that must persist even if its container is rebuilt or moved to another host: data in the database, files uploaded via the media library, and installed theme and plugin files. Mismanaging this section is the most common cause of WordPress data loss during containerization, especially for data modified via the admin dashboard that is never touched by Git-based deployment processes.

32.2.1 Named Volume vs Bind Mount for wp-content

The configuration in the previous section used a single named volume for the entire wp-content directory, including themes, plugins, and uploads simultaneously. This approach is the simplest and suitable if WordPress is managed entirely through the admin dashboard, including installing new plugins and themes directly from there.

If themes and plugins are managed through code committed to Git instead, such as for custom themes developed in-house, bind mounts become the more appropriate choice for those folders, while the uploads folder continues to use a named volume because its contents are purely runtime data irrelevant to store in the repository.

services:
  wordpress:
    image: wordpress:6.7-apache
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: secret
    volumes:
      - ./wp-content/themes:/var/www/html/wp-content/themes
      - ./wp-content/plugins:/var/www/html/wp-content/plugins
      - wp-uploads:/var/www/html/wp-content/uploads
    depends_on:
      db:
        condition: service_healthy

volumes:
  db-data:
  wp-uploads:

This combination strictly separates code that can be verified via code review and version controlled from upload data that is purely operational and typically much larger than theme or plugin source code. Note that this option requires discipline: if a plugin is installed directly through the admin dashboard while the plugins folder uses a bind mount to the host, those changes immediately appear on the host and must be manually synchronized to the Git repository if they are to be preserved.

32.2.2 File Permissions and Ownership

The official wordpress image runs Apache as the www-data user, and the PHP processes inside require write permissions to the wp-content folder for media uploads, plugin installations, and auto-update processes. If using bind mounts as shown in the previous section, the host directories might have different ownership than www-data inside the container, depending on the user ID that created the directory on the host.

Inspect the ownership of the wp-content folder inside the container.

docker compose exec wordpress ls -la /var/www/html/wp-content

If bind-mounted folders show ownership of a user ID different from www-data and WordPress fails to write files with permission errors in the log, adjust the ownership of those folders directly on the host to match the www-data user ID inside the image (typically UID 33 on the Debian base image used by the official wordpress image).

sudo chown -R 33:33 ./wp-content

In practice, a trap often encountered by Sysadmin/DevOps Engineers is running containers with a custom user for security reasons, but forgetting to adjust bind mount directory ownership, causing WordPress to fail completely when writing files even though the container starts without errors. Always verify write permissions by attempting to upload a small image via the media library after making such permission changes.

32.2.3 Volume Backup as Part of Storage Strategy

Because wp-uploads and db-data store the only copy of data without duplicates elsewhere, both volumes must be included in routine backup strategies rather than relied upon solely as temporary storage. Verify the size of data stored in named volumes via docker system df -v to estimate backup capacity requirements.

docker system df -v

The SIZE column in this command's output indicates the total data size stored in each volume, a metric useful for determining backup storage capacity requirements, whether on a separate disk, object storage, or another backup server. In practice, the wp-uploads volume size on long-running sites is usually much larger than database dumps, especially if old media has never been cleaned up or compressed.

32.3 Reverse Proxy and SSL/TLS Termination

WordPress containers from the official image only serve plain HTTP over port 80, whereas production WordPress sites must be accessed via HTTPS. The standard pattern is placing Nginx as a reverse proxy, an intermediate server that receives all external requests and forwards them to the WordPress container behind it. Nginx handles SSL/TLS termination in this pattern, while traffic to WordPress remains plain HTTP within Docker's internal network.

32.3.1 Nginx as a Reverse Proxy

Add a proxy service that forwards requests to the wordpress service using its service name rather than localhost, as both are connected via Compose's internal network.

server {
    listen 443 ssl;
    server_name blog.example.com;

    ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;

    client_max_body_size 64m;

    location / {
        proxy_pass http://wordpress:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name blog.example.com;
    return 301 https://$host$request_uri;
}

The X-Forwarded-Proto header is the most crucial part of this configuration. The official wordpress image includes built-in mechanisms that read this header to detect that the original client connection used HTTPS, even though traffic forwarded to the WordPress container itself is plain HTTP. Without this header, WordPress misdetects the connection protocol and generates URLs starting with http:// across all pages, including assets like CSS and JavaScript, which ultimately triggers mixed content warnings in visitor browsers. The client_max_body_size 64m directive must match the upload limit configured in WordPress's php.ini, as Nginx will reject requests early with a 413 Request Entity Too Large error if its limit is lower than expected before the request reaches WordPress.

Define the proxy service in compose.yaml, with SSL certificates mounted read-only from the host.

services:
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs/etc:/etc/letsencrypt:ro
    depends_on:
      - wordpress

  wordpress:
    image: wordpress:6.7-apache
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: secret
      WORDPRESS_CONFIG_EXTRA: |
        define('FORCE_SSL_ADMIN', true);
    volumes:
      - wp-content:/var/www/html/wp-content
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mariadb:11.4
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: secret
      MARIADB_ROOT_PASSWORD: rootsecret
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      start_period: 60s
      retries: 3

volumes:
  db-data:
  wp-content:

Notice that ports 80 and 443 are mapped only on the proxy service. The wordpress service continues listening on port 80 internally but is not exposed directly to the host, ensuring the only external entry point for traffic is through Nginx. The WORDPRESS_CONFIG_EXTRA variable is used to inject additional PHP constants into the wp-config.php generated automatically by this image, and the FORCE_SSL_ADMIN constant in the example above forces all WordPress login pages and admin dashboards to be accessed via HTTPS.

32.3.2 Let's Encrypt Certificates with Certbot

For free, automatically renewed SSL certificates, a common pattern is running a separate Certbot container that shares the certificate volume with Nginx. Run Certbot in standalone mode first for initial certificate issuance before Nginx actively uses port 443.

docker run --rm -p 80:80 \
  -v "$(pwd)/certs/etc:/etc/letsencrypt" \
  -v "$(pwd)/certs/lib:/var/lib/letsencrypt" \
  certbot/certbot certonly --standalone \
  -d blog.example.com \
  --email [email protected] \
  --agree-tos --no-eff-email

The /etc/letsencrypt directory must be mapped entirely, not just its live subfolder, because Certbot stores actual certificates in the archive directory and places symbolic links pointing to them inside live. If the archive folder is not mapped to the host, symbolic links in live break once the Certbot container stops and its temporary volume disappears. The resulting ./certs/etc folder issued by Certbot is mapped entirely to /etc/letsencrypt inside the Nginx container, keeping the ssl_certificate and ssl_certificate_key paths used by Nginx consistent with Certbot's original folder structure. Let's Encrypt certificates are valid for 90 days, so renewal must be scheduled periodically, such as via a cron job on the host running certbot renew with the same volume parameters as the initial issuance command, followed by reloading Nginx after new certificates are issued so visitors do not encounter expired certificate warnings.

32.3.3 HTTPS Verification and Mixed Content Troubleshooting

Verify that SSL certificates are installed correctly using curl.

curl -vI https://blog.example.com 2>&1 | grep -i "SSL certificate"

If the site is accessible over HTTPS but some assets still load via http://, the most common cause is that the siteurl and home values in the WordPress database are still saved as http:// from before HTTPS was enabled. Check both values using WP-CLI.

docker compose exec wordpress wp option get siteurl --allow-root
docker compose exec wordpress wp option get home --allow-root

If the output still uses http://, update both to consistently use https://.

docker compose exec wordpress wp option update siteurl https://blog.example.com --allow-root
docker compose exec wordpress wp option update home https://blog.example.com --allow-root

The official wordpress image does not include WP-CLI by default. WP-CLI is the official command-line interface for managing WordPress via the terminal, covering operations like modifying options, running database migrations, and installing plugins without opening the admin dashboard in a browser. The command above only works if WP-CLI has been added to the image via a custom Dockerfile or executed through a separate WP-CLI container sharing the wp-content volume and network with the wordpress service.

32.4 WordPress Data Backup and Migration

A comprehensive WordPress backup must cover two components simultaneously: a database dump storing all content, settings, and metadata, as well as an archive of the wp-content folder containing media, themes, and plugins. Missing either component makes full site restoration impossible.

32.4.1 Database Backup with mysqldump

Run mysqldump directly inside the database container to export full database contents to a SQL file.

docker compose exec db sh -c 'exec mysqldump -u wordpress -psecret wordpress' > backup-db-$(date +%Y%m%d).sql

The exec command inside sh -c ensures the mysqldump process receives signals directly from docker compose exec, with output redirected to a file on the host using standard redirection. For large databases, add the --single-transaction flag to perform the dump within a single consistent transaction without locking tables completely, a practice recommended by official MySQL documentation specifically for tables using the InnoDB storage engine as WordPress does by default.

docker compose exec db sh -c 'exec mysqldump --single-transaction -u wordpress -psecret wordpress' > backup-db-$(date +%Y%m%d).sql

32.4.2 Backing Up the wp-content Directory

Archive the contents of the wp-content named volume using a temporary container mounting the volume as read-only, then run tar to package it into a single file.

docker run --rm \
  -v docker_wp-content:/wp-content:ro \
  -v "$(pwd)":/backup \
  alpine tar czf /backup/backup-wp-content-$(date +%Y%m%d).tar.gz -C /wp-content .

This approach does not alter volume contents at all (mounted read-only) and does not require modifying the running wordpress service configuration, allowing backups to run anytime without disturbing visiting users.

32.4.3 Restoring to a New Environment

For migration to a new server or environment, prepare the db and wordpress services using the same compose.yaml as the source, but do not start them until old data restoration finishes.

Copy the wp-content archive into the new named volume.

docker run --rm \
  -v docker_wp-content:/wp-content \
  -v "$(pwd)":/backup \
  alpine sh -c "tar xzf /backup/backup-wp-content-20260101.tar.gz -C /wp-content"

Start the db service first, wait until its status is healthy, then import the SQL dump into it.

docker compose up -d db
docker compose exec -T db sh -c 'exec mysql -u wordpress -psecret wordpress' < backup-db-20260101.sql

The -T option on docker compose exec disables pseudo-TTY allocation, a required step when streaming file contents through standard input during import commands, as an interactive TTY can interfere with streaming binary or large text data into the container.

32.4.4 Domain Migration with wp search-replace

WordPress stores site URLs hardcoded in multiple locations within the database, including post content and specific settings, not just in the siteurl and home options. If migration involves changing domains, such as moving from staging.example.com to blog.example.com, a database dump alone is insufficient because all old URL references remain embedded within it.

The wp search-replace command from WP-CLI is designed specifically for this scenario, safely replacing all occurrences of an old string with a new string across all database tables.

docker compose exec wordpress wp search-replace \
  'https://staging.example.com' 'https://blog.example.com' \
  --skip-columns=guid --allow-root

The --skip-columns=guid option is intentionally used to skip the guid column, following official WP-CLI documentation recommendations, because the GUID (globally unique identifier) for each post should remain permanent from initial creation and should not change even if site domain shifts. Before executing actual changes, perform a dry run to observe how many rows will be affected without writing changes to the database.

docker compose exec wordpress wp search-replace \
  'https://staging.example.com' 'https://blog.example.com' \
  --skip-columns=guid --dry-run --allow-root

The wp search-replace command modifies data directly and permanently once executed without --dry-run, making a prior database backup essential to verify and restore if the replace process produces unintended side effects, such as on content accidentally containing old domain strings in non-URL contexts.

32.4.5 Verifying Migration Results

After all steps are complete, start the wordpress service and verify that the site displays correctly, ensuring all media from the old uploads directory loads properly.

docker compose up -d wordpress
docker compose logs -f wordpress

Also check the wp_options table to ensure no serialized values were corrupted due to string length changes caused by improper search-replace methods (such as manual SQL queries instead of wp search-replace). WordPress stores certain data in serialized PHP array formats that include the character length of each string, meaning text replacements that fail to account for this format risk rendering that data unreadable by WordPress. WP-CLI handles this case automatically, which is a primary reason why wp search-replace is the recommended method over manual SQL queries for domain migration tasks.