> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frankenpress.com/llms.txt
> Use this file to discover all available pages before exploring further.

# site-template

> GitHub template for new WordPress sites

[`site-template`](https://github.com/frankenpress/site-template) is
a [GitHub template repo](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template)
for new WordPress sites running on the FrankenPress stack.

## Layout

Bedrock-style:

```text theme={null}
.
├── composer.json              # site dependencies (no WC, no theme picks)
├── Dockerfile                 # multi-stage: composer build → runtime
├── docker-compose.yml         # local dev: site + mariadb + redis + minio
├── .env.example               # all platform env vars with sane defaults
├── config/
│   ├── application.php        # main config + lockdown constants + env wiring
│   └── environments/
│       ├── development.php    # WP_DEBUG=true, indexing disabled
│       ├── staging.php        # WP_DEBUG=true (log only), indexing disabled
│       └── production.php     # WP_DEBUG=false, all auto-updates disabled
└── web/                       # docroot
    ├── index.php              # WP front controller
    ├── wp-config.php          # thin loader → config/application.php
    ├── wp/                    # WP core (composer-installed, gitignored)
    └── app/                   # wp-content
        ├── mu-plugins/
        │   └── 00-stack.php   # loader (boots roots/bedrock-autoloader)
        ├── plugins/           # composer require wpackagist-plugin/...
        └── themes/            # composer require wpackagist-theme/...
```

## What is Bedrock and why use it?

[Bedrock](https://roots.io/bedrock/) is [Roots'](https://roots.io/)
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:

| Package                                                                                             | Job                                                                                                                                                                                                                                                  |
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`roots/wordpress`](https://packagist.org/packages/roots/wordpress)                                 | Composer-installable WordPress core — no bundled themes, no sample content. Lands at `web/wp/` (gitignored). Lets you treat WP as a versioned dependency.                                                                                            |
| [`roots/wp-config`](https://packagist.org/packages/roots/wp-config)                                 | Thin loader (`web/wp-config.php`) that defers all config to `config/application.php`. Single source of truth for site config — plugins can't mutate it behind your back.                                                                             |
| [`roots/bedrock-autoloader`](https://packagist.org/packages/roots/bedrock-autoloader)               | Scans `web/app/mu-plugins/<dir>/` and loads each one's main file. Required because WordPress only auto-loads `.php` files at the *root* of `mu-plugins/`, not in subdirectories — and composer-installed mu-plugins always end up in subdirectories. |
| [`roots/bedrock-disallow-indexing`](https://packagist.org/packages/roots/bedrock-disallow-indexing) | When `DISALLOW_INDEXING` is true, forces `blog_public = 0` (noindex meta + robots.txt `Disallow: /`) and adds an admin notice with the current `WP_ENV`. Fires automatically on `staging` / `development`, no-ops on `production`.                   |

[`vlucas/phpdotenv`](https://packagist.org/packages/vlucas/phpdotenv)
and [`oscarotero/env`](https://packagist.org/packages/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](https://roots.io/sage/) (modern build
tooling for a custom theme) and [Trellis](https://roots.io/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

<Steps>
  <Step title="Use this template">
    Click **[Use this template](https://github.com/frankenpress/site-template/generate)** on the repo. Pick a name (e.g. `mysite`).
  </Step>

  <Step title="Bootstrap locally">
    ```bash theme={null}
    git clone git@github.com:<your-org>/<your-site>.git
    cd <your-site>
    fp init
    ```

    `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`).

    <Note>
      The [`charts`](/components/charts#first-install) 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.
    </Note>

    <Tip>
      Lower-level alternative if you'd rather drive each step manually:

      ```bash theme={null}
      make setup    # composer install + .env from .env.example
      make up       # docker compose up -d
      make wp ARGS="core install --url=http://localhost:8080 \
        --title='My Site' --admin_user=admin \
        --admin_email=admin@example.com --admin_password=admin --skip-email"
      ```

      `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="…"`.
    </Tip>
  </Step>
</Steps>

## Adding plugins and themes

Composer-managed (recommended):

```bash theme={null}
composer require wpackagist-plugin/seo-by-rank-math
composer require wpackagist-theme/twentytwentyfive
```

The [wpackagist](https://wpackagist.org/) 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.

<Tip>
  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`](/components/mu-plugin) loads explicitly.
</Tip>

## 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:

```php theme={null}
$fp_in_kubernetes = (bool) getenv( 'KUBERNETES_SERVICE_HOST' );
$fp_allow_mods    = filter_var( getenv( 'FP_ALLOW_FILE_MODS' ), FILTER_VALIDATE_BOOLEAN );
$fp_lockdown      = $fp_in_kubernetes && ! $fp_allow_mods;
Config::define( 'DISALLOW_FILE_EDIT', $fp_lockdown );
Config::define( 'DISALLOW_FILE_MODS', $fp_lockdown );
Config::define( 'DISALLOW_INDIRECT_FILE_MODS', $fp_lockdown );
```

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:

```php theme={null}
Config::define( 'AUTOMATIC_UPDATER_DISABLED', true );
Config::define( 'WP_AUTO_UPDATE_CORE', false );
Config::define( 'WP_AUTO_UPDATE_PLUGINS', false );
Config::define( 'WP_AUTO_UPDATE_THEMES', false );
```

`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:

```bash theme={null}
git tag v1.0.0
git push origin v1.0.0
```

The image is then available at `ghcr.io/<your-org>/<your-site>:v1.0.0`.
Deploy with:

```bash theme={null}
helm upgrade mysite oci://ghcr.io/frankenpress/charts/site \
  --namespace mysite \
  --set image.repository=ghcr.io/<your-org>/<your-site> \
  --set image.tag=v1.0.0
```
