fkr.dev

About

Hio, this is Flo. Born 1990, I like doing things and this is my blog. Into lisps, oil painting and esoteric text editors. On the interwebs I like to appear as a badger interested in cryptography. Every other decade I like spinning old jazz/funk records or programming experimental electronic music as Winfried Scratchmann. Currently located in Aix la Chapelle.

Table of Contents
    Why bother modernizing a CMS that already works
    What the twelve factors mean inside a WordPress world
    A campaign-site stack that behaves like an application
    Building, testing, and versioning the release
    Running it in production without improvisation
    Conclusions
    Sources

Modern WordPress and the Twelve Factors

Why bother modernizing a CMS that already works

The Twelve-Factor App was written as a methodology for software delivered as a service: one codebase, explicit dependencies, config in the environment, logs as streams, disposable processes, and admin work done as one-off tasks. It came out of Heroku's view of how web software should survive growth without turning into a wet cardboard box. WordPress, by contrast, grew up in a world where editing live files over FTP, clicking "Update plugin" in production, and letting the app mutate itself were considered normal adult behavior. The most influential WordPress-specific answer from Roots is refreshingly honest: strict 12-factor compliance is basically impossible, but getting most of the way there is very practical.

For a simple campaigning site, that honesty is useful. A campaign page is not a distributed social network and does not need a baroque platform. It does need a clean path from local development to staging to production, a way to version custom code, a sane separation between application code and content, and an update story that does not rely on whoever last had browser tabs open. In 12-factor terms, the goal is not to force WordPress to become something else; it is to draw hard boundaries around what is code, what is config, and what is state.

A good mental model for modern WordPress is embarrassingly simple:

# repo layout
campaign-wp/
├── compose.yaml
├── compose.prod.yaml
├── .env.example
├── app/
│   └── wp-content/
│       ├── mu-plugins/
│       │   └── campaign-health.php
│       └── themes/
│           └── campaign-2026/
│               ├── style.css
│               ├── functions.php
│               └── templates/
├── docker/
│   ├── wordpress/
│   │   ├── Dockerfile
│   │   └── wp-config.php
│   └── caddy/
│       └── Caddyfile
├── tests/
│   └── e2e/
│       └── landing-page.spec.ts
└── .github/
    └── workflows/
        └── ci.yml

That structure encodes the real operational boundary: Git owns the theme, MU plugins, Dockerfiles, tests, and deployment config; the database owns content and settings; uploads live in a persistent volume or object store; environment-specific values live outside the repo. That is already most of the modernization story.

For a small custom theme, the "application" layer is often not much more than a few PHP files and some assets, which is good news because boring code deploys better than exciting code:

<?php // app/wp-content/themes/campaign-2026/functions.php

add_action('after_setup_theme', function (): void {
    add_theme_support('title-tag');
    add_theme_support('post-thumbnails');
});

add_action('wp_enqueue_scripts', function (): void {
    wp_enqueue_style(
        'campaign-2026',
        get_stylesheet_uri(),
        [],
        '2026.05.0'
    );
});

Nothing magical happens here, and that is the point. A campaign site usually wants restrained custom code, not an interpretive dance of plugins. The more of the site that is plain, versioned theme code, the easier every later step becomes.

What the twelve factors mean inside a WordPress world

The first useful move is to group the factors into the ones WordPress can satisfy cleanly, the ones it can satisfy with discipline, and the ones that remain a little impolite no matter how nicely you talk to them. Codebase, dependencies, config, backing services, build-release-run, logs, and admin processes translate well if you stop treating production wp-admin as your package manager. Processes, concurrency, and disposability work once you externalize mutable state. Full purity remains elusive because WordPress is a CMS, and CMSs are professionally involved with state. That is not a moral failure; it is just architecture.

Codebase, dependencies, and build-release-run are where most WordPress projects go off-road. The Twelve-Factor App says one codebase in revision control and explicitly declared dependencies. WordPress absolutely can do that, but only if theme and plugin changes happen in Git and the release artifact is built from that repository. If you allow plugin installation and updates from the admin area in production, you still have a website, but not an auditable build pipeline. WordPress' own configuration constants acknowledge this tension: DISALLOW_FILE_EDIT disables the built-in editor, and DISALLOW_FILE_MODS blocks plugin and theme installation and update functionality from wp-admin. WP-CLI, meanwhile, explicitly supports runtime plugin install and update commands, which is useful for maintenance but also a reminder that WordPress will happily mutate itself if you let it.

