London, United Kingdom

(+44) 07788.377.460 [email protected]

Run WordPress on Docker (with nginx)

WordPress via nginx on Docker

Synopsis

This article describes how to configure and deploy a WordPress instance, either in production or for local development (and assumes a beginner+ / enthusiast+ level in DevOps).
To achieve this, other than the official WordPress codebase, we will also need a database and a web server which we’ll refer to as services (the Docker Compose terminology) and each of these will run in their own Docker container*:

  1. WordPress (v6.4)
  2. Database (MySQL v8.0)
  3. Proxy (Nginx v20)

* Note
This separation is not required! The project can be installed and deployed on a single server running all services, however this is not ideal and definitely not an industry standard. Apart from the obvious separation of concerns, we’re also gaining atomicity by separating our services while requiring less processing power so that when deploying to cloud for instance, we can provision a cheaper infrastructure to run on.

Considerations

We want to employ volumes to achieve data persistence, and networks to segregate traffic and data access. The networks will connect the proxy and, respectively, database services to our WordPress installation, but not to eachother; one benefit of this is that the database is not publicly available.

Basic WordPress on Docker

The following example is a basic docker-compose.yml template:

version: '3'
services:
  frontend:
    image: wordpress:6.4-fpm
    restart: unless-stopped
    ports:
      - 9000:9000
    depends_on:
      - database
    networks:
      - public
      - internal
    volumes:
      - wordpress:/var/www/html
    environment:
      - WORDPRESS_DOMAIN="https://website.uk/"
      - WORDPRESS_DB_HOST=database
      - WORDPRESS_DB_PORT=3306
      - WORDPRESS_DB_NAME=${DB_NAME:-wpdatabase}
      - WORDPRESS_DB_USER=${DB_USERNAME:-wpdbuser}
      - WORDPRESS_DB_PASSWORD=${DB_PASSWORD:-wpdbpassword}
  database:
    image: mysql:latest
    restart: unless-stopped
    networks:
      - internal
    volumes:
      - dbdata:/var/lib/mysql
    environment:
      - MYSQL_DATABASE=${DB_NAME:-wpdatabase}
      - MYSQL_USER=${DB_USERNAME:-wpdbuser}
      - MYSQL_PASSWORD=${DB_PASSWORD:-wpdbpassword}
  proxy:
    image: nginx:latest
    restart: unless-stopped
    ports:
      - 80:80
      - 443:443
    networks:
      - public
    depends_on:
      - frontend
    volumes_from:
      - frontend
volumes:
  wordpress:
  dbdata:
networks:
  public:
    driver: bridge
  internal:
    driver: bridge

The above relies on a couple of environment variables, which you may want to define in a .env file (rather than hardcoding them in the docker-compose.yml file and version commit them):

DB_NAME=the-name-of-the-database
DB_USERNAME=user-with-access-to-db
DB_PASSWORD=your-password
# DB_PORT=3306

Nginx configuration

You’ll also need to mount the nginx configuration for your domain, from the directory proxy/ in the root of the repo.

You’ll also need to configure Nginx for your domain name. Create a file with .conf extension in the proxy/ directory (which will mount it to the web proxy Docker container). Unfortunately you’ll need to hard-code a few things since we cannot use environment variables in Nginx configuration files (at least I’ve not been able to get it to work with the .env file). Here’s an example (e.g. `website.conf`) with duplicated blocks for HTTP and HTTPS, in case you run into issues with SSL certificates you can rely on the good ol’ HTTP:

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name website.uk www.website.uk;
    server_tokens off;

    ssl_certificate     /etc/letsencrypt/live/website.uk/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/website.uk/privkey.pem;

    root /var/www/html;
    index index.php;

    location ~ /.well-known/acme-challenge {
        allow all;
        root /var/www/html;
    }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
         include fastcgi_params;
         fastcgi_split_path_info ^(.+\.php)(/.+)$;
         fastcgi_pass WORDPRESS-CONTAINER-NAME:9000;
         try_files $uri =404;
         include fastcgi_params;
         fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
         fastcgi_param PATH_INFO $fastcgi_path_info;
         fastcgi_intercept_errors on;
    }
}

server {
        listen 80;
        listen [::]:80;

        server_name website.uk www.website.uk;
        server_tokens off;

        root /var/www/html;
        index index.php;

        location / {
                try_files $uri $uri/ /index.php?$args;
        }

        location ~ \.php$ {
                include fastcgi_params;
                fastcgi_split_path_info ^(.+\.php)(/.+)$;

                fastcgi_pass WORDPRESS-CONTAINER-NAME:9000;
                try_files $uri =404;

                include fastcgi_params;

                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
                fastcgi_param PATH_INFO $fastcgi_path_info;
                fastcgi_intercept_errors on;
        }
}

Conclusions

This is more than plenty to get you started with a fresh WordPress installation with Docker Compose. However there are things we can improve (e.g. persistence of important files such as WP themes, uploads, or plugins), as well as addressing potential issues which one might run into when developing locally (especially on MacOS, or Windows’ Subsystem for Linux WSL) particularly around file ownership and permissions between the host system and the Docker containers. We would probably also want to enable SSL on our website so that web browsers won’t complain.

;tldr

See my GitHub gist:

Run WordPress website behind nGinx, on Docker, with SSL via Lets Encrypt
https://gist.github.com/mariuscucuruz/530fd356e97146ece62be4e093b8a582

Improve on the basics

