Docker – Part 6 – the final boss

By Huntly Cameron Uncategorized

So far in this series we’ve covered:

  • images
  • containers
  • networking
  • volumes
  • docker compose

These are all the fundamental building blocks to start creating your own setups and containerising applications.

In this part, we’ll put everything together to create a WordPress dev environment that you can run locally. We’ll also cover how to set environment variables.

This is a fairly large post with a lot of steps so if you’re following along you’ll need to set aside some time.

Lets break down what we need:

  • images
    • NGINX – to serve our content
    • PHP-FPM – to execute our php code
    • MariaDB – to store our data
    • PHPMyAdmin – so we can interact with our database should we need to
  • Volumes
    • wpdb – a docker volume to persist our database
    • wordpress_files- to allow both NGINX and PHP-FPM to access our WordPress install
    • bind mount to wp-content so we can edit our code
  • Network
    • wp – an isolated network so we can have our containers talk to each other

Conceptually, it’ll look like this:
~IMAGE HERE~

If you don’t want to do these steps manually, which I do recommend, then you can pull the following repo down: Docker WP

NGINX

We’ll build this up step by step starting with the NGINX image. Perviously we’ve run the stock image without any modifications. For this project we’ll want to have SSL setup so that any 3rd party libraries will work correctly.

First of all lets create a .env file inside our empty directory. This will house all our variables that we’ll need:

SERVER_NAME=wordpress.local
MYSQL_PASSWORD=test
WP_DB=wp
WP_DB_USER=wp
WP_DB_USER_PASS=test

Inside that directory create an nginx directory and and then create a file called nginx.conf.template

In that file we want the following:

# HTTP server - redirect to HTTPS if SSL certificates exist
server {
    listen 80;
    server_name ${SERVER_NAME} www.${SERVER_NAME};
    root /usr/share/nginx/html;
    index index.php index.html index.htm;

    access_log /var/log/nginx/wp_access.log;
    error_log  /var/log/nginx/wp_error.log;

    # Allow larger file uploads (match PHP settings)
    client_max_body_size 128M;

    # Check if SSL certificate exists, if so redirect to HTTPS
    location / {
        if (-f /etc/ssl/certs/${SERVER_NAME}.pem) {
            return 301 https://$host$request_uri;
        }
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP handling (via php-fpm container)
    location ~ \.php$ {
        if (-f /etc/ssl/certs/${SERVER_NAME}.pem) {
            return 301 https://$host$request_uri;
        }
        include fastcgi.conf;
        fastcgi_pass php:9000;   # php = php-fpm service name in docker-compose
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
    }

    # Deny access to sensitive files
    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
    }

    location ~ /\.ht {
        deny all;
    }

    # Static file caching
    location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|mp4|ogg|webm)$ {
        expires 30d;
        access_log off;
        add_header Cache-Control "public";
    }
}

# HTTPS server for WordPress
server {
    listen 443 ssl;
    http2 on;
    server_name ${SERVER_NAME} www.${SERVER_NAME};
    root /usr/share/nginx/html;
    index index.php index.html index.htm;

    access_log /var/log/nginx/wp_ssl_access.log;
    error_log  /var/log/nginx/wp_ssl_error.log;

    # Allow larger file uploads (match PHP settings)
    client_max_body_size 128M;

    # SSL certificates generated by mkcert
    ssl_certificate     /etc/ssl/certs/${SERVER_NAME}.pem;
    ssl_certificate_key /etc/ssl/certs/${SERVER_NAME}-key.pem;

    # SSL configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # Handle WordPress rewrites
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP handling (via php-fpm container)
    location ~ \.php$ {
        include fastcgi.conf;
        fastcgi_pass php:9000;   # php = php-fpm service name in docker-compose
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
        fastcgi_param HTTPS on;
    }

    # Deny access to sensitive files
    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
    }

    location ~ /\.ht {
        deny all;
    }

    # Static file caching
    location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|mp4|ogg|webm)$ {
        expires 30d;
        access_log off;
        add_header Cache-Control "public";
    }

}

You can modify any of this to suit your needs. You can see that there are ${SERVER_NAME} variables littered throughout this file. This is not some special syntax and when we copy our config across to the NGINX image, we’ll need to substitute these out. To do so, we can create a Dockerfile for this. It’s contents are as follows:

FROM nginx:alpine

