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
- The Twelve-Factor App — the foundational methodology
- Roots — the WordPress modernization framework and series
- Bedrock — Composer-managed WordPress boilerplate by Roots
- Roots: Twelve-Factor — WordPress-specific treatment of stateless processes
- WordPress wp-config.php
- WordPress Docker image
- WordPress Docker Hub content
- MySQL Docker image
- Docker Compose services
- Docker Compose build
- WordPress E2E test utilities
- Playwright
- WP-CLI: plugin
- Caddy: reverse<sub>proxy</sub>
- Caddy: log
- Fail2ban filter
- WordPress security hardening
- WordPress automatic background
- Uptime Kuma
- Uptime Kuma: adding a