A minimal Composer file is still worthwhile even when your campaign page has only custom code today, because it gives you a sane place to pin future plugin or theme dependencies instead of acquiring them by ritual browser clicking:

{
  "name": "acme/campaign-wp",
  "type": "project",
  "require": {
    "composer/installers": "^2.3"
  },
  "repositories": [
    {
      "type": "composer",
      "url": "https://wpackagist.org"
    }
  ],
  "extra": {
    "installer-paths": {
      "wp-content/plugins/{$name}/": ["type:wordpress-plugin"],
      "wp-content/themes/{$name}/": ["type:wordpress-theme"]
    }
  }
}

This is essentially the same direction the Bedrock ecosystem took years ago: Composer-managed dependencies, better structure, environment-driven configuration, and Git-first deployments. If you already use Bedrock, a lot of the policy shape is pre-installed. If you do not, you can still implement the same operational posture with the stock layout and the official images.

Config and backing services are where WordPress gets surprisingly cooperative. The wp-config.php explicitly supports hard-coding WP_HOME and WP_SITEURL to improve portability and avoid dashboard misconfiguration, and it also documents WP_ENVIRONMENT_TYPE with the expected environment values: local, development, staging, and production. The official WordPress also supports runtime configuration via environment variables and _FILE variants for secrets, while Compose supports both .env~/~env_file workflows and explicit secrets, with Docker's own documentation warning not to pass sensitive data as ordinary environment variables when secrets are available. In other words: the platform is no longer the excuse.

A slim .env.example is usually enough to make the contract obvious:

# .env.example
APP_VERSION=dev
SITE_HOST=campaign.localhost
WP_HOME=https://campaign.localhost
WP_SITEURL=https://campaign.localhost
WP_ENVIRONMENT_TYPE=development
WP_FORCE_SSL_ADMIN=false

WORDPRESS_DB_HOST=db
WORDPRESS_DB_NAME=campaign
WORDPRESS_DB_USER=campaign

WP_ADMIN_USER=admin
WP_ADMIN_EMAIL=admin@example.test

# Generate real values per environment
WORDPRESS_AUTH_KEY=replace-me
WORDPRESS_SECURE_AUTH_KEY=replace-me
WORDPRESS_LOGGED_IN_KEY=replace-me
WORDPRESS_NONCE_KEY=replace-me
WORDPRESS_AUTH_SALT=replace-me
WORDPRESS_SECURE_AUTH_SALT=replace-me
WORDPRESS_LOGGED_IN_SALT=replace-me
WORDPRESS_NONCE_SALT=replace-me

The database is then just an attached resource in the 12-factor sense: replaceable, externalized, and not baked into the image. The MySQL documents _FILE support for credentials and also makes clear that initialization variables only act on a fresh data directory, not on an already-populated database. That is exactly the behavior you want for repeatable local bootstrap without accidental production re-initialization.

Processes, concurrency, logs, and admin tasks require more judgment. The 12-factor definition says processes should be stateless and share-nothing. Roots' WordPress-specific names the two recurring trouble spots without drama: sessions and uploaded files. If your campaign site sticks to normal WordPress cookies and one app container, you are mostly fine. The moment you introduce PHP file-backed sessions or scale to multiple web containers with local uploads, you have to move that state into backing services such as a database, Redis, shared storage, or object storage. That is not optional; that is the bill arriving.

Admin work, fortunately, maps neatly. The Twelve-Factor App says management tasks should be run as one-off processes, and WP-CLI is already a strong fit for that model: install core, export a database, perform safe search-replace on serialized content, verify checksums, and run schema updates. That makes WordPress an unusually good citizen at exactly the moment people often insist on driving it by browser alone.

# one-off admin tasks, not long-lived service behavior
docker compose run --rm wpcli core install \
  --url="$WP_HOME" \
  --title="Campaign 2026" \
  --admin_user="$WP_ADMIN_USER" \
  --admin_email="$WP_ADMIN_EMAIL"

docker compose run --rm wpcli search-replace \
  'https://staging.example.com' \
  'https://campaign.example.com' \
  --dry-run