# Copy the template
COPY nginx.conf.template /etc/nginx/templates/default.conf.template

# Create startup script that handles variable substitution
RUN echo '#!/bin/sh' > /docker-entrypoint.sh && \
    echo 'sed "s/\${SERVER_NAME}/$SERVER_NAME/g" /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf' >> /docker-entrypoint.sh && \
    echo 'exec nginx -g "daemon off;"' >> /docker-entrypoint.sh && \
    chmod +x /docker-entrypoint.sh

CMD ["/docker-entrypoint.sh"]

It might look a bit much, but breaking it down we are doing the following:

  • Using the NGINX alpine image
  • Copying our config template across to the image
  • Running a search and replace on our ${SERVER_NAME} string replacing it with the SERVER_NAME variable we defined in our environment
  • Creating a custom entry point command that will run nginx in the foreground which is needed on a container.

Generating SSL

In the root of your project, create the following bash script called generate-ssl.sh

#!/bin/bash

# SSL Certificate Generation Script using mkcert Docker image
# This script generates local development SSL certificates

set -e

# Load environment variables
if [ -f .env ]; then
    export $(grep -v '^#' .env | xargs)
else
    echo "Error: .env file not found. Please copy .env.sample to .env and configure it."
    exit 1
fi

# Check if SERVER_NAME is set
if [ -z "$SERVER_NAME" ]; then
    echo "Error: SERVER_NAME not set in .env file"
    exit 1
fi

echo "Generating SSL certificates for: $SERVER_NAME"

# Create ssl directory if it doesn't exist
mkdir -p ssl

# Generate certificates using mkcert Docker image
docker run --rm -it \
    -v "$(pwd)/ssl:/root/.local/share/mkcert" \
    -v "$(pwd)/ssl:/certs" \
    alpine/mkcert \
    -install

# Generate the certificate for the domain
docker run --rm -it \
    -v "$(pwd)/ssl:/root/.local/share/mkcert" \
    -v "$(pwd)/ssl:/certs" \
    alpine/mkcert \
    -cert-file /certs/${SERVER_NAME}.pem \
    -key-file /certs/${SERVER_NAME}-key.pem \
    ${SERVER_NAME} \
    www.${SERVER_NAME}

# Fix permissions so nginx can read the certificates
sudo chown $(id -u):$(id -g) ssl/${SERVER_NAME}*.pem
chmod 644 ssl/${SERVER_NAME}*.pem

echo "SSL certificates generated in ./ssl/ directory:"
echo "  Certificate: ssl/${SERVER_NAME}.pem"
echo "  Private Key: ssl/${SERVER_NAME}-key.pem"
echo ""
echo "Next steps:"
echo "1. Run 'docker-compose down && docker-compose up -d' to restart with SSL"
echo "2. Access your site at https://$SERVER_NAME"

We’ll use the mkcert program, running in a container, to generate our certificates. It takes care of all the hard work in generating and trusting certificates.

PHP

Back in the root directory create a php8.2 directory. We’ll want to have a custom .ini file that sets generous upload sizes and so on.

Our php.ini file is as follows:

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; General Settings
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

; Memory
memory_limit = 512M        ; More room for plugins/themes

; File uploads
upload_max_filesize = 128M ; Allow big theme/plugin zips
post_max_size = 128M       ; Must be >= upload_max_filesize
max_file_uploads = 50      ; Reasonable cap for uploads at once

; Execution
max_execution_time = 300   ; 5 minutes (slow imports/updates)
max_input_time = 300
max_input_vars = 5000      ; Avoid hitting limits with complex forms

; Error reporting (verbose for dev)
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
log_errors = On
error_log = /var/log/php/error.log

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Opcache (helps dev & prod)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0    ; Always revalidate (good for dev)
opcache.validate_timestamps=1
opcache.save_comments=1
opcache.fast_shutdown=1

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Extensions & Tweaks
;;;;;;;;;;;;;;;;;;;;;;;;

And our Docker file is as follows:

FROM php:8.2-fpm

# Install system dependencies
RUN apt-get update && apt-get install -y \
    libfreetype6-dev \
    libjpeg62-turbo-dev \
    libpng-dev \
    libwebp-dev \
    libzip-dev \
    libicu-dev \
    libonig-dev \
    libxml2-dev \
    unzip \
    git \
    curl \
    vim \
    mariadb-client \
    && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
    && docker-php-ext-install -j$(nproc) \
        bcmath \
        exif \
        gd \
        intl \
        mbstring \
        mysqli \
        opcache \
        pdo_mysql \
        zip \
        xml \
    && docker-php-ext-enable opcache

