Running Caddy with Wordpress (php-fpm) with docker compose

This is a “clone and go” docker stack where you can setup a Caddy instance and host a Wordpress site in a few minutes.

Docker and compose files are separated from application config. You don’t need to understand the setup to get going, even do I encourage people to actually understand what they are using. :slight_smile:

Config Injection
Configuration is injected into the docker containers as environment variables. You should have as little config as possible injected into each container to lower the attack vector. Therefor it is better to have many small config files than one large and then inject only what is needed for that application to run.
I will store all config files in the conf directory.

Database
Yes, we need a DB and I will use MariaDB. Let’s make a conf/db.env file and a docker-compose.yml.

conf/db.env:

MYSQL_USER=siteuser
MYSQL_PASSWORD=sitepassword
MYSQL_DATABASE=wordpress
MYSQL_ROOT_PASSWORD=rootpassword
TERM=meh

MariaDB and other applications are mapping the Mysql environment variables and even though this is MariaDB, the variables start with MYSQL.
These are start values that will give us a database during startup. The TERM can be anything, as long as it is set it will be accepted.

The docker-compose.yml:

version: "3.9"
services:
  db:
    image: mariadb:11-jammy
    restart: always
    volumes:
      - dbdata:/var/lib/mysql
    env_file:
      - ./conf/db.env
    logging:
      driver: "json-file"
      options:
        max-size: "1M"
        max-file: "10"
volumes:
  dbdata:
    driver: local

Wordpress
Let’s extend the composition with a Wordpress instance.

conf/wordpress.env:

WORDPRESS_DB_USER=siteuser
WORDPRESS_DB_PASSWORD=sitepassword
WORDPRESS_DB_NAME=wordpress
WORDPRESS_DB_HOST=db:3306

PHP_MEMORY_LIMIT=2048M
PHP_ENABLE_XDEBUG=false
DEBUG=false
UPLOAD_MAX_FILESIZE=64M

The bottom part is optional, but gives a bit of control over the container.

Extending the docker-compose.yml:

  wp-fpm:
    image: wordpress:6-fpm
    restart: always
    links:
      - db
    depends_on:
      - db
    volumes:
      - html:/var/www/html
    env_file:
      - ./conf/wordpress.env
    logging:
      driver: "json-file"
      options:
        max-size: "1M"
        max-file: "10"

volumes:
  html:
    driver: local
    driver_opts:
      type: none
      device: $PWD/html 
      o: bind
  dbdata:
    driver: local

Create an html directory where you have the docker-compose.yml. This dir will act as a mounted volume inside the WP container and will give you direct access to all the site files when up and running.

The fun Part, Adding Caddy!
So this will be bit more complex than just adding an image to the compose file. Adding it this way makes it easy to run the same setup locally, as a test server or as a prod setup. The Caddyfile itself will be pretty static and we will inject extra stuff with our config files.

Let’s create a caddy/etc/Caddyfile:

{
    default_sni {$SERVER_NAME}
}