docker compose run --rm wpcli db export /tmp/backup.sql
docker compose run --rm wpcli core verify-checksums --include-root

A campaign-site stack that behaves like an application

The official WordPress image currently ships Apache, FPM, and CLI variants across several PHP versions, which means you can pick the process shape you actually want instead of inheriting one accidentally. For a small campaign page, a custom WordPress image based on the official Apache variant behind a separate Caddy reverse proxy works well. It is not the maximal decomposition possible, but it is operationally clean, keeps TLS and proxy concerns where they belong, and avoids turning a simple site into a tiny museum of containers. If you want stricter separation later, the same image family also offers FPM variants.

A practical Compose file looks like this:

# compose.yaml
services:
  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${WORDPRESS_DB_NAME}
      MYSQL_USER: ${WORDPRESS_DB_USER}
      MYSQL_PASSWORD_FILE: /run/secrets/db_password
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    secrets:
      - db_password
      - db_root_password
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "mysqladmin ping -h localhost -uroot --password=$$(cat /run/secrets/db_root_password) || exit 1"
        ]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s

  wordpress:
    image: ghcr.io/acme/campaign-wp:${APP_VERSION}
    build:
      context: .
      dockerfile: docker/wordpress/Dockerfile
      args:
        WORDPRESS_BASE_IMAGE: wordpress:6.9.4-php8.4-apache
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: ${WORDPRESS_DB_HOST}
      WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME}
      WORDPRESS_DB_USER: ${WORDPRESS_DB_USER}
      WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
      WP_HOME: ${WP_HOME}
      WP_SITEURL: ${WP_SITEURL}
      WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE}
      WP_FORCE_SSL_ADMIN: ${WP_FORCE_SSL_ADMIN}
      WORDPRESS_AUTH_KEY: ${WORDPRESS_AUTH_KEY}
      WORDPRESS_SECURE_AUTH_KEY: ${WORDPRESS_SECURE_AUTH_KEY}
      WORDPRESS_LOGGED_IN_KEY: ${WORDPRESS_LOGGED_IN_KEY}
      WORDPRESS_NONCE_KEY: ${WORDPRESS_NONCE_KEY}
      WORDPRESS_AUTH_SALT: ${WORDPRESS_AUTH_SALT}
      WORDPRESS_SECURE_AUTH_SALT: ${WORDPRESS_SECURE_AUTH_SALT}
      WORDPRESS_LOGGED_IN_SALT: ${WORDPRESS_LOGGED_IN_SALT}
      WORDPRESS_NONCE_SALT: ${WORDPRESS_NONCE_SALT}
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - uploads:/var/www/html/wp-content/uploads

  wpcli:
    image: ghcr.io/acme/campaign-wp-cli:${APP_VERSION}
    build:
      context: .
      dockerfile: docker/wordpress/Dockerfile
      args:
        WORDPRESS_BASE_IMAGE: wordpress:cli-2.12.0-php8.4
    profiles: ["ops"]
    user: "33:33"
    working_dir: /var/www/html
    environment:
      WORDPRESS_DB_HOST: ${WORDPRESS_DB_HOST}
      WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME}
      WORDPRESS_DB_USER: ${WORDPRESS_DB_USER}
      WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
      WP_HOME: ${WP_HOME}
      WP_SITEURL: ${WP_SITEURL}
      WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE}
      WP_FORCE_SSL_ADMIN: ${WP_FORCE_SSL_ADMIN}
      WORDPRESS_AUTH_KEY: ${WORDPRESS_AUTH_KEY}
      WORDPRESS_SECURE_AUTH_KEY: ${WORDPRESS_SECURE_AUTH_KEY}
      WORDPRESS_LOGGED_IN_KEY: ${WORDPRESS_LOGGED_IN_KEY}
      WORDPRESS_NONCE_KEY: ${WORDPRESS_NONCE_KEY}
      WORDPRESS_AUTH_SALT: ${WORDPRESS_AUTH_SALT}
      WORDPRESS_SECURE_AUTH_SALT: ${WORDPRESS_SECURE_AUTH_SALT}
      WORDPRESS_LOGGED_IN_SALT: ${WORDPRESS_LOGGED_IN_SALT}
      WORDPRESS_NONCE_SALT: ${WORDPRESS_NONCE_SALT}
    secrets:
      - db_password
    volumes:
      - uploads:/var/www/html/wp-content/uploads

  caddy:
    image: caddy:2.10
    restart: unless-stopped
    depends_on:
      - wordpress
    ports:
      - "80:80"
      - "443:443"
    environment:
      SITE_HOST: ${SITE_HOST}
    volumes:
      - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
      - caddy_logs:/var/log/caddy

secrets:
  db_password:
    file: ./secrets/db_password.txt
  db_root_password:
    file: ./secrets/db_root_password.txt

volumes:
  db_data:
  uploads:
  caddy_data:
  caddy_config:
  caddy_logs:

There are a few quiet virtues here. Compose health checks and depends_on: condition: service_healthy are doing explicit readiness work instead of relying on optimism. Docker secrets keep database credentials out of normal environment variables. The writable surface is narrowed to the database volume, the uploads volume, and the proxy's operational data. That lines up with WordPress' own hardening to minimize writable files and only relax permissions where actual application behavior requires it.

The build itself is intentionally boring. It starts from the official image, copies in your versioned app code, and stops there:

# docker/wordpress/Dockerfile
ARG WORDPRESS_BASE_IMAGE=wordpress:6.9.4-php8.4-apache

FROM composer:2 AS vendor
WORKDIR /app
COPY app/composer.json app/composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader

FROM ${WORDPRESS_BASE_IMAGE}
WORKDIR /var/www/html
COPY --from=vendor /app/wp-content /var/www/html/wp-content
COPY app/wp-content /var/www/html/wp-content
COPY docker/wordpress/wp-config.php /var/www/html/wp-config.php
RUN mkdir -p /var/www/html/wp-content/uploads

The Compose build explicitly supports image building and publishing from Compose projects, and the official WordPress image makes exact tag pinning possible for both the runtime container and the CLI companion container. That lets you build once, ship the same artifact to staging and production, and stop pretending that "whatever is on the server right now" is a release strategy.

The wp-config.php is where WordPress becomes an application instead of a self-willed appliance. The wp-config.php covers WP_HOME, WP_SITEURL, WP_ENVIRONMENT_TYPE, FORCE_SSL_ADMIN, DISALLOW_FILE_EDIT, and DISALLOW_FILE_MODS for exactly the concerns we care about here. It also documents the reverse-proxy HTTP_X_FORWARDED_PROTO fix needed to avoid SSL redirect loops when TLS is terminated upstream.

<?php // docker/wordpress/wp-config.php

function env_or_file(string $key, ?string $default = null): ?string {
    $file = getenv($key . '_FILE');
    if ($file && is_readable($file)) {
        return trim((string) file_get_contents($file));
    }

    $value = getenv($key);
    return $value === false ? $default : $value;
}

if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
    $_SERVER['HTTPS'] = 'on';
}

define('DB_NAME', env_or_file('WORDPRESS_DB_NAME', 'campaign'));
define('DB_USER', env_or_file('WORDPRESS_DB_USER', 'campaign'));
define('DB_PASSWORD', env_or_file('WORDPRESS_DB_PASSWORD', ''));
define('DB_HOST', env_or_file('WORDPRESS_DB_HOST', 'db'));

define('WP_HOME', env_or_file('WP_HOME'));
define('WP_SITEURL', env_or_file('WP_SITEURL'));

$env = env_or_file('WP_ENVIRONMENT_TYPE', 'production');
define('WP_ENVIRONMENT_TYPE', $env);
define('WP_DEBUG', in_array($env, ['local', 'development'], true));
define('WP_DEBUG_LOG', false);
define('WP_DEBUG_DISPLAY', false);

define('FORCE_SSL_ADMIN', env_or_file('WP_FORCE_SSL_ADMIN', 'false') === 'true');
define('DISALLOW_FILE_EDIT', $env !== 'local' && $env !== 'development');
define('DISALLOW_FILE_MODS', $env === 'staging' || $env === 'production');

define('AUTH_KEY',         env_or_file('WORDPRESS_AUTH_KEY'));
define('SECURE_AUTH_KEY',  env_or_file('WORDPRESS_SECURE_AUTH_KEY'));
define('LOGGED_IN_KEY',    env_or_file('WORDPRESS_LOGGED_IN_KEY'));
define('NONCE_KEY',        env_or_file('WORDPRESS_NONCE_KEY'));
define('AUTH_SALT',        env_or_file('WORDPRESS_AUTH_SALT'));
define('SECURE_AUTH_SALT', env_or_file('WORDPRESS_SECURE_AUTH_SALT'));
define('LOGGED_IN_SALT',   env_or_file('WORDPRESS_LOGGED_IN_SALT'));
define('NONCE_SALT',       env_or_file('WORDPRESS_NONCE_SALT'));

$table_prefix = 'wp_';

if (!defined('ABSPATH')) {
    define('ABSPATH', __DIR__ . '/');
}

require_once ABSPATH . 'wp-settings.php';

One subtle point is worth calling out. The official WordPress image can inject extra configuration through WORDPRESS_CONFIG_EXTRA, but its documentation notes that value ends up being appended and evaluated inside wp-config.php. For a few constants that is convenient; for a long-lived deployment contract it is basically configuration by polite eval(), which is a phrase that should make adults stare into the middle distance for a moment. A real versioned config file is cleaner.

A tiny MU plugin gives you an operational health endpoint without committing the usual sin of probing wp-login.php and calling that observability:

<?php // app/wp-content/mu-plugins/campaign-health.php
/**
 * Plugin Name: Campaign Health
 */

add_action('rest_api_init', function (): void {
    register_rest_route('campaign/v1', '/health', [
        'methods' => 'GET',
        'permission_callback' => '__return_true',
        'callback' => function () {
            global $wpdb;

            $wpdb->query('SELECT 1');

            return [
                'ok' => true,
                'db' => 'ok',
                'environment' => wp_get_environment_type(),
            ];
        },
    ]);
});

That endpoint becomes useful immediately for smoke checks, Uptime Kuma monitors, and post-deploy verification. It is a tiny example of the broader principle: operational affordances belong in code, not in tribal memory.

Building, testing, and versioning the release

Build-release-run matters more in WordPress than people like to admit. If you cannot build and reliably roll back a release, you should not be calling the process mature. The clean boundary is simple: Git commit in, image out, database and uploads preserved, one-off schema tasks run separately. Content editors still use WordPress; they just stop being your deployment mechanism.

A CI workflow for a campaign page does not need to be heroic. It should verify the application contract, not re-enact a Mars mission. For most sites that means: validate the Compose configuration, build images, boot the stack, hit the health endpoint, run front-end smoke tests, and verify the integrity of core files with WP-CLI. For plugin-heavy or theme-heavy projects, add PHPUnit and admin/editor tests. WordPress' own developer docs now document PHPUnit-based plugin tests and Playwright-based E2E, including a package specifically for WordPress admin and editor interactions.

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]

jobs:
  build-test:
    runs-on: ubuntu-latest
    env:
      APP_VERSION: ci-${{ github.sha }}
      SITE_HOST: localhost
      WP_HOME: http://localhost
      WP_SITEURL: http://localhost
      WP_ENVIRONMENT_TYPE: development
      WP_FORCE_SSL_ADMIN: "false"
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: campaign
      WORDPRESS_DB_USER: campaign
      WORDPRESS_AUTH_KEY: test
      WORDPRESS_SECURE_AUTH_KEY: test
      WORDPRESS_LOGGED_IN_KEY: test
      WORDPRESS_NONCE_KEY: test
      WORDPRESS_AUTH_SALT: test
      WORDPRESS_SECURE_AUTH_SALT: test
      WORDPRESS_LOGGED_IN_SALT: test
      WORDPRESS_NONCE_SALT: test

    steps:
      - uses: actions/checkout@v4

      - name: Prepare secrets
        run: |
          mkdir -p secrets
          printf 'campaign-pass' > secrets/db_password.txt
          printf 'root-pass' > secrets/db_root_password.txt

      - name: Validate Compose
        run: docker compose config

      - name: Build and start stack
        run: docker compose up -d --build

      - name: Wait for WordPress
        run: |
          for i in $(seq 1 30); do
            curl -fsS http://localhost/wp-json/campaign/v1/health && exit 0
            sleep 2
          done
          exit 1

      - name: Verify WordPress core checksums
        run: docker compose run --rm wpcli core verify-checksums --include-root

      - name: Install Playwright
        run: |
          npm ci
          npx playwright install --with-deps

      - name: Run smoke tests
        run: npx playwright test

      - name: Shutdown
        if: always()
        run: docker compose down -v

A minimalist smoke test is often enough to catch the embarrassing failures that matter on a campaign site: homepage broken, CSS missing, CTA absent, form page 500ing. There is no medal for discovering that only after the press release goes live.

// tests/e2e/landing-page.spec.ts
import { test, expect } from '@playwright/test';

test('landing page renders the primary CTA', async ({ page }) => {
  await page.goto('http://localhost/');
  await expect(page).toHaveTitle(/Campaign/i);
  await expect(page.getByRole('link', { name: /donate|join|sign up/i })).toBeVisible();
});

test('health endpoint is green', async ({ request }) => {
  const response = await request.get('http://localhost/wp-json/campaign/v1/health');
  expect(response.ok()).toBeTruthy();
  await expect(await response.json()).toMatchObject({ ok: true, db: 'ok' });
});

If you need admin or editor E2E coverage, WordPress' Playwright are a better fit than inventing brittle selectors against wp-admin yourself. If you need unit tests for custom plugin logic, WordPress' current developer guidance is straight down the middle: PHPUnit, Composer, and GitHub-based automation. None of that is exotic anymore. It is just the boring part of being able to change things safely.

The versioning rule is equally boring and equally important: tag and deploy the image, not the working tree. If you need plugin updates in production, do them by changing composer.lock or the image build inputs, rebuilding, and shipping a fresh artifact. WP-CLI can update, but on a 12-factor-sensitive site that is better used in controlled maintenance or pre-production than as a standing invitation for runtime drift.

Running it in production without improvisation

Caddy is a nice fit in front of WordPress because it handles TLS and reverse without much ceremony, and by default it already sets or augments X-Forwarded-For, sets X-Forwarded-Proto, and sets X-Forwarded-Host when proxying. WordPress' HTTPS documentation explicitly calls out the HTTP_X_FORWARDED_PROTO adjustment required when SSL is terminated at the proxy. That makes the Caddy -> WordPress shape operationally tidy instead of spiritually aspirational.

# docker/caddy/Caddyfile
{
    metrics
    log default {
        output stdout
        format json
        include http.log.access
    }
}

{$SITE_HOST} {
    encode zstd gzip

    header {
        -Server
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "interest-cohort=()"
    }

    @health path /healthz
    handle @health {
        redir /wp-json/campaign/v1/health 308
    }

    log {
        output file /var/log/caddy/access.log
        format json
    }

    reverse_proxy wordpress:80
}

The small compromise here is deliberate. Twelve-factor wants logs as event streams, and Caddy can absolutely emit JSON logs to stdout. Fail2ban, meanwhile, has a wonderfully old-school habit of wanting log sources to inspect. Caddy's own logging is explicit that it emits logs rather than consuming them, and Fail2ban's jail model is explicit that filters are attached to log sources via logpath. So if you want host-level HTTP brute-force mitigation with Fail2ban, a dedicated proxy access log is a pragmatic exception worth making. The app remains immutable; the proxy gets one file so your firewall can keep score. Everyone stays calm. Mostly.

A host-side Fail2ban setup can be restrained. Keep SSH protection standard and make WordPress-specific bans narrow and measurable:

# /etc/fail2ban/filter.d/caddy-wordpress-login.conf
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"method":"POST".*"uri":"\/wp-login\.php".*$
ignoreregex =
# /etc/fail2ban/jail.d/caddy-wordpress.local
[caddy-wordpress-login]
enabled  = true
filter   = caddy-wordpress-login
logpath  = /var/lib/docker/volumes/campaign-wp_caddy_logs/_data/access.log
findtime = 10m
maxretry = 10
bantime  = 1h
port     = http,https

[sshd]
enabled = true

Fail2ban's own recommends testing custom filters against real log lines and then enabling them through jail.local or a per-jail local file. Do that. Regex confidence without fail2ban-regex is how people become convinced that "security is in place" while bots keep logging in politely every three seconds.

WordPress' hardening also remains quite sensible on three production basics: keep the app current, minimize writable files, and maintain tested backups. For a campaign site, that leads to a maintenance loop that is plain enough to automate and plain enough to trust. Use wp db export for database backups, snapshot the uploads volume, deploy a new image, run wp core update-db if needed, and verify core checksums. Those are one-off admin processes in the 12-factor sense, not side effects of a person browsing wp-admin with courage in their heart.

#!/usr/bin/env bash
# scripts/deploy.sh
set -euo pipefail

export APP_VERSION="${1:?usage: deploy.sh <image-tag>}"

timestamp="$(date +%F-%H%M%S)"
backup_dir="/srv/backups/campaign/${timestamp}"
mkdir -p "${backup_dir}"

docker compose run --rm wpcli db export "/tmp/${timestamp}.sql"
docker cp "$(docker compose ps -q wpcli):/tmp/${timestamp}.sql" "${backup_dir}/db.sql"

docker run --rm \
  -v campaign-wp_uploads:/from:ro \
  -v "${backup_dir}:/to" \
  alpine sh -lc 'cp -a /from /to/uploads'

docker compose pull
docker compose up -d

docker compose run --rm wpcli core update-db
docker compose run --rm wpcli core verify-checksums --include-root

curl -fsS "https://${SITE_HOST}/wp-json/campaign/v1/health" >/dev/null

The uncomfortable but useful production policy question is updates. WordPress defaults to automatic minor core and translation updates, and it can be configured more broadly through constants or filters. At the same time, DISALLOW_FILE_MODS blocks plugin and theme installation and update from wp-admin. For a modern deployment pipeline, the clean recommendation is: let the pipeline own all code changes in staging and production, and keep wp-admin for content. If you choose in-dashboard updates instead, do it explicitly and admit that you are trading reproducibility for convenience. That can be a valid business choice. It is just not the 12-factor choice.

Monitoring should stay proportional. Uptime supports the monitor types that matter for a campaign site: HTTP/HTTPS, keyword checks, ping, DNS, TCP, and push-style monitoring. Caddy can also expose Prometheus metrics once metrics are enabled in global options. In practice, the small-stack answer is usually Uptime Kuma for synthetic external checks and notifications, plus optional Prometheus scraping if you already run metrics infrastructure elsewhere. No need to build a graduate seminar around a landing page.

# compose.prod.yaml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    restart: unless-stopped
    volumes:
      - uptime_kuma_data:/app/data
    ports:
      - "127.0.0.1:3001:3001"

volumes:
  uptime_kuma_data:

A sensible monitoring set for this example is: one HTTP monitor for /, one keyword monitor for the expected CTA text, one health for /wp-json/campaign/v1/health, and one DNS monitor for the site hostname. If the page is money-adjacent, add certificate expiry and response-time alerting as well. That is enough to catch most of the failures people actually experience, which is usually more valuable than another dashboard panel with seven decimal points.

Conclusions

Modern WordPress is entirely compatible with 12-factor thinking once you stop asking the runtime to be your source of truth. Codebase, explicit dependencies, config, backing services, build-release-run, logs, and admin processes all map well onto WordPress if you store configuration outside the repo, build immutable images, version custom code in Git, and use WP-CLI for one-off operational work. The official container images, wp-config.php constants, Compose secrets, and WordPress' own CLI tooling already provide most of the raw material.

Where WordPress needs adaptation is exactly where Roots said it would: state. Uploaded media and server-side sessions are the recurring pressure points. For a single-container campaign site, a persistent uploads volume is fine. For horizontal scale, that local filesystem becomes a liability and has to move to shared storage or object storage. Likewise, if a plugin introduces PHP file sessions, you no longer have a clean share-nothing process model and should move that state to a proper backing service. WordPress does not break 12-factor here; it simply exposes whether you have actually followed it.

What does not translate cleanly is the cultural default of WordPress itself mutating live production code. WordPress supports auto-updates and runtime modification because that is useful for many site owners. A 12-factor-sensitive engineering workflow should treat that as an opt-in exception, not the baseline. Keep content mutable. Keep code immutable. Let marketing change the copy, not the dependency graph.

So the distilled recommendation for a modern campaign page is pleasantly uneventful: version the theme and MU plugins in Git; externalize config with .env plus secrets; bake a release image from the official WordPress image; keep only database and uploads persistent; put Caddy in front; run operational tasks through WP-CLI; add smoke tests in CI; deploy by image tag; protect the host with Fail2ban; watch the site with Uptime Kuma; and make updates through the pipeline instead of through adrenaline. WordPress remains WordPress. It just behaves like production software now, which, on balance, seems fair.

Sources