Skip to main content
ramit's kb

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.

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

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.