We run joyofexploringtheworld.com — a travel blog — on a single 24 GB VPS using Docker Compose and free-tier Cloudflare. No managed WordPress hosting, no premium plugins, no surprise invoices. Here is the full stack, laid out so you can steal whatever is useful.

Architecture overview Link to heading

The request path looks like this:

Internet
  -> Cloudflare (CDN / APO / edge SSL)
    -> Traefik v3 (reverse proxy, TLS termination for origin)
      -> 2 WordPress containers (round-robin with sticky session cookies)
        -> MariaDB 11 (single-writer database)
        -> Redis (persistent object cache)

Alongside the main request path we run a handful of supporting services: imgproxy on a dedicated subdomain for on-the-fly image resizing and format conversion, a wp-cron sidecar that hits wp-cron.php every five minutes so we can disable the default front-end cron, an automated backup container that dumps the database and syncs uploads to Hetzner Storage Box nightly, and the Datadog agent for logs, traces, and container metrics.

Everything is defined in a single docker-compose.yml. The full sanitised config lives in the companion GitHub repo: wordpress-docker-stack.

The services Link to heading

The stack has ten services (nine named, plus WordPress scaled to two replicas). Here is what each one does.

redis Link to heading

An Alpine Redis 7 instance used as the WordPress object cache backend via the redis PHP extension. It listens on a private Docker network only — no port is exposed to the host. We pin maxmemory at 128 MB with an allkeys-lru eviction policy so it never eats into the VPS RAM budget.

wordpress (x2) Link to heading

Two replicas of our custom WordPress image, load-balanced by Traefik with sticky session cookies so logged-in users always hit the same container. Each replica mounts the same wp-content volume for shared uploads and plugins. Environment variables wire up the database, Redis, and Datadog connection details.

db (MariaDB) Link to heading

MariaDB 11 with a tuned InnoDB buffer pool and redo log config. The data directory lives on a named volume so it survives container recreation. A custom healthcheck runs healthcheck.sh to make sure the container only reports healthy once it can actually accept queries.

reverse-proxy (Traefik v3) Link to heading

Traefik handles TLS certificate management via Let’s Encrypt, routes traffic to WordPress and imgproxy based on hostname rules, and applies middlewares for real-IP forwarding and gzip/brotli compression. It exposes ports 80 and 443 on the host, and its dashboard is locked behind basic auth on an internal port.

imgproxy Link to heading

A self-hosted imgproxy instance that serves optimised images (WebP/AVIF, resized, stripped of metadata) from a dedicated subdomain. WordPress source images are read from the shared uploads volume. A companion MU-plugin rewrites image URLs at render time so visitors get optimised versions without any manual work.

datadog-agent Link to heading

The Datadog agent container collects container logs via the Docker socket, APM traces from the PHP tracer baked into the WordPress image, and host-level metrics. We keep profiling and AppSec disabled to save overhead on a budget VPS — tracing alone gives us enough visibility to catch slow queries and plugin regressions.

backup Link to heading

A lightweight Alpine container that runs on a cron schedule via ofelia labels. Each night it dumps the MariaDB database with mariadb-dump, compresses it, and rsyncs both the dump and the wp-content/uploads directory to a Hetzner Storage Box over SSH. Old backups are pruned after 14 days.

wp-cron Link to heading

A tiny sidecar that curls wp-cron.php on the internal Docker network every five minutes. This lets us set DISABLE_WP_CRON=true in the WordPress containers so scheduled tasks (newsletter sends, post scheduling, EDD cleanup) run reliably without depending on visitor traffic to trigger them.

wpcli Link to heading

An on-demand utility container for running WP-CLI commands (wp plugin update --all, wp cache flush, database migrations, etc.) without shelling into a live WordPress container. It shares the same volumes and environment variables, so it sees the exact same WordPress install.

The Dockerfile Link to heading

The entire custom image is nine lines:

FROM wordpress:latest

RUN curl -sSLf https://github.com/DataDog/dd-trace-php/releases/latest/download/datadog-setup.php -o /tmp/datadog-setup.php \
    && php /tmp/datadog-setup.php --php-bin=all --enable-appsec --enable-profiling 2>&1 \
    && rm -f /tmp/datadog-setup.php

