Wordpress, on Docker, on a Droplet

Dank Tec
7 min readJan 3, 2024

--

Wordpress has been around a long time. It’s one of the most popular platforms on the internet. In this post we’re going to configure it to run completely inside Docker containers, upon a Digital Ocean Droplet using LetsEncrypt and Certbot to automate our digital certificate management.

LEMP Stack with Docker

The goal is to make this deployment as simple, lightweight and automated as possible, using the latest technologies, thus resulting in the lowest cost for hosting, ease of portability to a new provider.

The Stack

We’re going to be using the following technologies in this project, I’ll talk about the reasoning behind each choice:

  • Terraform
  • Docker & Docker Compose
  • Digital Ocean
  • LetsEncrypt & Certbot
  • MySQL
  • PHP-FPM
  • Nginx
  • Cloud-Init / Bash

Step One: A host!

The first thing we need is a system to host our software. We’re not going to entertain anything too complex like container orchestration because it’s unnecessary and would be too costly. The goal is to run this on the smallest, cheapest server available. Digital Ocean is our choice for now, we are using Terraform to orchestrate a Digital Ocean droplet, DNS records and bootstrapping our OS with UserData. Terraform is mature and highly compatible across cloud providers and very easy to use.

main.tf

terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}
# This is set in the environment with 'export TF_VAR_DoToken=123'
variable "DoToken" {
type = string
}
provider "digitalocean" {
token = var.DoToken
}

resource "digitalocean_droplet" "wordpress_docker" {
image = "debian-10-x64"
name = "debian-wordpress"
region = "sfo3"
ipv6 = false
# size = "s-1vcpu-512mb-10gb" # This is too little memory for MySQL
size = "s-1vcpu-1gb"
ssh_keys = [1234567] # the key id can be found from the DO API
tags = ["name:wordpress"]
user_data = file("userdata.sh")
}

output "wordpress_docker_host" {
value = digitalocean_droplet.wordpress_docker[*].ipv4_address
}

# DNS A Records
resource "digitalocean_domain" "wordpress" {
name = "mydomain.test"
ip_address = digitalocean_droplet.wordpress_docker.ipv4_address
}

resource "digitalocean_record" "www" {
domain = digitalocean_domain.wordpress.id
type = "A"
name = "www"
value = digitalocean_droplet.wordpress_docker.ipv4_address
}

Export the DO API key and apply the config to launch the server

export TF_VAR_DoToken=123
terraform apply

Userdata / CloudInit

CloudInit is a tool built into all cloud hosting providers which executes code supplied in the user_data field on first boot. This is a great way to bootstrap our server by installing dependencies, while keeping config in code. It gives us the flexibility to destroy the server and start again, or rebuild it on a new platform and get a consistent environment each time.

userdata.sh

#!/bin/bash
echo "Starting UserData Script $(date -R)"

echo "Installing packages"
apt-get install iptables net-tools vim htop git python3-pip curl certbot -y

## Set up docker on Debian
apt-get update
apt-get install ca-certificates curl gnupg -y
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null

apt-get update

apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y

curl -SL https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

echo "Completed at $(date -R)"

After the server boots, dependencies will be installed. This does take some time, and the solution for performance improvement would be to “bake” some or all of this configuration into a machine image. This increases complexity while improving boot times, and we are not entertaining this method for this simple project. Progress can be monitored in the cloud-init log file.

Step Two: Docker!

We have set up docker and docker-compose. Now we are ready to design a stack of containers which interact with each other to host Wordpress.

docker-compose.yml

version: "3.8"
services:
web:
image: nginx:1.24-perl
restart: always
volumes:
- ./mydomain.test:/var/www/html
- ./default.conf:/etc/nginx/conf.d/default.conf
# Certs mapped from server to container
- /etc/letsencrypt/live/mydomain.test/fullchain.pem:/etc/letsencrypt/live/mydomain.test/fullchain.pem
- /etc/letsencrypt/live/mydomain.test/privkey.pem:/etc/letsencrypt/live/mydomain.test/privkey.pem
ports:
- "80:80"
- "443:443"
environment:
- NGINX_HOST=mydomain.test
- NGINX_PORT=80
links:
- php-fpm

First we start with the ‘web’ service which runs on Nginx. We map a default Nginx config as well as the digital certificates from the host to the container. Web ports are exposed publicly and we create a network link to the php-fpm container.

default.conf

server {
if ($host = www.mydomain.test) {
return 301 https://$host$request_uri;
}
if ($host = mydomain.test) {
return 301 https://$host$request_uri;
}

listen 80;
server_name mydomain.test www.mydomain.test;

root /var/www/html/;

location ~ /.well-known {
allow all;
}
}

