Deploying WordPress on a domain level is straightforward and easy, but deployment in a subdirectory is a bit complex.

This guide walks you through deploying WordPress in a subdirectory (e.g., /blog) using Docker Compose, following best practices for a production-grade WordPress stack. We’ll use the official WordPress image, MariaDB, Valkey (Redis alternative), and Caddy for reverse proxying. All configuration is based on the provided workspace files.

At the time of writing, the latest version of WordPress is 6.8.3.

The goals of this how it to create High Performance, Sectured, Dockeried, HTTPS with Let’s Encrypt, Object Caching and High Maintainability wordpress stack.

Architecture

Internet → Caddy → [ Valkey → WordPress → MariaDB ]
  • Caddy acts as your reverse proxy
  • Valkey (Redis-compatible) caching layer
  • WordPress communicates with Valkey for object caching
  • MariaDB for the database

Note: The reverse proxy is not included in the stack, but the Caddy configuration provided assumes Caddy is running on the host machine.

In this setup, we are using WordPress CLI (optional). It is usefull in automating the configuration of WordPress.

sh → WordPress CLI → MariaDB

For this setup, we will need the following files:

wordpress_docker/
├── .env  # .env to store credentials
├── docker-compose.yml # container orchestration
└── htaccess # htaccess to configure wordpress

Implementation

mkdir wordpress_docker
cd wordpress_docker
touch .env docker-compose.yml htaccess

file: .env

DB_ROOT_PASSWORD=wordpressStrongRootPassword
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=wordpressStrongDBPassword

Edit the .env file and add the content below. This ensures your database credentials are secure and easily configurable. Make sure to choose a strong password for DB_ROOT_PASSWORD and DB_PASSWORD.

file: htaccess

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

Important: The htaccess file is required for WordPress to function correctly. WordPress will override the htaccess file with its own configuration, but we don’t want that, so we are mounting the htaccess file readonly to the /var/www/html/.htaccess later in the docker-compose.yml.

file: docker-compose.yml

services:
  wordpress_db:
    image: mariadb:lts
    container_name: wordpress_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./data/db:/var/lib/mysql
    networks:
      - wordpress_internal

  wordpress_cache:
    image: valkey/valkey:9.0.0-alpine # official valkey image latest
    container_name: wordpress_cache
    restart: unless-stopped
    command: valkey-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - ./data/valkey:/data
    networks:
      - wordpress_internal

  wordpress:
    image: wordpress:latest  # official wordpress image latest
    container_name: wordpress
    restart: unless-stopped
    depends_on:
      - wordpress_db
      - wordpress_cache
    ports:
      - "8080:80"    # WordPress → Host port 8080
    environment:
      WORDPRESS_DB_HOST: wordpress_db:3306
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_MAX_MEMORY_LIMIT', '256M' );
        define('WP_REDIS_HOST', 'wordpress_cache');
        define('WP_REDIS_PORT', 6379);
        define('WP_CACHE', true);
        if (!defined('WP_DEBUG')) {
          define('WP_DEBUG', true);
        }
        define('WP_DEBUG_DISPLAY', false );
        define('WP_DISABLE_FATAL_ERROR_HANDLER', false );
        define('WP_DEBUG_LOG', '/var/www/html/wp-debug.log' );
        define('WP_ENVIRONMENT_TYPE', 'production' );
        define('CONCATENATE_SCRIPTS', true );
        define('DISABLE_WP_CRON', true);
        define('RECOVERY_MODE_EMAIL','admin@domain.com'); 
        define('WP_SITEURL', 'https://domain.com/blog' );
        define('WP_HOME', 'https://domain.com/blog' );
        // FORCE HTTPS
        if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
          $$_SERVER['HTTPS'] = 'on';
        }
        // RESTORE SUBDIRECTORY PATH (SMARTER VERSION)
        // If REQUEST_URI is missing '/blog' at the start, add it back.
        if (isset($$_SERVER['REQUEST_URI']) && strpos($$_SERVER['REQUEST_URI'], '/blog') !== 0) {
            $$_SERVER['REQUEST_URI'] = '/blog' . $$_SERVER['REQUEST_URI'];
        }
    volumes:
      - ./data/html:/var/www/html
      - ./data/themes:/var/www/html/wp-content/themes
      - ./data/plugins:/var/www/html/wp-content/plugins
      - ./data/wc-logs:/var/www/html/wp-content/uploads/wc-logs
      - ./htaccess:/var/www/html/.htaccess:ro
      - ./data/wp-debug.log:/var/www/html/wp-debug.log
    networks:
      - proxy
      - wordpress_internal

  wordpress_cli:
    image: wordpress:cli  # official wordpress cli image latest
    container_name: wordpress_cli
    depends_on:
      - wordpress_db
      - wordpress
    environment:
      WORDPRESS_DB_HOST: wordpress_db:3306
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./data/html:/var/www/html
      - ./data/themes:/var/www/html/wp-content/themes
      - ./data/plugins:/var/www/html/wp-content/plugins
    networks:
      - wordpress_internal
    command: tail -f /dev/null

networks:
  wordpress_internal:
    driver: bridge

Note: The WORDPRESS_CONFIG_EXTRA block customizes WordPress for the /blog subdirectory and enables Redis caching.

file: /etc/caddy/Caddyfile

domain.com {
  # Enable gzip compression
  encode gzip

  # Client caching response headers (applies to all responses)
  header {
    Cache-Control "public, max-age=86400"
    X-Cache-Enabled "true"
    X-Cache-Disabled "false"
    X-Srcache-Store-Status "BYPASS"
    X-Srcache-Fetch-Status "BYPASS"
  }

  # WordPress blog section (including all WordPress paths under /blog)
  handle_path /blog/* {
    reverse_proxy wordpress:80
  }
  
  # Redirect /blog to /blog/  
  redir /blog /blog/ permanent
  
  # Default: serve static files for everything else
  handle {
    root * /usr/share/caddy
    file_server
  }
}

This is for the Caddy reverse proxy running on the host machine.

If you use Nginx or any other reverse proxy, you need to configure it accordingly.

Add the above route to the Caddy reverse proxy:

This ensures:

  • All /blog/* requests go to WordPress
  • /blog redirects to `/blog/** for consistency
  • Other paths serve static files, for example, we show the default Caddy page

Start the Stack

cd wordpress_docker
sudo docker compose up -d

This will start all services in the background. You can check status with: docker compose ps

Visit domain.com/blog or domain.com/blog/wp-admin to complete the installation of WordPress.

For Object Caching to work, you need to install and enable the Redis Object Cache plugin in WordPress.

By following this guide, you can run WordPress securely in a subdirectory using Docker and Caddy. This setup is ideal for multi-app environments or when you want WordPress to power only a section of your site.

References 1 , 2