{$SERVER_NAME} {    
    import /etc/{$TLS_MODE} 
    root * /var/www/html
    encode zstd gzip

    @forbidden {
        not path /wp-includes/ms-files.php
        path /wp-admin/includes/*.php
        path /wp-includes/*.php
        path /wp-config.php
        path /wp-content/uploads/*.php
        path /.user.ini
        path /wp-content/debug.log
    }
    respond @forbidden "Access denied" 403

    php_fastcgi wp-fpm:9000
    file_server

    log {
        output file /var/log/caddy.log
    }

    header / {
      X-Frame-Options "SAMEORIGIN"
      X-Content-Type-Options "nosniff"
    }

}

Also, let’s create two more files in the same directory.
caddy/etc/tls_auto:

tls {$TLS_AUTO_EMAIL}

And caddy/etc/tls_selfsigned:

tls internal

And then our conf/caddy.env:

# replace this with your FQDN if this is a public server
SERVER_NAME=localhost
# replace this with tls_auto if this is a public server
TLS_MODE=tls_selfsigned
TLS_AUTO_EMAIL=your@email.com

As you can see above there is a correlation between the env variables, conf files and the Caddyfile. If you set the TLS_MODE to tls_auto, the other file will be imported into Caddyfile and Caddy will try to fetch a certificate based on your server name.

Adding on to the docker-compose.yml file:

...
  caddy:
    image: caddy:2.7-alpine
    restart: always
    volumes:
      - ./html:/var/www/html
      - ./.caddy_data:/data
      - ./.caddy_config:/config
      - ./caddy/etc/Caddyfile:/etc/caddy/Caddyfile
      - ./caddy/etc/tls_auto:/etc/tls_auto
      - ./caddy/etc/tls_selfsigned:/etc/tls_selfsigned
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    env_file:
      - ./conf/caddy.env
    logging:
      driver: "json-file"
      options:
        max-size: "1M"
        max-file: "10"

So all the files we’ve setup for Caddy are added onto the container as volumes. The env file is injecting its content as environment variables.
You can now change the conffiles and restart to get a new behavior without touching the real application config.

With all this created, we have the last step:

docker compose up -d

Open up your favorite browser and go to https://localhost and you should get to the final steps of completing Wordpress.

If you want to try this setup without the manual copy pasting, you can find a complete working example in this branch:

if you want to dig deeper, with support for more features. Check the main branch of the same repository:

5 Likes

3 posts were split to a new topic: Wordpress Docker

Do we need to add ownership to the directory of wordpress like this:

command: >
  sh -c "chown -R www-data:www-data /var/www/html &&
         chmod -R 755 /var/www/html &&
         chmod -R 775 /var/www/html/wp-content/uploads &&
         php-fpm"

?

@viktorli if it’s running in Docker, it shouldn’t matter because the Docker user is root by default.

1 Like

Hi all, just asking if anyone has thoughts as to why my setup (which is based on the setup described in the OP of this topic) is seemingly going offline about once a week, at not predictable time, seemingly at periods of low website demand.

When this happens, no requests are served and the browser will eventually report a 502 or similar error. Checking the server, all the containers are still running, and there are no apparent errors in the streaming logging. Running docker compose down then docker compose up gets things back up again immediately. Could it be somehow that the Docker network is failing?

I can’t find anything in the logs that suggests anything bad is happening, but I’m not completely sure what I’m looking for.

docker stats returns the following usage

CONTAINER ID   NAME        CPU %     MEM USAGE / LIMIT    MEM %     NET I/O           BLOCK I/O         PIDS 
bb99141acb0d   caddy       0.07%     32.68MiB / 7.57GiB   0.42%     83.7MB / 116MB    0B / 4.2MB        8 
083d10eb11a8   wordpress   0.01%     182.7MiB / 7.57GiB   2.36%     3.54GB / 112MB    41kB / 0B         4 
9920ed921889   mariadb     0.01%     137.4MiB / 7.57GiB   1.77%     33.3MB / 3.54GB   69.8MB / 11.3MB   15 
Caddyfile
{
    email me@example.com
    default_sni example.com
}

conference.example.com,
www.example.com {
redir https://example.com{uri}
}

example.com {
    root * /var/www/html
    encode zstd gzip

    @forbidden {
        not path /wp-includes/ms-files.php
        path /wp-admin/includes/*.php
        path /wp-includes/*.php
        path /wp-config.php
        path /wp-content/uploads/*.php
        path /.user.ini
        path /wp-content/debug.log
    }
    respond @forbidden "Access denied" 403

    php_fastcgi wordpress:9000
    file_server

    log {
        output file /var/log/caddy.log
    }

    header / {
      X-Frame-Options "SAMEORIGIN"
      X-Content-Type-Options "nosniff"
    }

}
docker-compose.yml
x-global-environment: &global
  networks:
    - caddy
  restart: always

services:

  caddy:
    <<: *global # this will inherit standard configs from x-global-environment
    image: caddy:latest
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    links:
      - wordpress
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy_data:/data
      - ./caddy_config:/config
    volumes_from:
      - wordpress

  mariadb:
    <<: *global # this will inherit standard configs from x-global-environment
    image: mariadb:latest
    container_name: mariadb
    volumes:
      - "./mariadb-data:/var/lib/mysql"
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: wordpress
      MYSQL_USER: wordpress
      TERM: meh
    logging:
      driver: "json-file"
      options:
        max-size: "1M"
        max-file: "10"


  wordpress:
    <<: *global # this will inherit standard configs from x-global-environment
    image: wordpress:6-fpm
    container_name: wordpress
    depends_on:
      - mariadb
    links:
      - mariadb
    volumes:
      - "./wordpress:/var/www/html"
    environment:
      WORDPRESS_DB_HOST: mariadb:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress

      PHP_MEMORY_LIMIT: 2048M
      PHP_ENABLE_XDEBUG: false
      DEBUG: false
      UPLOAD_MAX_FILESIZE: 64M

networks:
  caddy:

I know that this community isn’t for support of esoteric setups, but I wondered if anything jumps out to anyone as being something obvious I should check.

Just following up on my own post in case it’s of help to anyone following in my footsteps. I have a setup as described in this thread using Docker Compose, Caddy, Wordpress and MariaDB. At regular intervals the WP server was failing, but without errors in Docker, or in the OS, or in the container logs.

Worker exhaustion

Eventually I figured this must be either exhaustion of the numbers of available PHP-FPM ‘workers’ or worker failure caused by memory leaks from WP plugins that were causing those workers to crash, and once all 5 of the default workers crashed, the whole stack would stop working and I would just get 502 errors.

Since 5 workers for a site that has any kind of traffic volume seems very low, I think it would be worth including this in the Wiki OP as an option for any sites that are not for testing or minimal traffic.

I eventually concocted a ‘Proper Docker Compose’ way to make the changes to PHP-FPM config to increase the number of workers, and to make the workers re-spawn after a certain number of requests (500) so that they wouldn’t be as susceptible to memory leaks making them unavailable. Sharing the details here for others (and for Future Me who will have this same problem but will forget how I solved it).

Create a PHP-FMP config file zz-custom-www.conf

This file needs to be in the same directory as your docker-compose.yml, or if you place it somewhere else remember to change the path in the volume mount accordingly.

The number of ‘children’ you can set is dependent on the available memory. I have a 4vCPU Hetzner VPS with 8Gb RAM and I think these settings are probably slightly conservative, in that I think I could increase the number of children workers safely.

; zz-custom-www.conf

[www]
pm = dynamic
pm.max_children = 50
pm.start_servers = 20
pm.min_spare_servers = 20
pm.max_spare_servers = 40
pm.max_requests = 500

; Logging
access.log = /proc/self/fd/2
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s

; Security
security.limit_extensions = .php .html .htm

Mount it as a volume in docker-compose.yml

Config files in the php-fpm.d directory are evaluated in alphanumeric order, hence naming this file with zz- prepended means it will be evaluated last and will override any existing settings for these specific parameters.

# docker-compose.yml

  wordpress:
    <<: *global # this will inherit standard configs from x-global-environment
    image: wordpress:6-fpm
    container_name: wordpress
    restart: always
    depends_on:
      - mariadb
    links:
      - mariadb
    volumes:
      - "./wordpress:/var/www/html"
      - "./zz-custom-www.conf:/usr/local/etc/php-fpm.d/zz-custom-www.conf" # Custom PHP-FPM overrides
    environment:
      WORDPRESS_DB_HOST: ********
      WORDPRESS_DB_USER: ********
      WORDPRESS_DB_PASSWORD: ********
      WORDPRESS_DB_NAME: ********

      PHP_MEMORY_LIMIT: 2048M
      PHP_ENABLE_XDEBUG: false
      DEBUG: false
      UPLOAD_MAX_FILESIZE: 64M

Restart the Wordpress container

docker compose down
then
docker compose up

So far I’ve had no outages with this setup, if anyone has comments, advice, or finds this useful please let me know!

1 Like

Nice write up. I have a reason for only adding a minimal config in the repo, it is mainly for dev and staging purposes (even though I host several huge sites with the same base) and I don’t want people to setup a site with the config and think it is enough, really. There are a lot of sec hardening and optimization to be done depending on the use case.

With that said, you’re contribution to the docs is great! :+1: