Unlocking Peak Performance: Migrating from Apache2 to FrankenPHP with Docker

Foreword

Up until today (April 20, 2025) I’ve hosted this WordPress site using a variety of services and software. This site originally started out on Google Cloud Platform but then slowly transitioned to self-hosting via Raspberry Pi 4. For reasons that I do not remember, when migrating over to rpi4 I set up the site and all its components directly in the host server, without using any containerization software such as Docker. Retroactively speaking, I likely did it to brush up on my linux knowledge and make sure that I understood everything that was happening under the hood of my server and website. I’ve created and documented my journey through the various migrations in this site but finally realized recently that it is time to move on to using new tools and services. To “catch up with the times” or so people say. Now that I spent enough time building code repos from source, manually orchestrating the deployments and upgrades of WordPress and MariaDB, and manually updating PHP versions by hand, I will now leave that to the community and embrace the wonderfulness that is waiting for new software versions to arrive in docker containers 😉

About Caddy and FrankenPHP

Apache2 was (and likely still is) the go-to webserver for WordPress deployments. For WordPress users, transitioning from the traditional Apache2 setup to a more modern and efficient architecture can yield significant benefits. Caddy, with its V2 released in 2020, is a new webserver that, though young, promises high performance with an easier to use configuration. FrankenPHP is a cutting-edge PHP application server built on the Caddy web server. It consolidates the web server and PHP runtime into a single service, eliminating the need for separate PHP-FPM and Apache processes. This integration simplifies deployment and enhances performance. Notably, benchmarks have shown that FrankenPHP can process requests significantly faster than traditional setups .​

Current System Setup

  • WordPress v6.7
  • Apache/2.4.62
  • FPM-PHP (PHP v8.2.28)
  • MariaDB 10.5.26

Prerequisites

  • Backup the site using your WP site migration tool of choice (I used UpdraftPlus’s Free Version)
  • Backup your MySQL/MariaDB as well – if you decide to upgrade your DB version then you may potentially come across compatibility issues, and your database tables may need to be repaired in order for it to be useable
  • Docker and Docker-Compose. Below is the version I’m using at the time of writing this post:
    Docker version 28.0.4, build b8034c0
    Docker Compose version v2.34.0
  • You will need to retrieve all of the username/passwords for the following
    • WordPress DB name
    • WordPress DB username/password
    • MariaDB root password

How to Migrate

Note: There will be site downtime while you perform this migration using this manual.

Creating the Docker Compose Configuration File

I will be using FrankenPHP’s officially (yet not official at the same time…) sponsored WordPress images created from the FrankenWP project. The list of latest images can be found here. For MySQL/MariaDB, choose the version that is closest if not equal to the version you are currently using – if you perform too big of a version jump the built-in upgrade feature may not work for your existing data. Lastly, if you use PHPMyAdmin, also grab and configure the details for that. Long story short, Caddy nor FrankenWP official pages had anything close to a work-able compose.yaml, and a lot of trial-and-error and extra configurations were necessary in order to at least properly serve the site in Production.

Below is the compose.yaml file I finally ended up with that made the whole thing work, though I’m still giving most credit to FrankenWP’s creator since I took the template from the project’s examples section. Note that the below example has sample data (denoted by ##) which will need to be replaced with info from your current WP site and DB info:

services:
  wordpress:
    image: wpeverywhere/frankenwp:latest-php8.3
    restart: always
    ports:
      - "80:80" # HTTP
      - "443:443" # HTTPS
    environment:
      SERVER_NAME: ${SERVER_NAME:-:80}
      WORDPRESS_DB_HOST: ${DB_HOST:-db}
      WORDPRESS_DB_USER: ${DB_USER:-wordpress}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD:-##YOUR_WP_DB_PASSWORD##}
      WORDPRESS_DB_NAME: ${DB_NAME:-wordpress}
      # Currently WP_DEBUG turns on even if below is set false. To turn debug off, remove it entirely.
      # See:  https://github.com/docker-library/wordpress/issues/496
      #WORDPRESS_DEBUG: false
      WORDPRESS_TABLE_PREFIX: ${DB_TABLE_PREFIX:-wp_}
      CACHE_LOC: ${CACHE_LOC:-/var/www/html/wp-content/cache}
      TTL: ${TTL:-80000}
      PURGE_PATH: ${PURGE_PATH:-/__cache/purge}
      PURGE_KEY: ${PURGE_KEY:-}
      BYPASS_HOME: ${BYPASS_HOME:-false}
      BYPASS_PATH_PREFIXES: ${BYPASS_PATH_PREFIXES:-/wp-admin,/wp-content,/wp-includes,/wp-json,/feed}
      CACHE_RESPONSE_CODES: ${CACHE_RESPONSE_CODES:-000}
        # Enable debug to check for Caddy issues (Certificate renewal error, config error, etc)
      #CADDY_GLOBAL_OPTIONS: |
        #email ##EMAIL_ADDRESS##
        #auto_https disable_redirects
        #debug
      WORDPRESS_CONFIG_EXTRA: |
        # Add extra configs here as necessary
        define('WP_SITEURL', '##SITEURL##');
        define('WP_HOME', '##WP_HOME##');

    volumes:
      # Mount your WordPress content directory if needed
      - /var/www/html/wp-content:/var/www/html/wp-content
      # Load your own Caddyfile to configure site
      - ./Caddyfile:/etc/caddy/Caddyfile
      # Store SSL Certs so that it can be reused when containers are recreated
      # (and do avoid exceeding cert issue rate limits when debugging containers)
      - ./caddy_data:/data
      # Other root files, such as ads.txt
      - /var/www/html/ads.txt:/var/www/html/ads.txt

    depends_on:
      - db
    tty: true
    networks:
      wpnet:
        ipv4_address: 172.28.0.3

    # Below only if necessary
    #extra_hosts:
    #  - ##TLD##:172.28.0.3
    #  - www.##TLD##:172.28.0.3

  db:
    # Be careful of making too big of a version jump...
    # Slowly update db version to reduce chance of data corruption
    image: mariadb:11.7.2
    restart: always
    ports:
      - 3306:3306
    environment:
      MYSQL_DATABASE: ${DB_NAME:-wordpress}
      MYSQL_USER: ${DB_USER:-wordpress}
      MYSQL_PASSWORD: ${DB_PASSWORD:-##YOUR_WP_DB_PASSWORD##}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-##YOUR_ROOT_PASSWORD##}
    volumes:
      - /var/lib/mysql:/var/lib/mysql
      - /etc/mysql/mariadb.conf.d:/etc/mysql/mariadb.conf.d
    networks:
      wpnet:
        ipv4_address: 172.28.0.4

  phpmyadmin:
    # using arm64 version since I am on raspberry pi
    image: arm64v8/phpmyadmin:latest
    restart: always
    ports:
      - ${LOCAL_PHPMYADMIN_PORT:-8086}:80
    environment:
      PMA_HOST: db
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-##YOUR_ROOT_PASSWORD##}
    depends_on:
      - db
    networks:
      wpnet:
        ipv4_address: 172.28.0.5
    volumes:
      # Use existing phpmyadmin data
      - /var/www/html/phpmyadmin:/var/www/html/phpmyadmin

networks:
  wpnet:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16
          gateway: 172.28.0.1

Mix and match whatever you need from the above to get your own site working. Below is a list of some configurations which can be made differently based on your unique situation:

  • Remove PHPMyAdmin if you don’t use it.
  • Above config uses a bridge network. This allows for multiple sites to be hosted on the same host device via reverse proxy. Also, as you will see later, if your PHPMyAdmin also uses standard HTTP/S ports then it is not possible to run both your site (WordPress) and PHPMyAdmin on the same port in the host network configuration.
  • Static addresses can be used to grant read/write privileges for the DB which is hosted in another instance and by default only allows for localhost connection. Static IP addresses can be omitted if you configure your MySQL/MariaDB to allow connections from the entire IP address range in your bridge network (example further below). It can also be omitted if you decide to host your containers using the host network configuration.
  • Static IP address of PHPMyAdmin is also used in the below Caddyfile to set up reverse proxy. There’s probably a better way to do this without static IP addresses… will update later…

Creating the Caddyfile

Now that our docker configurations are set up, we need to configure Caddyfile. This Caddyfile should be placed in the same folder as your compose.yaml:

{
        # Uncomment below when experiencing redirect loops from reverse proxy/cloud load balancer/etc
        #auto_https disable_redirects
        {$CADDY_GLOBAL_OPTIONS}

        frankenphp {
                #worker /path/to/your/worker.php
                {$FRANKENPHP_CONFIG}
        }

        # https://caddyserver.com/docs/caddyfile/directives#sorting-algorithm
        order php_server before file_server
        order php before file_server
        order wp_cache before rewrite
        order request_header before wp_cache
}

{$CADDY_EXTRA_CONFIG}

##WWW_DOMAIN## {

        tls ##EMAIL_ADDRESS##

        @phpmyadmin path /phpmyadmin*
        reverse_proxy @phpmyadmin 172.28.0.5:80

        @static {
                file
                path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.woff *.txt
        }

        root * /var/www/html/
        encode br zstd gzip

        wp_cache {
                loc {$CACHE_LOC:/var/www/html/wp-content/cache}
                cache_response_codes {$CACHE_RESPONSE_CODES:2XX,404,405}
                ttl {$TTL:6000}
                purge_path {$PURGE_PATH:/__cache/purge}
                purge_key {$PURGE_KEY}
                bypass_home {$BYPASS_HOME:false}
                bypass_path_prefixes {$BYPASS_PATH_PREFIXES:/wp-admin,/wp-json}
        }

        {$CADDY_SERVER_EXTRA_DIRECTIVES}
        php_server
}

# For www redirects
###DOMAIN## {
#        redir https://www.{host}{uri} permanent
#}

Replace the ##ITEM## with configurations for your own site. At the very bottom is a redirection bit in case you want www to be redirected to your TLD+1, or vice versa. The PHPMyAdmin reverse proxy is also configured inside this Caddyfile.

Execute the Migration

Now it’s time to make the transition. Bring your webserver and DB down from your host server and start your docker configurations. If at any point in this step something goes wrong, you can revert to your existing host server setup by reversing the commands below (and in the reverse execution order):

# FYI if you do not stop Apache, you will get the following error:
#Error response from daemon: failed to set up container networking: driver failed programming external
#connectivity on endpoint frankenphp-wordpress-1
#(931a4e79170c448d3aca76617cc7be9847f227138f70a4ef085efea09e19eeb3): failed to bind host port for
#0.0.0.0:80:172.18.0.2:80/tcp: address already in use

# Also need to stop MariaDB, or else you may get corrupt data from multiple live services at the same time.
sudo systemctl stop apache2
sudo systemctl stop mariadb
docker compose up

Once the instances have started up fully, let’s go and make the change in MariaDB to allow both wordpress and phpmyadmin instance access. Below settings incorporate some wildcards in the docker network which can set both wp and phpmyadmin at the same time, but if you don’t use phpmyadmin you can just limit to the WP ip address only:

docker exec -it ##DIR##-db-1 bash
mariadb -u root -p
# enter your root password

# replace each of the data with the items you configured in the compose.yaml
# 172.28.%.% is a wildcard match to all IP addresses in your docker bridge network.
GRANT ALL PRIVILEGES ON ##WORDPRESS_DB##.* TO '##WORDPRESS_USER##'@'172.28.%.%' IDENTIFIED BY '##WORDPRESS_DB_PASSWORD##';
FLUSH PRIVILEGES;

Now your WordPress and PHPMyAdmin should be able to access your DB normally. However, you may need to wait a few minutes for Caddy to use LetsEncrypt to fetch a new certificate for you. You can use docker logs ##DIR##-db-1 -f to live tail your wordpress log to check on SSH certification creation/renewal progress. After a while you should be able to access your site from the public as normal.

Other Notes

Under the above setup, if you use Site Kit by Google, it will need to be reconfigured every time you create a new docker container for wp. This means there’s data used by Site Kit which resides outside of the wp-content directory which we bind to. Will update this guide once I figure out what else is necessary to bind to the docker containers.

Additional Troubleshooting

PHPMyAdmin – mysqli_real_connect(): (HY000/2002): No such file or directory

Change $cfg['Servers'][$i]['host'] value to 127.0.0.1

Unable to Access MySQL or MariaDB

In /etc/mysql/mariadb.conf.d/50-server.cnf, change bind-address to 0.0.0.0