The above example creates a docker container running WordPress and persists data via named volumes, however we may encounter some challenges, for instance, when porting our local changes to staging or production. To persist and track our customisations we could either build (and push) our custom Docker images, or use Docker volumes (and track changes via Git or any versioning system), or both. The latter option is easier to implement if we were to build on the initial setup, so we’ll begin with that.

Persist your uploads, and track changes to Themes and Plugins

What we want to achieve is mapping certain files from the WordPress container onto our host computer so that Git will pick up and track all of our changes. In particular we’re interested in our Uploads and Themes, and quite possibly the Plugins we’ve installed. So we’ll add the local paths to the wordpress volume we’ve defined, mapping them to the path inside the Docker container:

services:
  ...
  frontend:
  ...
    volumes:
      - wordpress:/var/www/html
      - ./wp-content/uploads:/var/www/html/wp-content/uploads:rw
      - ./wp-content/themes:/var/www/html/wp-content/themes:rw
      - ./wp-content/plugins:/var/www/html/wp-content/plugins:rw

Note
If other files are of interest to your case, you may add them to the volumes list. Be aware that the host paths (i.e. the first path, to the left of the colon :) should be relative to the context, where the root is where your docker-compose.yml resides, and should almost always start with ./.

Save changes to custom Docker image

To build on the official WordPress image for Docker and push the custom image to your Docker Hub you’ll need to create a new file alongside our docker-compose.yml, call it wp.dockerfile:

FROM wordpress:6.4-fpm
RUN echo "WordPress Ready!"

And if we update our docker-compose.yml to use this new file (and mention our Docker Hub account while we’re at it):

services:
  frontend:
    ...
    build:
      context: .
      dockerfile: wp.dockerfile
    image: dockerhub-username/wordpress:local
  ...

What this will do is allow us to build our container, and push our custom WordPress image to our Docker Hub (you’ll need a terminal session where the Docker service is logged into your account). So the next time you spin the containers up you can add the --build flag:

docker compose up -d --build [--force-recreate] [--remove-orphans]

Or just run docker compose build. In the above example where image: dockerhub-username/wordpress:local, use colon : to tag different versions of your site (e.g. local, dev, staging, prod, live etc.) to avoid deploying the wrong image to production, for instance.

Then, assuming you’re logged into your Docker Hub account, push the image by running

docker composer push


Similarly should work for the database service to track pages and posts. We would need a very basic db.dockerfile file

FROM mysql:latest
RUN echo "Database Ready!"

which we then reference in the docker-compose.yml file:

services:
  ...
  database:
   ...
    build:
      context: .
      dockerfile: db.Dockerfile
    image: dockerhub-username/wp-db:local
....

Then build and push your changes as Docker images with:

docker compose build
docker compose push

Permissions Issues

You may run into permissions issues and occasional warnings when uploading or saving content via WP Admin. These are generally caused by discrepancies between the session user on the host and the container and should not occur in production. To fix this we’ll need to map our local user name, group and ID to the WordPress container, in the wp.dockerfile we’ve created earlier. Hold on to your hats cause this is gonna get bumpy:

FROM wordpress:6.4-fpm

ARG UID
ARG GID
ARG WEBUSER
ARG WEBGROUP

# try the ENV vars or default
ARG WEBUSER=${WEBUSER:-yourHostUserName}
ARG WEBGROUP=${WEBGROUP:-yourHostGroupName}
ARG UID=${UID:-1000}
ARG GID=${GID:-1000}

# create matching user and group
RUN addgroup --gid ${GID} --system ${WEBUSER}
RUN adduser --disabled-password --gecos '' --uid ${UID} --ingroup ${WEBGROUP} ${WEBUSER}
RUN chown -R ${WEBUSER}:${WEBGROUP} .

# update PHP-FPM user
RUN sed -ri -e "s!user = www-data!user = ${WEBUSER}!g" /usr/local/etc/php-fpm.d/www.conf
RUN sed -ri -e "s!group = www-data!group = ${WEBGROUP}!g" /usr/local/etc/php-fpm.d/www.conf

WORKDIR /var/www/html
RUN echo "WordPress Ready!"

Sure there are a few extra lines in there, but we would rather use the .env file than hardcode our username and use id into the Dockerfile. So now the environments variable we need are:

UID=1000
GID=1000
WEBUSER=yourHostUserName
WEBGROUP=yourHostGroupName

DB_PORT=3306
DB_HOST=database
DB_NAME=the-name-of-the-database
DB_USERNAME=user-with-access-to-db
DB_PASSWORD=your-password

How do I find my username, user id, or group name? Well, on Unix terminals it’s usually the id command in conjuncture with -u or -g flags to get ether the user or group IDs; for the actual names add -n flag into the mix:

id -u # user id
id -g # group id
id -u -n # user name
id -g -n # group name

Bonus

On most Linux distributions you can also implement this in your docker-compose.yml file to circumvent the need for new environment variables (but in my experience that does not always work, oddly):

services:
  ...
  wordpress:
   ...
    build:
      context: .
      dockerfile: wp.dockerfile
      args:
        - UID=${UID:-(id -u)}
        - GID=${GID:-(id -g)}
        - WEBUSER=${WEBUSER:-(id -un)}
        - WEBGROUP=${WEBGROUP:-(id -gn)}
    image: dockerhub-username/wordpress:local
....

GitHub Gist:

gist.github.com/mariuscucuruz

Hope you’ve found it useful.
Cheers