Skip to main content
site-template is a GitHub template repo for new WordPress sites running on the FrankenPress stack.

Layout

Bedrock-style:

What is Bedrock and why use it?

Bedrock is Roots’ opinionated shape for WordPress sites: WordPress is treated as a versioned dependency rather than something you commit, the filesystem is laid out for clean separation of code and runtime data, and configuration lives in one place per environment instead of being scattered across wp-config.php and the database. site-template is Bedrock-style — composer-managed, with the canonical web/wp + web/app + config/ split — because the whole FrankenPress proposition (immutable image, declarative deploy) breaks down without it. There needs to be a clear answer to “what’s code?” and “what’s data?” before you can confidently bake one into an image and leave the other in a database / S3 bucket.

What each Roots package does for you

The site composer-requires four small Roots packages. Each one does one thing: vlucas/phpdotenv and oscarotero/env sit alongside — they read .env into $_ENV for local dev and provide the env('FOO') helper used throughout application.php. In production the Helm chart sets env vars directly via the ConfigMap, so .env isn’t loaded at all.

Bedrock vs the other Roots projects

Roots also publishes Sage (modern build tooling for a custom theme) and Trellis (Ansible-based provisioning for Bedrock sites on VMs). Neither is used by FrankenPress:
  • Sage is theme-side — orthogonal to the site shape. You can use Sage on top of a FrankenPress site if you like; the runtime doesn’t care which theme you ship.
  • Trellis is the operational layer Bedrock sites traditionally paired with — VMs, Ansible, Nginx, PHP-FPM. FrankenPress replaces Trellis: containerised single-process runtime, declarative Helm deploy, immutable image. If you’re coming from a Trellis-shaped Bedrock site, FrankenPress is the migration target — keep Bedrock, drop Trellis.

Forking

1

Use this template

Click Use this template on the repo. Pick a name (e.g. mysite).
2

Bootstrap locally

fp init scaffolds .env from .env.example, runs composer install via docker (no PHP needed on host), brings the stack up (docker compose up -d --wait), installs WordPress with sensible local defaults (admin / admin / admin@example.test), and applies the latest committed snapshot if there is one. Re-run it after docker compose down -v to recover from a wiped local stack.Visit http://localhost:8080/wp/wp-admin/ and log in (override credentials via [init] in frankenpress.toml).
The charts Helm chart runs wp core install automatically as a post-install hook Job on cluster deploys — that’s a Helm-only mechanism, so this fp init flow is only relevant for local Docker Compose dev.
Lower-level alternative if you’d rather drive each step manually:
make wp injects --allow-root --path=/app/web/wp into every invocation — WordPress core lives at web/wp/ per the Bedrock layout, so wp-cli won’t auto-discover it from the container’s default working directory. Pass your sub-command + flags through ARGS="…".

Adding plugins and themes

Composer-managed (recommended):
The wpackagist repository is wired up in composer.json. Plugins land in web/app/plugins/, themes in web/app/themes/. Custom code (your own theme or plugin): drop a directory under the right web/app/* subtree and commit it. The .gitignore ignores composer-installed content but unhides anything you commit explicitly.
Composer-installed plugins do not auto-activate in WordPress. Use wp plugin activate <slug> once after install (or once per environment). The exception is humanmade/s3-uploads which mu-plugin loads explicitly.

Lockdown

DISALLOW_FILE_EDIT, DISALLOW_FILE_MODS, and DISALLOW_INDIRECT_FILE_MODS in config/application.php are gated on KUBERNETES_SERVICE_HOST — the env var the kubelet injects into every Pod:
In-cluster: all three are true — the “Update WordPress / plugins / themes” buttons are absent and admin-side installs hard-fail. Out-of-cluster (docker-compose, bare local): all three are false so designers can install block plugins / evaluation themes locally and promote the result into the image + DB via wp fp snapshot. Prod can’t accidentally land in the relaxed mode — the kubelet always sets KUBERNETES_SERVICE_HOST. DISALLOW_INDIRECT_FILE_MODS (WP 6.4+) is the third flag because the other two miss indirect write paths — language-pack downloads, font installs, and Site Health helper writes go through request_filesystem_credentials() rather than the plugin/theme installer, and would otherwise still land on ephemeral pod disk. FP_ALLOW_FILE_MODS=1 is a narrow opt-out used by the chart’s install Job only (so wp plugin install can run transiently inside the Job for snapshot apply). Web Pods, wpcron Pods, and init containers never see it. Setting it flips all three flags back off in lockstep. Plus in production.php, hard-coded with no gate:
AUTOMATIC_UPDATER_DISABLED covers core / translations / themes / plugins as a top-level kill switch; the three explicit WP_AUTO_UPDATE_* flags are defense in depth so a future WP release that loosens the umbrella check still finds an explicit false for each surface. The container image is the source of truth in-cluster — admin-side plugin installs would land on ephemeral disk and disappear on pod restart, replicating inconsistently across replicas.

Auth keys required at boot

config/application.php lists all eight WordPress auth keys + salts (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY, plus the matching _SALT siblings) in Dotenv->required(). A missing value fails loud at boot — Bedrock raises a RuntimeException before WP is loaded — instead of silently falling back to 'put your unique phrase here' and degrading session crypto across the cluster. Local dev keeps the dev-key / dev-salt defaults from docker-compose.yml. Production must inject real values via your cluster’s secret manager (the chart’s keysSalts.existingSecret or External Secrets Operator).

CI / publishing

GitHub Actions:
  • lint.yml — PHPCS + composer audit on every push and PR
  • build.yml — on push to main and on v*.*.* tag, builds the site image and pushes to ghcr.io/<your-org>/<your-site>
To cut a versioned release:
The image is then available at ghcr.io/<your-org>/<your-site>:v1.0.0. Deploy with: