WordPress + Terraform infrastructure for the Journalism Trust Initiative — a project of Reporters Without Borders (RSF), built and operated by Relief Applications.
Repos in this project:
ReliefApplications/jti-wordpress(this repo) — WordPress + default plugins + themes + Docker image + IaCReliefApplications/jti-custom— the JTI custom WordPress plugin. Deployed independently of this repo's image: its own GH Actions workflow (azcopy sync) uploads the plugin to the staging/prod Azure Files share on push to main. The WP container's polling overlay then picks it up within ~2-5 min. No WP image rebuild needed for jti-custom changes. Seeinfra/RUNBOOK.md §4"Update jti-custom".
├── docker/
│ ├── Dockerfile WP-on-Apache base + perf tuning + overlay scripts
│ ├── apache.conf vhost + HTTP Basic Auth (staging gating)
│ ├── php-opcache.ini OPcache prod tuning (validate_timestamps=0)
│ └── entrypoint-persist.sh uploads/ symlink + 30s bidirectional rsync poller
├── wordpress/ The WordPress install (core + plugins + themes)
│ ├── wp-config.php Env-aware config (Azure App Service settings)
│ ├── wp-content/
│ │ ├── plugins/ Default + third-party plugins (no jti-custom)
│ │ ├── themes/ Astra, hello-elementor, twentyeleven
│ │ ├── mu-plugins/ Must-use plugins (perf-trace, Elementor inline CSS)
│ │ └── languages/ Translations
│ └── ... WP core
├── infra/ Terraform IaC (App Service, MySQL, ACR, etc.)
│ ├── RUNBOOK.md ⭐ DETAILED OPS GUIDE — read this for deploys
│ ├── modules/ Reusable Terraform modules
│ ├── environments/ staging/ and prod/
│ └── bootstrap/ One-time state-storage bootstrap
├── docker-compose.yml Local development
├── .env.example Local-dev env template
└── .dockerignore / .gitignore What stays out of the image / repo
Cloudflare (DNS + proxy)
│
▼
Azure App Service (Linux Container, B2, France Central)
│
┌─────────┴───────────────────────┐
│ │
▼ ▼
Image: ACR (wp-jti:latest) Azure Files share "wp-content"
(WP core + default plugins (mounted at /persist)
+ themes + mu-plugins, ├─ uploads/ → symlinked into wp-content
OPcache pre-tuned; │ (instant, no rsync)
baseline files mtime=1970 └─ everything else → rsync poll every 30s,
for sync correctness) bidirectional, no --delete
│ (admin updates + Storage
▼ Explorer drops both work)
Azure DB for MySQL Flexible ┌── Azure Cache for Redis (Basic C0)
(8.4, B_Standard_B2s) └── ↑ used as WP object cache
Key design choices:
- Plugins/themes are baked into the Docker image for fast PHP includes
(Azure Files SMB latency on
require_oncewould be catastrophic — measured 20+ s vs ~2 s baked). Seeinfra/RUNBOOK.md §2for the performance journey. - Admin-installed plugin updates persist via the polling rsync overlay
(
docker/entrypoint-persist.sh—rsync -auboth directions every 30 s, no--delete). Updates from wp-admin AND direct Storage Explorer drops into the share both propagate. Lag is ~2–5 min for non-uploads paths (limited by SMB walk time over thousands of files), instant for uploads/. inotify was tried first; Azure Files CIFS doesn't reliably propagate cross-client change notifications to local inotify, so we poll instead — seeinfra/RUNBOOK.md §5.1for the test data. - User uploads are direct-mounted (symlink) so writes go straight to AzFiles with zero data-loss window.
- Image baseline mtimes are backdated to 1970 in the Dockerfile so the
poller's
-uflag (skip if dest is newer) reliably keeps share-side edits alive across image rebuilds. Without this, a fresh image rebuild's "newer" mtimes silently clobber older admin updates. - Cryptographic salts are generated by Terraform (
random_password), injected as App Service env vars — never in git, never in the image. - Staging is password-protected: HTTP Basic Auth at the Apache level.
Credentials are stored as a GitHub Actions environment secret
PUBLIC_ACCESS_PWDunder thestagingenvironment.
For day-to-day plugin/theme/code updates:
# 1. Edit files in wordpress/ or docker/
# 2. Commit (optional but recommended)
git add -A && git commit -m "Update foo plugin to X.Y.Z"
# 3. Build + push image (also restarts staging)
cd /path/to/jti-wordpress
az acr build \
--registry acrjtistaginghecl \
--image wp-jti:latest \
--file docker/Dockerfile \
--build-arg HTPASSWD_PASSWORD="<from PUBLIC_ACCESS_PWD secret>" \
.
# 4. Force fresh image pull (regular restart sometimes serves cached image)
az webapp stop --name app-jti-staging-fuml --resource-group rg-jti-staging
az webapp start --name app-jti-staging-fuml --resource-group rg-jti-stagingFor a quick hot-fix without rebuilding the image (e.g. iterating on a
mu-plugin), upload the file directly to the wp-content share via Azure
Storage Explorer or the REST API — the polling overlay picks it up within
2–5 min and serves it via the live site, with no container restart needed.
Remember to also commit the change to git + bake it into the next image,
or the next CI rebuild won't include it.
See infra/RUNBOOK.md for the full deploy procedure,
including the database import gotchas (Azure MySQL GIPK trap, etc.) that took
a day of debugging to find.
cp .env.example .env # tweak DB creds if needed
docker compose up
# → WordPress at http://localhost:8080The image expects:
- WordPress DB env vars (
WORDPRESS_DB_*) - Cryptographic salts (
WORDPRESS_AUTH_KEYetc.) — leave blank locally for dev or supply your own - Optional:
WP_REDIS_HOSTif you want object cache - Optional:
WORDPRESS_DOMAIN_CURRENT_SITEfor multisite
| Doc | Purpose |
|---|---|
infra/RUNBOOK.md |
Full deploy procedure, gotchas, performance journey, troubleshooting — read before any DB or infra operation |
infra/CLAUDE.md |
Project context for AI coding agents (Claude Code etc.) |
infra/README.md |
Terraform-specific guide |
infra/INFRACOST.md |
Monthly Azure cost breakdown |
- Staging: ✅ live at
https://staging.journalismtrustinitiative.org/(password-protected — seePUBLIC_ACCESS_PWDGH secret). CI auto-deploy on push to main (paths-filtered todocker/**+wordpress/**). - Prod: ✅ provisioned (2026-05-26); reachable internally via Front Door
endpoint
fde-jti-prod-dyfrdfhudtcsgyct.z03.azurefd.net+ thepreview.journalismtrustinitiative.orgvalidation domain. Live apex DNS still points at the legacy hosting — flipping it is the final cutover step. Seeinfra/RUNBOOK.md §10for the cutover checklist. - CI/CD: GitHub Actions on both repos; see
infra/RUNBOOK.md §4"Continuous deployment".
Internal Relief Applications / RSF work — not for redistribution.