Self-host Wordpress using Docker Compose
Bonjour! We all know using Docker is probably the coolest way to spin up your projects and self-host them.
I recently purchased a Raspberry Pi 5 and I wanted to re-visit hosting Wordpress using docker containers only.
I have (probably) over-engineered the setup because I was trying to try out something cool (not necessarily useful) infrastructure design pattern. Please feel free to adapt it to your use-case.
Tech stack #
As I mentioned, everything is running using containers. Note, am using arm64v8
images since am running this on Raspberry Pi; if you're on x64
please adapt the image names accordingly.
mariadb
as database, using imagearm64v8/mariadb:latest
redis
for caching, using imagearm64v8/redis:latest
varnish
for http caching and proxy, using imagearm64v8/varnish:latest
wordpress
using imagearm64v8/wordpress:latest
Request flow summary #
Domain (managed in Cloudflare)
=> Cloudflare tunnel
=> Raspberry Pi 5
=> varnish exposed on port 1001
=> varnish internally connects to one of the 3 containers running Wordpress (using round robin strategy)
=> each Wordpress instance is connect to the same redis, mariadb service
- My domain is configured in
Cloudflare
and pointed to the WordPress app viaCloudflare tunnel
- The Cloudflare tunnel connects the domain to an endpoint (or, port) in my Raspberry Pi 5
- Incoming request to my domain is sent to the
varnish
endpoint running on port1001
in my Raspberry Pi viaCloudflare tunnel
- The request hits the
varnish
service exposed on port1001
varnish
then routes the request to one of the 3 containers running the WordPress app (usingdirectors
module and around robin
strategy)- Optionally, you can configure a
probe
for eachbackend
defined invcl
file - The
probe
checks if thebackend
is healthy and if it's not; varnish stops forwarding request to thatbackend
vsthrottle
module is configured to act as a DDOS prevention mechanism torate limit
incoming requests- Add a
x-backend
header to the response to provide information about whichwordpress
container handled the request
- Add a
- Optionally, you can configure a
- Each
WordPress
container (or, node) connects to the samedatabase
andredis
service ensuring data consistency amongst all the nodes
compose.yaml
#
services:
service__mariadb:
image: arm64v8/mariadb:latest
volumes:
- ./data/mariadb:/var/lib/mysql
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: aen0aiRaijiecae
MYSQL_ROOT_PASSWORD: aen0aiRaijiecae
MYSQL_DATABASE: wp
MYSQL_USER: ahch9aehegur4Uz
MYSQL_PASSWORD: xoobacai0gaiM9u
service__redis:
image: arm64v8/redis:latest
restart: unless-stopped
service__varnish:
image: arm64v8/varnish:latest
volumes:
- ./data/varnish/default.vcl:/etc/varnish/default.vcl:ro
restart: unless-stopped
ports:
- 127.0.0.1:1001:80
# tmpfs:
# - /var/lib/varnish:exec
environment:
VARNISH_SIZE: 2G
depends_on:
- service__mariadb
- service__redis
- app__wordpress_une
- app__wordpress_deux
- app__wordpress_trois
# running the app container x3
# wordpress app instance 1
app__wordpress_une:
image: arm64v8/wordpress:latest
volumes:
- ./data/wordpress:/var/www/html:consistent
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: service__mariadb
WORDPRESS_DB_NAME: wp
WORDPRESS_DB_USER: ahch9aehegur4Uz
WORDPRESS_DB_PASSWORD: xoobacai0gaiM9u
WP_REDIS_HOST: service__redis
depends_on:
- service__mariadb
- service__redis
# wordpress app instance 2
app__wordpress_deux:
image: arm64v8/wordpress:latest
volumes:
- ./data/wordpress:/var/www/html:consistent
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: service__mariadb
WORDPRESS_DB_NAME: wp
WORDPRESS_DB_USER: ahch9aehegur4Uz
WORDPRESS_DB_PASSWORD: xoobacai0gaiM9u
WP_REDIS_HOST: service__redis
depends_on:
- service__mariadb
- service__redis
# wordpress app instance 3
app__wordpress_trois:
image: arm64v8/wordpress:latest
volumes:
- ./data/wordpress:/var/www/html:consistent
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: service__mariadb
WORDPRESS_DB_NAME: wp
WORDPRESS_DB_USER: ahch9aehegur4Uz
WORDPRESS_DB_PASSWORD: xoobacai0gaiM9u
WP_REDIS_HOST: service__redis
depends_on:
- service__mariadb
- service__redis
default.vcl
#
Here am creating three backends to route the incoming traffic to - backend1
, backend2
& backend3
. For each backend
you can add a probe
to check the health of that app backend. Backends that are not healthy are not sent any incoming request by varnish. Am not using it since my site is protected by HTTP auth and the probe
will not able to connect and cause varnish
to think all backends are down.
Using the varnish directors
module, I route the traffic to a backend
using round_robin
strategy. Check the config in vcl_init
and vcl_recv
.
Am also using the vsthrottle
to configure rate limiting
for the incoming requests. DDOS protection is extremely necessary and yet often overlooked.
vcl 4.1;
import std;
import directors;
import vsthrottle;
# Inspiration
# https://www.varnish-software.com/developers/tutorials/configuring-varnish-wordpress/#4-custom-wordpress-vcl
# https://www.varnish-software.com/developers/tutorials/multiple-backends/
# backend default {
# .host = "app__wordpress_une";
# .port = "80";
# }
backend backend1 {
.host = "app__wordpress_une";
.port = "80";
# .probe = {
# .url = "/";
# .timeout = 5s;
# .interval = 15s;
# .window = 5;
# .threshold = 3;
# }
}
backend backend2 {
.host = "app__wordpress_deux";
.port = "80";
# .probe = {
# .url = "/";
# .timeout = 5s;
# .interval = 15s;
# .window = 5;
# .threshold = 3;
# }
}
backend backend3 {
.host = "app__wordpress_trois";
.port = "80";
# .probe = {
# .url = "/";
# .timeout = 5s;
# .interval = 15s;
# .window = 5;
# .threshold = 3;
# }
}
# Add hostnames, IP addresses and subnets that are allowed to purge content
acl purge {
"localhost";
"127.0.0.1";
"blog.ramit.io";
"wp.ramit.app";
"::1";
}
sub vcl_init {
new vdir = directors.round_robin();
vdir.add_backend(backend1);
vdir.add_backend(backend2);
vdir.add_backend(backend3);
}
sub vcl_recv {
# Set the backend
set req.backend_hint = vdir.backend();
# Rate limit requests
# Allow max 30 requests per 15 seconds
# If it exceeds, block for 123 seconds
if (vsthrottle.is_denied(req.http.Cf-Connecting-Ip, 30, 15s, 123s)) {
return (synth(429, "Too Many Requests, slow down"));
}
# Remove empty query string parameters
# e.g.: www.example.com/index.html?
if (req.url ~ "\?$") {
set req.url = regsub(req.url, "\?$", "");
}
# Remove port number from host header
set req.http.Host = regsub(req.http.Host, ":[0-9]+", "");
# Sorts query string parameters alphabetically for cache normalization purposes
set req.url = std.querysort(req.url);
# Remove the proxy header to mitigate the httpoxy vulnerability
# See https://httpoxy.org/
unset req.http.proxy;
# Add X-Forwarded-Proto header when using https
if (!req.http.X-Forwarded-Proto) {
if(std.port(server.ip) == 443 || std.port(server.ip) == 8443) {
set req.http.X-Forwarded-Proto = "https";
} else {
set req.http.X-Forwarded-Proto = "http";
}
}
# Purge logic to remove objects from the cache.
# Tailored to the Proxy Cache Purge WordPress plugin
# See https://wordpress.org/plugins/varnish-http-purge/
if(req.method == "PURGE") {
if(!client.ip ~ purge) {
return(synth(405,"PURGE not allowed for this IP address"));
}
if (req.http.X-Purge-Method == "regex") {
ban("obj.http.x-url ~ " + req.url + " && obj.http.x-host == " + req.http.host);
return(synth(200, "Purged"));
}
ban("obj.http.x-url == " + req.url + " && obj.http.x-host == " + req.http.host);
return(synth(200, "Purged"));
}
# Only handle relevant HTTP request methods
if (
req.method != "GET" &&
req.method != "HEAD" &&
req.method != "PUT" &&
req.method != "POST" &&
req.method != "PATCH" &&
req.method != "TRACE" &&
req.method != "OPTIONS" &&
req.method != "DELETE"
) {
return (pipe);
}
# Remove tracking query string parameters used by analytics tools
if (req.url ~ "(\?|&)(utm_source|utm_medium|utm_campaign|utm_content|gclid|cx|ie|cof|siteurl)=") {
set req.url = regsuball(req.url, "&(utm_source|utm_medium|utm_campaign|utm_content|gclid|cx|ie|cof|siteurl)=([A-z0-9_\-\.%25]+)", "");
set req.url = regsuball(req.url, "\?(utm_source|utm_medium|utm_campaign|utm_content|gclid|cx|ie|cof|siteurl)=([A-z0-9_\-\.%25]+)", "?");
set req.url = regsub(req.url, "\?&", "?");
set req.url = regsub(req.url, "\?$", "");
}
# Only cache GET and HEAD requests
if (req.method != "GET" && req.method != "HEAD") {
set req.http.X-Cacheable = "NO:REQUEST-METHOD";
return(pass);
}
# Mark static files with the X-Static-File header, and remove any cookies
# X-Static-File is also used in vcl_backend_response to identify static files
if (req.url ~ "^[^?]*\.(7z|avi|bmp|bz2|css|csv|doc|docx|eot|flac|flv|gif|gz|ico|jpeg|jpg|js|less|mka|mkv|mov|mp3|mp4|mpeg|mpg|odt|ogg|ogm|opus|otf|pdf|png|ppt|pptx|rar|rtf|svg|svgz|swf|tar|tbz|tgz|ttf|txt|txz|wav|webm|webp|woff|woff2|xls|xlsx|xml|xz|zip)(\?.*)?$") {
set req.http.X-Static-File = "true";
unset req.http.Cookie;
return(hash);
}
# No caching of special URLs, logged in users and some plugins
if (
req.http.Cookie ~ "wordpress_(?!test_)[a-zA-Z0-9_]+|wp-postpass|comment_author_[a-zA-Z0-9_]+|woocommerce_cart_hash|woocommerce_items_in_cart|wp_woocommerce_session_[a-zA-Z0-9]+|wordpress_logged_in_|comment_author|PHPSESSID" ||
req.http.Authorization ||
req.url ~ "add_to_cart" ||
req.url ~ "edd_action" ||
req.url ~ "nocache" ||
req.url ~ "^/addons" ||
req.url ~ "^/bb-admin" ||
req.url ~ "^/bb-login.php" ||
req.url ~ "^/bb-reset-password.php" ||
req.url ~ "^/cart" ||
req.url ~ "^/checkout" ||
req.url ~ "^/control.php" ||
req.url ~ "^/login" ||
req.url ~ "^/logout" ||
req.url ~ "^/lost-password" ||
req.url ~ "^/my-account" ||
req.url ~ "^/product" ||
req.url ~ "^/register" ||
req.url ~ "^/register.php" ||
req.url ~ "^/server-status" ||
req.url ~ "^/signin" ||
req.url ~ "^/signup" ||
req.url ~ "^/stats" ||
req.url ~ "^/wc-api" ||
req.url ~ "^/wp-admin" ||
req.url ~ "^/wp-comments-post.php" ||
req.url ~ "^/wp-cron.php" ||
req.url ~ "^/wp-login.php" ||
req.url ~ "^/wp-activate.php" ||
req.url ~ "^/wp-mail.php" ||
req.url ~ "^/wp-login.php" ||
req.url ~ "^\?add-to-cart=" ||
req.url ~ "^\?wc-api=" ||
req.url ~ "^/preview=" ||
req.url ~ "^/\.well-known/acme-challenge/"
) {
set req.http.X-Cacheable = "NO:Logged in/Got Sessions";
if(req.http.X-Requested-With == "XMLHttpRequest") {
set req.http.X-Cacheable = "NO:Ajax";
}
return(pass);
}
# Remove any cookies left
unset req.http.Cookie;
return(hash);
}
sub vcl_hash {
if(req.http.X-Forwarded-Proto) {
# Create cache variations depending on the request protocol
hash_data(req.http.X-Forwarded-Proto);
}
}
sub vcl_backend_response {
# Inject header to hint which backend sent the response
set beresp.http.x-backend = beresp.backend.name;
# Inject URL & Host header into the object for asynchronous banning purposes
set beresp.http.x-url = bereq.url;
set beresp.http.x-host = bereq.http.host;
# If we dont get a Cache-Control header from the backend
# we default to 6h cache for all objects
if (!beresp.http.Cache-Control) {
set beresp.ttl = 6h;
set beresp.http.X-Cacheable = "YES:Forced";
}
# If the file is marked as static we cache it for 1 day
if (bereq.http.X-Static-File == "true") {
unset beresp.http.Set-Cookie;
set beresp.http.X-Cacheable = "YES:Forced";
set beresp.ttl = 1d;
}
# Remove the Set-Cookie header when a specific Wordfence cookie is set
if (beresp.http.Set-Cookie ~ "wfvt_|wordfence_verifiedHuman") {
unset beresp.http.Set-Cookie;
}
if (beresp.http.Set-Cookie) {
set beresp.http.X-Cacheable = "NO:Got Cookies";
} elseif(beresp.http.Cache-Control ~ "private") {
set beresp.http.X-Cacheable = "NO:Cache-Control=private";
}
}
sub vcl_deliver {
# Debug header
# if(req.http.X-Cacheable) {
# set resp.http.X-Cacheable = req.http.X-Cacheable;
# } elseif(obj.uncacheable) {
# if(!resp.http.X-Cacheable) {
# set resp.http.X-Cacheable = "NO:UNCACHEABLE";
# }
# } elseif(!resp.http.X-Cacheable) {
# set resp.http.X-Cacheable = "YES";
# }
# Cleanup of headers
unset resp.http.via;
unset resp.http.x-url;
unset resp.http.x-host;
unset resp.http.server;
unset resp.http.x-powered-by;
}
sub vcl_synth {
# Send custom HTML for rate limited response
if (resp.status == 429) {
set resp.http.Content-Type = "text/html; charset=utf-8";
set resp.http.Retry-After = "123";
set resp.body = {"
<html>
<head>
<title>Slow down</title>
</head>
<body>
<h1>My circuits are bubbling like a pot of question stew! Hold on a sec, gotta cool down before I overflow with answers.</h1>
</body>
</html>
"};
return (deliver);
}
}
Pour résumer #
Hope this post provides you with some inspiration about how to self host WordPress using Docker
. Please feel free to use the configuration files as a starting point for your own configuration.