# Install Redis extension (common for WP caching)
RUN pecl install redis \
    && docker-php-ext-enable redis

# Install Imagick (optional but nice for WP media handling)
RUN apt-get install -y libmagickwand-dev --no-install-recommends \
    && pecl install imagick \
    && docker-php-ext-enable imagick

# Cleanup
RUN apt-get clean && rm -rf /var/lib/apt/lists/*

# Copy custom PHP config (if you want overrides for WP)
COPY ./php.ini /usr/local/etc/php/conf.d/wp-custom.ini

WORKDIR /var/www/html

# Download and setup WordPress
RUN curl -o latest.tar.gz https://wordpress.org/latest.tar.gz \
    && tar xf latest.tar.gz \
    && rm latest.tar.gz \
    && mv wordpress/* . \
    && rmdir wordpress \
    && chown -R www-data:www-data /var/www/html

CMD ["php-fpm"]

This dockerfile instructs the image to install all the dependancies plus a few extra packages for future upgrades.

It then copies across the php.ini file we just created before changing the current working directory to the html folder.

Then we pull down the latest wordpress version, extract it, fix permissions and cleanup.

Directory setup

Create a wp direrectory and inside it, create the following directories: uploads, plugins, and themes. We’ll bind mount the wp directory to our NGINX and PHP containers later.

Orchastrating everything together

Now that we’ve got our custom parts setup, we can go ahead and create a compose.yaml file in the root of our project directory. This will have the following contents:

services:
  wp:
    build: ./nginx
    networks:
      - wp
    volumes:
      - wordpress_files:/usr/share/nginx/html
      - ./wp:/usr/share/nginx/html/wp-content
      - ./ssl:/etc/ssl/certs:ro
    environment:
      - SERVER_NAME=${SERVER_NAME}
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - php

  php:
    networks:
      - wp
    build: ./php8.2
    volumes:
      - wordpress_files:/var/www/html
      - ./wp:/var/www/html/wp-content

  db:
    networks:
      - wp
    image: mariadb
    restart: always
    volumes:
      - wpdb:/var/lib/mysql
    environment:
      MARIADB_ROOT_PASSWORD: ${MYSQL_PASSWORD}
      MARIADB_DATABASE: ${WP_DB}
      MARIADB_USER: ${WP_DB_USER}
      MARIADB_PASSWORD: ${WP_DB_USER_PASS}

  adminer:
    networks:
      - wp
    image: adminer
    restart: always
    ports:
      - 8080:8080

networks:
  wp:

volumes:
  wpdb:
  wordpress_files:

Going service by service:

wp – our nginx container

We tell docker to use our custom dockerfile by pointing the image paramater to ./nginx which is the nginx folder you created when making the custom image.

We tell docker that we want to use the wordpress_files volume for the web root directory and we bind mount our wp directory as the wp-content folder.

Finally we bind mount the ssl certificates as read only.

We also attatch it to our wp network, publish the ports and give it access to our SERVER_NAME environment variable so that it can do the substitution on the config file.

php

Similar to nginx, we tell it to use our custom Dockerfile, attach it to our network and give it access to our wordpress_files volume while also bind mounting our wp folder

db

There’s no custom image here. We are using the stock mariadb image which allows us to set a few environment variables. We set a root password and then also setup a generic user who will access our WordPress database using our preconfigured variables inside our .env file

Next to the db image is the adminer image. We tell it to attach to our network and run on port 8080. You can change the first part of this - 8080:8080 paring to change which port you access it on locally.

Networks and volumes

Finally we define the wp network as well as the two volumes:
wpdb for our databse, and wordpress_files for our root html filesystem.

You should now be able to run the following commands:

./generate-ssl.sh

Which will run you through creating the SSL certificates and making your computer trust them.

The final piece of the puzzle is to add your custom domain name to your /etc/hosts file on mac or linux. If you’re on windows its under C:\Windows\System32\drivers\etc\hosts

With that done, you can run docker compose up -d to spin everything up.

All going well, you should be able to access your WordPress installation!