RUN pecl install redis \
    && docker-php-ext-enable redis

We bake both the Datadog PHP tracer and the Redis PECL extension into the image at build time. Early on we tried installing them at container startup via an entrypoint script. That added 30-40 seconds to every container start and, worse, caused 5xx errors during rolling restarts because Traefik would route traffic to a container that was technically “running” but still compiling extensions. Building them into the image means the container is ready to serve requests the moment it starts.

Key config files Link to heading

The repo includes a handful of config files that get bind-mounted into the relevant containers. Each one is small and focused on a single concern.

  • config/health.php — A minimal PHP health-check endpoint that returns 200 OK if WordPress can connect to the database. Traefik uses this for its container healthcheck so it only routes to healthy replicas. View in repo.

  • config/php/99-opcache.ini — OPcache tuning: opcache.memory_consumption=128, revalidate_freq=60, max_accelerated_files=10000. These settings keep compiled PHP bytecode in shared memory and avoid hammering stat() on every request. View in repo.

  • config/apache/01-static-cache-headers.conf — Sets far-future Cache-Control and Expires headers on static assets (images, CSS, JS, fonts) so Cloudflare and browsers cache them aggressively. View in repo.

  • config/mariadb/conf.d/98-innodb-tuning.cnf — InnoDB buffer pool sized to roughly 50-60% of available RAM, plus redo log and flush settings tuned for a write-light WordPress workload. View in repo.

  • config/traefik/real-ip.yaml — A Traefik file provider that defines two middlewares: one to extract the real client IP from the CF-Connecting-IP header (since all traffic arrives via Cloudflare), and one for gzip and brotli compression on responses. View in repo.

MU-plugins Link to heading

We use two custom must-use plugins that live in wp-content/mu-plugins/ and load automatically on every request.

imgproxy-rewrite.php Link to heading

This plugin hooks into WordPress’s image output pipeline and rewrites image URLs at render time to point at our self-hosted imgproxy instance. A source URL like https://joyofexploringtheworld.com/wp-content/uploads/2025/12/photo.jpg becomes https://img.joyofexploringtheworld.com/insecure/rs:fill:800:600/plain/local:///uploads/2025/12/photo.jpg@webp. The visitor gets a properly sized, modern-format image without us having to generate thumbnails at upload time or install a heavy optimisation plugin.

asset-optimizer.php Link to heading

A front-end performance MU-plugin that handles several small but impactful optimisations: deferring render-blocking scripts, async-loading CSS, promoting the LCP image with a fetchpriority="high" attribute, and pruning unused Google Fonts weights. Together these changes shaved a couple of seconds off our Largest Contentful Paint without touching theme files.

Infrastructure as Code Link to heading

The terraform/ directory in the repo manages our Cloudflare configuration declaratively:

  • DNS records — A/AAAA records pointing to the VPS, CNAME for the imgproxy subdomain, and MX records for email.
  • Redirect rules — www-to-apex 301 redirects handled at the Cloudflare edge so they never hit the origin.
  • Cache rules — Page rules and cache rules that control Cloudflare APO behaviour, bypass caching for logged-in users, and set TTLs for static assets.

Managing these in Terraform means we can review changes in a PR, roll back mistakes with terraform apply, and keep the Cloudflare config version-controlled alongside the Docker stack.

Deep dives Link to heading

Each part of this stack has its own post with the full story, gotchas, and config snippets:

Get the full config Link to heading

The entire stack — docker-compose.yml, Dockerfile, config files, MU-plugins, Terraform, and a detailed README — is available in the companion repo:

github.com/luismsousa/wordpress-docker-stack

Clone it, swap in your own domain and credentials, and you have a production WordPress setup on a single VPS for the cost of the server alone.


Built for a travel blog on a budget. This stack powers Joy of Exploring the World — curated travel itineraries, restaurant reviews, and destination guides. If you're planning your next trip, come explore with us.

All config files from this post are in the companion GitHub repo.