Ansible tutorial: setting up Docker, MySQL and WordPress with Ansible - Feature image

Ansible Tutorial: Setting up Docker, MySQL, and WordPress with Ansible [Updated 2020]

von Sven Woltmann – 1. November 2018

Article Series: Ansible Tutorial

Part 1: Setting up a Server with Ansible

Part 2: User Accounts, SSH, and Firewall

Part 3: Docker, MySQL, and WordPress

Part 4: HAProxy + HTTPS from Let’s Encrypt

(Sign up for the HappyCoders Newsletter
to be immediately informed about new articles.)

In the first two parts of this tutorial series, I showed you how I installed the operating system image with Ansible on a root server of Hetzner and created user accounts, optimized the SSH configuration and configured the firewall. In this third part of the Ansible tutorial, I will show you how to install Docker with Ansible and – on top of that – MySQL and WordPress as Docker containers. Since this is much easier than I thought, this article will be shorter than the previous ones.

As always, you can find the code to the article in my GitLab repository:

And in addition to the article, there is again a (German-only) video tutorial: Setup of Docker, MySQL, and WordPress with Ansible

Why WordPress?

First of all, you might ask yourself why I, as a Java developer, am using a content management system developed in PHP. The answer is: it has the largest market share (almost 60 percent), meets all my requirements, is quick to set up, expandable with thousands of plugins and themes and has excellent community support. Here is a detailed comparison of WordPress with Joomla and Drupal.

In the Java area, there are hobby projects on the one hand, and heavyweights such as Magnolia, OpenCMS or Bloomreach Experience Manager (formerly Hippo CMS) on the other, which are more geared to the needs of large companies. These are much more complex to set up, and, due to low market share (less than one percent), the support community is also many times smaller.

I would like to install the whole thing as a Docker container and of course again via Ansible.

Installing Docker

First of all, I need to install Docker. To do this, I'll use an Ansible role from Jeff Geerling again: Docker Ansible Role. The role is installed as follows (on my developer machine – not the server):

ansible-galaxy install geerlingguy.dockerCode language: plaintext (plaintext)
Installing "geerlingguy.docker" from the Ansible Galaxy
Installing "geerlingguy.docker" from the Ansible Galaxy

To run the role on my server, I add the entry gerlingguy.docker to the array roles in the happy1.yml file, which I created in the previous article in the series. The file now looks like this:

- hosts:
    ansible_port: "{{ ssh_port | default('22') }}"
  remote_user: sven
  become: yes
    - hostnames
    - users
    - ssh
    - aliases
    - tools
    - geerlingguy.firewall
    - geerlingguy.dockerCode language: YAML (yaml)

I would like to decide for myself which Docker and Docker Compose versions will be installed. I can do this using the docker_package and docker_compose_version variables. This is how I find the version numbers:

  • I can't easily find the Docker version in advance because the Docker repository hasn't been installed yet. However, I can also run the role without specifying a version, which will automatically install the latest version. I can fill in the version number later.
  • Docker Compose version: I can find this on the Docker Compose page in GitLab. At the current time (September 2018), this is 1.22.0.

At first, I leave out the Docker version number, which I would write behind docker-ce. I enter the Docker Compose version number into my host variable file host_vars/ as follows:

docker_package: docker-ce
docker_compose_version: 1.22.0Code language: SubUnit (subunit)

I also want to use Docker as the sven user, and for this, I need the following entry directly below:

  - svenCode language: YAML (yaml)

Now I run the playbook as follows:

ansible-playbook happy1.ymlCode language: plaintext (plaintext)

This time, you have to be patient – the task "Install Docker" can take up to ten minutes.

Installing Docker via Ansible
Installing Docker via Ansible

Now I can easily find out which Docker version was installed with dpkg -s docker-ce | grep Version:

Find out the installed Docker version
Find out the installed Docker version

I append this version with an equal sign to the docker_package variable, so that my host variable file host_vars/ now contains the following entries regarding Docker:

docker_package: docker-ce=18.06.1~ce~3-0~debian
docker_compose_version: 1.22.0
  - svenCode language: YAML (yaml)

It is not necessary to run the playbook again, as the latest version is already installed. The installation of Docker is now complete.

Ansible Firewall and Docker Rules

It is important to note at this point that Docker adds some iptables entries:

Firewall rules added by Docker
Firewall rules added by Docker

These are, unfortunately, deleted by running the Ansible firewall role, which I configured in the "Firewall with Ansible" tutorial. The problem is known (, but the workaround specified there does not work: contrary to the developer's explanation, Docker is not restarted after the firewall has been restarted, but only when Docker has been updated. Therefore, it is currently necessary to manually restart Docker using service docker restart when the firewall rules have been changed.

My opinion:

A Docker restart is not problematic because the actual containers are not restarted. However, there is an interruption of the service for a few seconds, which is acceptable for a private blog. This would not be acceptable for a large corporate site. There, the better solution would be that the firewall role does not delete the firewall rules and create them completely from scratch, but instead adds new ones and deletes those that are no longer needed. Maybe I'll take that up in a future article.

Installing MySQL and WordPress

For the installation of WordPress, I follow this article: Quickstart: Compose and WordPress. Therefore, I create an Ansible role wordpress_docker and copy the docker-compose.yml from the above article into the template roles/wordpress_docker/templates/docker-compose.yml.j2 (you will find the code below).

I change the port from 8000 to 8001 because I will need port 8000 in the next article for Certbot, and I found no way to change the Certbot port.

I want to extract the MySQL version and the passwords into variables. But the passwords shouldn't be stored in plain text. Therefore, I encrypt them with ansible-vault. With the following command, I encrypt the password 123456 and store it in the variable mysql_root_password:

ansible-vault encrypt_string '123456' --name 'mysql_root_password'Code language: plaintext (plaintext)

I also encrypt the password for the wordpress user. Of course, you should replace the sample passwords with your own passwords.

Encrypting passwords with ansible-vault
Encrypting passwords with ansible-vault

I copy the MySQL version from docker-compose.yml and the just encrypted passwords into my host variable file. The depth of the indentation under the variable names (starting with $ANSIBLE_VAULT) is not important, so it is sufficient to insert two spaces before the variable names if you want to group the password variables under a parent variable:

  mysql_version: 5.7
  mysql_root_password: !vault |
  mysql_wordpress_password: !vault |
  wordpress_version: latestCode language: YAML (yaml)

The finished template roles/wordpress_docker/templates/docker-compose.yml.j2 looks like this:

version: '3.3'

    image: mysql:{{ wordpress_docker.mysql_version }}
      - db_data:/var/lib/mysql
    restart: always
      MYSQL_ROOT_PASSWORD: {{ wordpress_docker.mysql_root_password }}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: {{ wordpress_docker.mysql_wordpress_password }}

      - db
    image: wordpress:latest
      - "8001:80"
    restart: always
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: {{ wordpress_docker.mysql_wordpress_password }}
Code language: YAML (yaml)


I have not yet created any tasks for the wordpress_docker role. I will do that now. There is not much to do: create a directory, copy the docker-compose.yml file and call docker-compose up. I do this with the following three tasks in the file roles/wordpress_docker/tasks/main.yml:

Task 1 – Create directory:

- name: Create docker/wordpress directory
    path: /opt/docker/wordpress
    state: directory
    owner: root
    group: root
    mode: 0755Code language: YAML (yaml)

Task 2 – Copy docker-compose file:

- name: Create docker-compose file
    src: docker-compose.yml.j2
    dest: /opt/docker/wordpress/docker-compose.yml
    owner: root
    group: root
    mode: 0644Code language: YAML (yaml)

Task 3 – Execute docker-compose up:

- name: Run docker-compose up -d
  shell: docker-compose up -d
    chdir: /opt/docker/wordpress/Code language: YAML (yaml)

The used Ansible modules file, template and shell were explained in the previous articles.

Opening the Firewall

I have configured the WordPress Docker container to be accessible on port 8001. I have to open this port temporarily in the firewall to test the WordPress installation. Temporarily because in the next article, I will set up HAProxy to redirect port 80 to 8001, and then I can close port 8001 again for external access.

To open the port, I have to add the value 8001 to the array firewall_allowed_tcp_ports in the host variable file so that the entry now looks like this:

  - 80
  - 443
  - 5930
  - 8001Code language: YAML (yaml)

Running the Playbook

If I tried to run the playbook now, it would abort with an error message, "Failed to program FILTER chain: iptables failed […]". I have mentioned the reason above: Changing the firewall rules deletes the Docker rules, so the attempt to call docker-compose up fails. I will therefore temporarily comment out the wordpress_docker role in the playbook happy1.yml by prefixing it with a #:

#    - wordpress_dockerCode language: YAML (yaml)

Now I can run the playbook to first change the firewall rules:

ansible-playbook happy1.ymlCode language: plaintext (plaintext)

I won't insert a screenshot this time. The following two tasks report the status "changed":

  • geerlingguy.firewall : Copy firewall script into place.
  • geerlingguy.firewall : restart firewall

I now log on to the server in order to

  1. check if port 8001 has been opened,
  2. restart the Docker service, and to
  3. check if the Docker firewall rules are active again:
ssh -p 5930 [email protected]
sudo iptables -L
sudo service docker restart
sudo iptables -LCode language: plaintext (plaintext)
Checking the firewall rules and restarting Docker
Checking the firewall rules and restarting Docker

It's all what I expected: port 8001 was opened, and the Docker settings were missing at first. After restarting Docker, these settings are present again.

Now I can reactivate the previously commented wordpress_docker role in the playbook happy1.yml and run the playbook again. This time, I have to add the parameter --ask-vault-pass so that Ansible can decrypt the encrypted passwords:

ansible-playbook --ask-vault-pass happy1.ymlCode language: plaintext (plaintext)
Installing MySQL and WordPress via Ansible
Installing MySQL and WordPress via Ansible

It'll take a while for the containers to run. The progress can be monitored on the server as follows:

cd /opt/docker/wordpress
docker-compose logs -fCode language: plaintext (plaintext)
Calling "docker-compose logs -f"
Calling "docker-compose logs -f"

I'll ignore the warnings for now. After about a minute, the message appears that the database is ready for connections. The WordPress installation is now available at

WordPress is installed and ready for configuration
WordPress is installed and ready for configuration

Persistent Data of the Docker Containers

Where are the data of the Docker containers located? In the docker-compose file of the db container, the directory /var/lib/mysql was mounted to the volume db_data. But where exactly is this volume? To find out, I first determine the container ID using docker ps and then run docker inspect -f '{{ .Mounts }}' <container ID> to read the mountpoint:

Reading the mountpoint with "docker ps" and "docker inspect"
Reading the mountpoint with "docker ps" and "docker inspect"

So the MySQL data is located at /var/lib/docker/volumes/wordpress_db_data/_data and would survive deleting and re-creating the db container. And the data of the WordPress container? There is no volume mounted in the docker-compose file, but docker inspect shows me a mountpoint:

Retrieving the mountpoint of the Docker container with "docker inspect"
Retrieving the mountpoint of the Docker container with "docker inspect"

Where is this mountpoint set? Very simple: in the Dockerfile file of the WordPress image. The WordPress files are therefore also persistent. In addition, I will, of course, install a good backup plugin, which will not only back up the WordPress installation but also the MySQL database.

[Update] Upgrading PHP within the Docker WordPress Container

When I wrote this article, the WordPress Docker image was created based on PHP 7.2. Currently, the image is based on PHP 7.3, but it is not easy – and not necessary – to upgrade PHP within the WordPress container.

Instead, you can upgrade the WordPress image itself to get the latest PHP version.

Just download the latest WordPress image:

docker pull wordpressCode language: plaintext (plaintext)

Then change to the directory where the docker-compose file is located and update the container. You do not need to worry about your wp-content directory; it is safely located in a volume outside the container (as demonstrated in the previous section).

cd /opt/docker/wordpress
docker-compose up -dCode language: plaintext (plaintext)

That's all – your website now runs on the latest PHP version.

Summary and Outlook

In this third article of the series, I showed how to install Docker with Ansible and how to install and start MySQL and WordPress as Docker images using a docker-compose file.

I will not go deeper into the configuration of WordPress here. You will find enough reading material on the internet. But I'd like to share with you the theme and a list of the plugins I use for this blog.

[Update: I have updated this list in April 2020 because I have replaced some plugins over time - either for legal reasons (e.g., the cookie notice) or because other plugins do the job much better (e.g., SEOPress instead of Yoast SEO). In brackets, you can find the formerly used plugins.]

I have removed these plugins without replacement:

  • Sticky navigation bar: myStickymenu (removed because I found the sticky navigation to be superfluous)
  • Social media follow icons in the footer: Easy Social Icons (removed; I now integrate them as HTML code and thus save a plugin)
  • FancyBox: Easy FancyBox (removed because I do not need the functionality)
  • Duplicating posts to translate them: Duplicate Post (removed because this function is included in WPML)

That concludes this article. In the fourth and final part, I will set up HAProxy and a free HTTPS certificate from Let's Encrypt, so that the website will be accessible via the standard HTTP/HTTPS ports.

If you liked this third part of the series, feel free to leave me a comment or share the article using one of the share buttons at the end.

Do you want to be informed when new articles are published on Then click here to sign up for the newsletter.