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

# runtime

> Caddy + FrankenPHP + Souin base container image

[`runtime`](https://github.com/frankenpress/runtime) is the base
container image every FrankenPress site extends. It ships:

| Component                            | Version                                                            | Notes                                                                                                                                  |
| ------------------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| **FrankenPHP**                       | `1.12.2`                                                           | Caddy + PHP runtime, single statically-linked binary. Built per supported PHP minor — see [Runtime matrix](/operations/runtime-matrix) |
| **caddyserver/cache-handler**        | `v0.16.0`                                                          | Souin HTTP cache, compiled into the FrankenPHP binary via xcaddy                                                                       |
| **darkweak/storages/go-redis/caddy** | `v0.0.19`                                                          | Redis storage backend for Souin                                                                                                        |
| **dunglas/caddy-cbrotli**            | `v1.0.1`                                                           | Brotli encoding                                                                                                                        |
| **WP-CLI**                           | `2.12.0`                                                           | Cron + admin tasks                                                                                                                     |
| **PHP extensions**                   | gd, intl, exif, zip, opcache, mysqli, pdo\_mysql, memcached, redis | WP-friendly set                                                                                                                        |
| **mu-plugin**                        | configurable                                                       | Baked at build time via `FP_MU_PLUGIN_VERSION` (default: `v0.1.1`)                                                                     |

Published images: a multi-arch manifest list (`linux/amd64` +
`linux/arm64`) per supported PHP minor — `:php8.3`, `:php8.4`,
`:php8.5`. The default channel is **`ghcr.io/frankenpress/runtime:php8.3`**.
See [Runtime matrix](/operations/runtime-matrix) for the full tag schema.

## Consumer pattern

Downstream sites build on top:

```dockerfile theme={null}
# In your-site/Dockerfile
FROM composer:2 AS deps
COPY composer.* /app/
WORKDIR /app
RUN composer install --no-dev --no-scripts --optimize-autoloader

FROM ghcr.io/frankenpress/runtime:php8.3
COPY --from=deps /app/vendor /app/vendor
COPY web /app/web
COPY config /app/config
```

[`site-template`](/components/site-template) ships this Dockerfile
out of the box.

## Environment variables

All optional. Defaults produce a working config out of the box.

| Var                        | Default                | Purpose                                                                                                      |
| -------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `REDIS_URL`                | `redis:6379`           | Address of the Redis used by Souin's HTTP cache                                                              |
| `FP_CACHE_TTL`             | `5m`                   | Default cache entry TTL                                                                                      |
| `FP_CACHE_STALE`           | `1h`                   | Stale-while-revalidate window                                                                                |
| `FP_CACHE_DEFAULT_CONTROL` | `public, s-maxage=300` | `Cache-Control` fallback when upstream doesn't set one                                                       |
| `FP_CACHE_BYPASS_EXTRA`    | (empty)                | Extra alternation fragment appended to the path-bypass regex. See [Cacheability model](#cacheability-model). |
| `FP_DOCROOT`               | `/app/web`             | WordPress webroot inside the container                                                                       |
| `FP_PORT`                  | `8080`                 | Public HTTP listen port                                                                                      |
| `FP_METRICS_PORT`          | `9145`                 | Prometheus metrics listen port (separate from public traffic)                                                |

## Logging

The public server emits Caddy access logs as JSON to stdout (one line per
request); PHP errors flow to stderr via `php.ini`. The metrics server is
intentionally unlogged. End-to-end shipping setup (Vector → Loki / Datadog)
is in [Operations → Logging](/operations/logging).

## Build args

Override at build time with `--build-arg`:

| Arg                         | Default                               |
| --------------------------- | ------------------------------------- |
| `PHP_VERSION`               | `8.3`                                 |
| `FRANKENPHP_VERSION`        | `1.12.2`                              |
| `CACHE_HANDLER_VERSION`     | `v0.16.0`                             |
| `STORAGES_GO_REDIS_VERSION` | `v0.0.19`                             |
| `CADDY_CBROTLI_VERSION`     | `v1.0.1`                              |
| `WP_CLI_VERSION`            | `2.12.0`                              |
| `FP_MU_PLUGIN_VERSION`      | `v0.1.1` (set to `""` to skip baking) |

## Cacheability model

Souin caches **only anonymous GETs to public paths**. Two layers of
bypass keep authenticated and admin traffic out of the cache, regardless
of whether the upstream `Cache-Control` header is honoured by Souin
itself (cache-handler v0.16.0 is unreliable about `no-store` /
`private`):

### Layer 1 — path bypass

The global `cache` block uses `regex.exclude` to skip any path matching:

```text theme={null}
^(/wp)?/(wp-admin/|wp-login\.php|wp-cron\.php|xmlrpc\.php|wp-json/)
```

Covers Bedrock (`/wp/...`) and classic (`/...`) layouts. Set
`FP_CACHE_BYPASS_EXTRA` to add custom paths — the value is appended
verbatim to the regex, so use an alternation fragment with a leading
`|`:

```bash theme={null}
FP_CACHE_BYPASS_EXTRA="|^/api/private/|^/members/"
```

### Layer 2 — cookie bypass

A server-block matcher routes any request carrying a per-user state
cookie around the `cache` directive entirely:

```caddyfile theme={null}
@auth_or_state_cookie header_regexp Cookie (wordpress_logged_in_|wp-postpass_|comment_author_)
```

This is what stops a logged-in admin's response from being keyed into
Redis under the URL anonymous visitors then read — the leak that
prompted this design.

### What's still cached

Everything else: anonymous GETs to `/`, `/<slug>/`, `/category/<x>/`,
feeds, sitemaps, etc. Per the standard HTTP semantics — upstream
`Cache-Control` is honoured, with `FP_CACHE_DEFAULT_CONTROL` as the
fallback when the response is silent.

### Note on cache key scheme

You will see `Cache-Status` headers like:

```text theme={null}
cache-status: Souin; hit; key=GET-http-frankenpress.com-/
```

Even though the client used `https://`. This is intentional and
benign:

* Caddy 2.7.x's `trusted_proxies` directive controls which upstream
  hops may set `X-Forwarded-For` (client IP propagation). It does
  **not** propagate `X-Forwarded-Proto` into the request scheme that
  Souin reads. So when a TLS-terminating proxy (Envoy Gateway,
  Cloudflare, an LB) sits in front, Souin sees plain HTTP from inside
  the cluster and keys cache entries with `http` regardless of how
  the client connected.
* The two failure modes this *could* matter for are both already
  closed: authenticated requests are bypassed entirely via the cookie
  matcher, and invalidation works regardless via mu-plugin
  v0.2.1+'s dual-scheme DEL (the `SouinInvalidator` deletes both
  `GET-http-...` and `GET-https-...` variants on every write).

You can flip the key to `https` natively with either
`cache_keys { .+ { disable_scheme } }` (drops scheme from the key
shape; needs a coordinated invalidator update + cache flush) or an
out-of-tree Caddy plugin that rewrites `r.URL.Scheme`. Neither is
worth the churn for what is purely a `Cache-Status` cosmetic.

## Page cache invalidation

Souin caches GET responses in Redis. Invalidation is performed by
[`mu-plugin`](/components/mu-plugin)'s `SouinInvalidator`,
connecting **directly to Redis** and `DEL`ing cache keys. Souin's
documented HTTP invalidation APIs (`PURGE`, POST-CRUD, `/api.souin/*`
admin) are broken in cache-handler v0.16.0 — the workaround is empirical.

Redis key shape (verified):

```text theme={null}
GET-<scheme>-<host>-<path>     cached response body
IDX_GET-<scheme>-<host>-<path> index entry pointing at the body
SURROGATE_<tag>                set of cache keys for a given Surrogate-Key tag
```

Bulk tag-based invalidation: read `SURROGATE_<tag>` (a Redis SET), then
pipeline-`DEL` all members. Sub-millisecond.

Full investigation log: [`runtime/PHASE-0.md`](https://github.com/frankenpress/runtime/blob/main/PHASE-0.md).

## Local development

```bash theme={null}
git clone https://github.com/frankenpress/runtime
cd runtime
make build         # build runtime:dev
make up            # docker compose up -d (runtime + redis)
make test          # run tests/cache-spike.sh
make down          # tear down
make ci            # all of the above in one shot
```

## CI / publishing

Built and pushed by GitHub Actions on every push to `main` and on
`v*.*.*` tag pushes. CI exercises the full `(php, arch)` matrix and
emits per-PHP rolling, per-PHP per-sha, per-PHP per-release, and an
unprefixed per-release tag (default PHP only). The full schema lives
in [Runtime matrix](/operations/runtime-matrix#tag-schema).