server {
listen 443 ssl;
server_name mydomain.test www.mydomain.test;

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

client_max_body_size 10m;

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

location ~* \.php$ {
include fastcgi_params;
fastcgi_pass php-fpm:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
}
}

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;

ssl_certificate /etc/letsencrypt/live/mydomain.test/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mydomain.test/privkey.pem;
}

We are creating two servers, one listens on port 80 and the other on 443. Any web request to the unsecured server will be redirected to the secure one. The digital certificate and certificate chain are hosted on the secure server with FPM invoked on port 9000 on the neighbouring ‘php-fpm’ container.

docker-compose.yml

  php-fpm:
image: php:7.4-fpm
expose:
- "9000"
restart: always
environment:
- WORDPRESS_DB_PASSWORD=${WORDPRESS_DB_PASSWORD}
volumes:
- ./mydomain.test:/var/www/html
links:
- db
command:
- /bin/bash
- -c
- |
docker-php-ext-install mysqli
docker-php-ext-enable mysqli
php-fpm

This time we use the expose keyword which only exposes port 9000 to linked containers, and not to the public. Since this container is the workhorse for PHP code execution, it requires a link to the ‘db’ container.

Since the fpm docker image does not come with the mysqli extension preinstalled we are invoking the compilation and installation each time the container starts. This is not ideal since it takes some time to run, however without building and hosting this image ourselves, this is the next best option, and I’ve chosen this as a reasonable tradeoff between complexity and performance for this guide.

docker-compose.yml

  db:
image: mysql
restart: always
ports:
- "3306:3306"
volumes:
- /var/lib/mysql:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
MYSQL_USER: test
MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
command: --innodb-buffer-pool-size=200M

Finally we define our database container. The official MySQL Docker image is very flexible, we define root and user passwords on startup, and optionally define a bind-mount volume to persist data on the host. If we don’t specify a volume, the data would be ephermally stored in the container’s volume and lost when the container is removed. Another option could be to use Docker Volumes, which would improve portability of the system. For this guide we assume a database dump will suffice.

Keep in mind we are pulling mysql:latest with this config. This means when they release a new version, it will be tagged latest and we will automatically upgrade our database when that happens (and the stack is restarted). To avoid this we should version-lock to a static tag. This is a tradeoff between staying current or staying stable.

Step Three: Certbot

Ok we are finally ready to request our digital certificate from LetsEncrypt using Certbot. Read more about digital certificate management.

certbot certonly -d mydomain.test --webroot -w /path/to/wordpress/webroot/

This operation is performed on the host. It COULD be performed inside one of the containers, but we have chosen to do this outside, and use volume mounts to share the certificates between host and container.

Once the certificates have been requested, they need to be periodically refreshed. It’s essential to renew the certificates, so this script should be installed in a monthly crontab:

certbot renew
docker restart wordpress_web_1

Certificate requests have rate-limits, so performing this operation monthly will mean it only gets a new certificate when LetsEncrypt determines we are close enough to the expiry date. Otherwise the operation completes silently.

The restart is required to tell NGINX to reload the new configuration containing the new certificates. There are a couple of improvements which could happen here. A process ‘reload’ command is better than restart, as restart will drop connections, where reload will not. It’s also not necessary to perform the restart/reload UNLESS there is a new cert to be loaded.

Another nice-to-have feature would be certificate expiry monitoring. If something goes wrong during our cert renewal process, we want to know about it.

A word on secrets

It’s considered best-practice to store passwords inside environment variables over code, and preferably inside a secret manager. In this project we pull all secrets from environment variables. Note that once they are generated, they are baked into the database

export WORDPRESS_DB_PASSWORD=$(random password)
export MYSQL_ROOT_PASSWORD=$(random password)

Find the following line in wp-config.php and replace it with the following getenv() function to pull the database password from the environment.

/** wp-config.php */
/** MySQL database password */
define( 'DB_PASSWORD', getenv('WORDPRESS_DB_PASSWORD') );

Once everything has been set up, we can start our stack with:

docker-compose up -d

To view the logs generated by each container we can run:

docker-compose logs -f

Conclusion

There you have it! A tried-and-true L(N)MP stack wrapped up in Docker with a free and eternal digital certificate!

If we find a cheaper or more performant cloud hosting provider, we can quickly and easily move our stack to a new host because all configuration is centrally managed, and will behave the same way on different underlying systems.

If you have any ideas or suggestions to improve this please leave a comment! The full source code/repo is available at: https://github.com/danktec/wordpress-docker

If you enjoyed this article, subscribe to the DankTec SubStack

--

--

Dank Tec

Hi Friends! I dig into the trenches of nuanced topics to delivering concise articles to help you contend with a complex landscape https://danktec.substack.com/