CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Deployment & Operations

Docker

The Docker Compose stack for local development and single-server production (a preview) — services, volumes, environment variables, the images and their build stages, and how Caddy caches pages.

The CWA template repository ships a complete Docker Compose stack. Clone the template and you have a fully wired local environment — PHP, Nuxt, Mercure and PostgreSQL — ready in minutes. For first-time local setup, follow Installation; this page is the reference for the stack itself. When something goes wrong, see Troubleshooting.

Services

Three services, and no separate proxy or hub container: FrankenPHP is the front door, and the Mercure hub runs inside it as a Caddy module. Development adds a fourth, Mailpit, to catch email.

ServiceImageRole
phpFrankenPHPSymfony API, Caddy front door, embedded Mercure hub; proxies non-API traffic to app
appNode 26 alpineNuxt SSR application (port 3000, published on 3001 in dev)
databasePostgreSQL 16 alpinePrimary database
mailpitaxllent/mailpitDevelopment only. Catches every email the API sends, such as password resets and verification links, and shows them at http://localhost:8025

mailpit is declared in compose.override.yaml only, so compose.prod.yaml never starts it. php sends to it over SMTP at mailpit:1025, and only its web UI, port 8025, is published. It has no volume, so its emails last only while the container runs.

The Nuxt service is called app, not nuxt — so it's docker compose exec app ….

Volumes

compose.yaml declares three named volumes. They survive docker compose down, so data and certificates persist between restarts:

VolumeMounted atHolds
caddy_data/data in phpCaddy's data, including the local certificate authority your browser trusts for https://localhost
caddy_config/config in phpCaddy's saved config, and Composer's /config/composer/auth.json
database_data/var/lib/postgresql/data in databaseThe PostgreSQL data

compose.prod.yaml adds a fourth, media_data, mounted at /app/public/uploads in php. It holds a single server's uploaded media and image variants (see Media on one server).

docker compose down -v deletes them, and with them the database and the local CA. compose.yaml notes you can bind-mount a host directory for the database instead, so it's harder to remove by accident.

In development, compose.override.yaml also mounts your source (see Development workflow).

Local Development

Environment Variables

Three files set the development environment, and they are read by different things:

FileRead byUse it for
.env in the project root (yours, not committed)Docker Compose, to fill the ${…} values in compose.yaml and compose.override.yamlWhat Compose passes to the containers: SERVER_NAME, POSTGRES_*, CADDY_MERCURE_*, API_URL, API_URL_BROWSER, XDEBUG_MODE, MAILER_DSN
api/.env (committed)Symfony, inside phpThe API's defaults
api/.env.local (yours, not committed)Symfony, inside php, after api/.envYour own values for the API, such as ADMIN_USERNAME, ADMIN_PASSWORD and ADMIN_EMAIL

A variable Compose sets on the container wins over both of Symfony's files. Compose sets DATABASE_URL, the MERCURE_* URLs and keys, TRUSTED_HOSTS and TRUSTED_PROXIES on php, and in development MAILER_DSN too, so the same name in api/.env.local is ignored. Change those through the root .env instead. For example, to send development email through a real SMTP server instead of Mailpit:

# .env in the project root
MAILER_DSN=smtp://user:password@smtp.example.com:587

Then run docker compose up -d to recreate php with it. Without one, development email goes to Mailpit (smtp://mailpit:1025).

The two CWA API URLs are intentionally different in Docker Compose: NUXT_CWA_API_URL (default https://php.local/_api) is how the Nuxt container reaches PHP over the Docker network (php.local is a link alias on the app service), while NUXT_PUBLIC_CWA_API_URL_BROWSER (default https://localhost/_api) is the public-facing URL clients use from their browsers. They must stay different whenever the API is on an internal Docker hostname. Override them with API_URL and API_URL_BROWSER in the root .env, and the Mercure URLs with CADDY_MERCURE_URL / CADDY_MERCURE_PUBLIC_URL.

Cookie SameSite: JWT_COOKIE_SAMESITE sets SameSite on the login, session and Mercure cookies. The template defaults it to strict. Subdomains of one domain, such as www.example.com and api.example.com, count as the same site, so strict works. If the API and the front end are on different registrable domains, set JWT_COOKIE_SAMESITE=none (CWA_API_JWT_COOKIE_SAMESITE as a CI variable in a Kubernetes deploy), with Secure: true. See API Authentication › The cookie's SameSite.

Starting the Stack

From the project root:

bin/up

bin/up runs docker compose up -d --wait, loads the fixtures with --append, pre-compiles / and /login, and prints the login (see Installation › Let bin/up start the site). It's safe to run again; bin/up --skip-fixtures starts the stack without resetting the admin's password.

The php entrypoint installs Composer dependencies if needed, generates dev JWT keys, waits for the database, runs migrations and runs ANALYZE, all before Caddy accepts traffic.

Waiting for the stack

Both images have a Docker HEALTHCHECK, so docker compose up -d --wait returns only once the site is ready, and docker compose ps shows each container as starting, healthy or unhealthy:

docker compose up -d --wait
  • php requests http://127.0.0.1:2080/_api/_/health, the bundle's readiness route (see Kubernetes & Helm › The API's health endpoints). It answers 200 once PHP runs and the database responds, and 503 while the database is down. Port 2080 is a site of its own in the Caddyfile: it listens on the container's loopback address only, answers only the two health paths (anything else is a 404), and works whatever SERVER_NAME is. It sends the Host from BROWSER_SERVER_NAME (default localhost), so it passes TRUSTED_HOSTS. The health paths are also left out of Souin's @use_cache, so a check always reaches php. The start period is 300 seconds, for a first composer install and the migrations.
  • app requests http://127.0.0.1:3000/_cwa/healthcheck, which the @cwa/nuxt module answers from Node without calling the API, so an API outage doesn't mark app unhealthy. The start period is 300 seconds in the dev image, which runs pnpm install first, and 30 seconds in the production image.

Both check every 10 seconds, with a 5-second timeout, and turn unhealthy after three failures in a row. app and php also restart unless you stopped them (restart: unless-stopped). Kubernetes ignores a Docker HEALTHCHECK: the chart has its own probes (see Kubernetes & Helm › Health Checks). If php stays unhealthy, see Troubleshooting › php stays unhealthy.

Keep the Caddyfile and both Dockerfiles in step: api/frankenphp/Caddyfile, api/Dockerfile and app/Dockerfile. php's health check calls the Caddyfile's port 2080 site, so an api/Dockerfile with a Caddyfile that lacks that site leaves php unhealthy for good. Rebuild with docker compose build php app after changing them.

Development Workflow

compose.override.yaml mounts source directories into the containers:

  • PHP: your api/ directory is mounted; PHP changes are reflected immediately (no build step)
  • Nuxt (app service): app/ is mounted; Vite HMR updates the browser on save
  • Media: uploads and their image variants are stored in api/public/uploads (gitignored), and served at https://localhost/uploads/…
  • Xdebug: installed in the dev image but off by default (XDEBUG_MODE=off); run with XDEBUG_MODE=debug to enable it, then connect via your IDE on port 9003

The dev config is read through the ./api directory mount, never single-file mounts, which Docker Desktop can serve truncated (see Troubleshooting).

JWT Keys

In development you don't generate keys. Outside APP_ENV=prod, the php entrypoint runs lexik:jwt:generate-keypair --skip-if-exists, which writes api/config/jwt/private.pem and public.pem (both gitignored) encrypted with the JWT_PASSPHRASE from api/.env. The template ships a working passphrase for local use.

The production image carries no key files and never generates them. compose.prod.yaml takes the key contents in JWT_SECRET_KEY and JWT_PUBLIC_KEY, with JWT_PASSPHRASE, and the CI deploy generates them when they aren't set (see CI/CD Variables › Secrets). Never bake private keys into an image: the template's api/.dockerignore keeps them out (see What stays out of the images).

Use your own passphrase anywhere public. Generate one with docker compose exec php php -r "print bin2hex(random_bytes(26));". The passphrase and the keys must always match — change one without the other and every login fails. To regenerate a key pair for a new passphrase locally, set it in api/.env.local and run docker compose exec php bin/console lexik:jwt:generate-keypair --overwrite.

Images

Multi-stage Dockerfiles produce lean production images:

# Build PHP image
docker build --target frankenphp_prod -t ghcr.io/your-org/app-php:latest ./api

# Build Nuxt image
docker build --target prod -t ghcr.io/your-org/app-nuxt:latest ./app

To be able to roll back, tag each build with its git SHA instead of latest: see Kubernetes › Rollback.

PHP Dockerfile stages

The php image is built in two layers: a base with everything that's the same for every CWA project (the patched FrankenPHP, Debian packages and PHP extensions), and your project's own Caddy config and code on top.

  1. builder — compiles FrankenPHP with xcaddy, adding these Caddy modules:
    Souin and its Redis storage are built from patched copies (see Patches in the build).
  2. cwa_php_base — the base: FrankenPHP from builder, the Debian packages and the PHP extensions. It holds none of your project's files.
  3. frankenphp_base — starts FROM ${CWA_PHP_BASE} (see Where the base comes from) and adds the project's PHP settings, entrypoint and Caddyfiles. Add your own Debian packages or PHP extensions here, below that FROM line, never in cwa_php_base, which a build that pulls the base skips.
  4. frankenphp_dev / frankenphp_prod — the two targets you build. xdebug is built into the base but turned off; frankenphp_dev turns it on (with XDEBUG_MODE=off until you set a mode).

Where the base comes from

api/Dockerfile sets the base with ARG CWA_PHP_BASE. It's either:

  • ghcr.io/components-web-app/cwa-php-base:<version>, a public image published for each template release, for amd64 and arm64. A build that pulls it compiles no Go and installs no PHP extensions, which are the slow steps of an uncached build; or
  • cwa_php_base, which builds the base from source in your own build.
A project pulls the base by default. Each release's api/Dockerfile defaults to that release's own image, such as ARG CWA_PHP_BASE=ghcr.io/components-web-app/cwa-php-base:2.0.0-alpha.44, and every build, locally and in CI, starts from it.

Build from source when you change what's in the base: the Souin or storages patches (api/frankenphp/souin/, api/frankenphp/storages/), api/frankenphp/caddy/queryallowlist or the xcaddy modules. A build that pulls the base ignores those files.

  • Locally: set CWA_PHP_BASE=cwa_php_base in the root .env (it's commented out in .env.dist), then docker compose build php. compose.override.yaml and compose.prod.yaml pass it to the build only when it's set. Never set it to an empty value: the build fails on FROM "".
  • In CI: set CWA_CI_PHP_IMAGE_FROM_SOURCE="true" (see CI/CD Variables › php image). Don't make it Protected, or review branches' builds pull the release's base and leave out your changes.
  • With plain Docker: docker build --build-arg CWA_PHP_BASE=cwa_php_base --target frankenphp_prod ./api.

Building from source needs BuildKit (the default builder); the legacy builder (DOCKER_BUILDKIT=0) can't skip the stages it doesn't use.

A pulled base stays as it was released. A build from source gets the newest dunglas/frankenphp base each time, with its PHP patch releases and Debian security fixes. A build that pulls gets them only when you upgrade the template, or from the weekly rebuild of the newest release, tagged <version>-rYYYYMMDD: set CWA_CI_PHP_BASE_IMAGE to that tag, or to <image>@sha256:… to pin a digest. Locally, CWA_PHP_BASE takes the same values. A rebuild can move to a newer PHP minor version if FrankenPHP's image has. Tags are never overwritten.

The template's own CI always builds the base from source, so its patches are tested on every push. That's matched on the template's own repository, so it never applies to your project.

.github/workflows/publish-php-base.yml and .github/php-base/ publish the template's own image: your project doesn't need them, and create-cwa leaves them out.

Patches in the build

These apply wherever the base is built: in cwa-php-base, or in your build from source.

The builder's Go module fetches and the xcaddy build run through retry-fetch, the script described for the Debian fetches below: 300 seconds for each go mod download and 1000 seconds for the build, up to three attempts each, so a sum.golang.org stream error or a slow mirror on a CI runner doesn't fail an uncached build. Go also fetches over HTTP/1.1 (GODEBUG=http2client=0), because sum.golang.org can reset HTTP/2 streams from GitLab SaaS runners; checksums are still verified.

Packages and extensions in the base

Every step that fetches from Debian (the base packages and the install-php-extensions call) runs through api/frankenphp/retry-fetch.sh, which gives the step a hard time limit and tries it up to three times: 240 seconds for the base packages and 480 for the extensions, pdo_pgsql included. apt fetches deb.debian.org over HTTPS, and retries with 30-second timeouts and no pipelining, so a stalled fetch can't hold an uncached build until the CI job's one-hour limit.

The base installs libvips (libvips42t64) and the ffi extension for the libvips imagine driver (see Image processing), with these defaults. Each can be overridden at runtime; on a Kubernetes deploy, set it as CWA_API_ plus the name:

  • VIPS_BLOCK_UNTRUSTED=1: no ImageMagick, PDF or SVG loaders for uploads
  • VIPS_CONCURRENCY=2: two threads per image, where libvips would otherwise use one per CPU, and memory grows with the thread count
  • MALLOC_ARENA_MAX=2 and MALLOC_MMAP_THRESHOLD_=1048576: glibc hands freed image buffers back to the system. Without them, php keeps 200 to 300 MB more memory after several images are processed at once

The base also installs the brotli PHP extension. warm-http-cache --accept-encoding=br needs it to decode the responses it warms; without it, the br pass of the start-up warm (Kubernetes & Helm › Warming the API cache on start) fails. api/tests/Unit/BrotliExtensionTest.php checks the extension is there.

Your project's layer

frankenphp_base copies api/frankenphp/Caddyfile to /etc/caddy/Caddyfile, and beside it mercure-hub.caddy and mercure-hub.Caddyfile. The Caddyfile imports the Mercure hub's settings from mercure-hub.caddy (see Real-Time Updates (Mercure) › Hub Caddyfile). The dev stack runs the Caddyfile from /app/frankenphp/, where the import finds the file in the mounted api/ directory.

Copy mercure-hub.caddy and mercure-hub.Caddyfile whenever you copy the template's Caddyfile, and the COPY line for them in api/Dockerfile. Without mercure-hub.caddy, Caddy can't load the Caddyfile's import, so php doesn't start. If you changed the hub's settings in your own Caddyfile's mercure block, make the change in mercure-hub.caddy instead. mercure-hub.Caddyfile is for scaled mode's own hub pod (see Scaled Mode › The Mercure hub), and the COPY line needs it even if you never turn scaled mode on.

Migrations are not baked into the image. They are run at container start: the template's php entrypoint waits for the database, then runs app:database:migrate, which runs doctrine:migrations:migrate --allow-no-migration --all-or-nothing, then ANALYZE. On PostgreSQL it holds a lock while it does, so several php containers starting together migrate once, and the others wait (see Kubernetes & Helm › Running Migrations).

Nuxt Dockerfile stages

  1. base — corepack/pnpm setup. Its apk add is limited to 90 seconds and tried three times, switching to another Alpine mirror after a failure, so a stalled mirror can't hang the build
  2. dev — pnpm dev with HMR
  3. builder — pnpm build (outputs .output/)
  4. prod — copies .output/ into /srv/app and runs node server/index.mjs as an unprivileged user

What stays out of the images

  • api/.dockerignore keeps config/jwt/*.pem, decrypted Symfony secrets and public/uploads/ out of the API image. They're gitignored, so a CI build never has them, but an image built on a developer's machine would otherwise copy them in.
  • app/.dockerignore leaves out .env, .env.local and .env.*.local, because nuxt build reads app/.env, so a local file with real keys would end up in an image built on a developer's machine. It also leaves out .pnpm-store/, node-compile-cache/, logs, OS files, the Dockerfile and the compose files, so a local build matches CI's.

Image processing

The API makes thumbnails with libvips instead of GD: driver: vips in api/config/packages/liip_imagine.yaml, through rokka/imagine-vips and jcupitt/vips. A 12 MP thumbnail takes about 200 ms and 50 MB, where GD takes 320 ms and 140 MB. libvips' memory is outside PHP's memory_limit, so large originals don't exhaust it, and image uploads are accepted up to 30 MP.

  • jcupitt/vips binds libvips through FFI, so conf.d/10-app.ini sets ffi.enable = true. The web server only allows FFI with true.
  • api/config/preload_vips.php starts libvips once, before FrankenPHP starts its threads. libvips' start-up isn't thread-safe, so without it the first images processed at the same time in worker mode segfault with cannot retrieve class for invalid (unclassed) type. The production image runs it from config/preload.php, and the dev image preloads it directly from conf.d/20-app.dev.ini. Keep it while the driver is vips.
  • Every filter set in the template's liip_imagine.yaml saves WebP (format: webp, quality: 80), whatever the upload's format. See WebP and Other Output Formats.
  • GD is still installed, and liip_imagine.yaml has a commented # driver: gd line above driver: vips. Both drivers read AVIF uploads: libvips through libheif, and GD through Debian's libavif. So switching needs no image rebuild. To switch to GD, set driver: gd and lower the maxPixels limit on Image::$file, and on any other image field, to 20 MP. A field with #[UploadedImage] and no maxPixels takes its limit from uploadable.image_max_pixels instead.

If Composer or PHP reports a missing ffi extension, the dev php image needs rebuilding: see Troubleshooting.

Production Docker Compose

Single-server production is a preview. The template's bin/server/ scripts deploy, back up and restore it (see Deploy to One Server), but they haven't run a production site yet. Kubernetes with Helm is the supported way to run a site (see Deploying a CWA Site).

For production on one server, use compose.prod.yaml in place of the dev override. bin/server/init writes .env.prod, whose COMPOSE_FILE picks compose.yaml and compose.prod.yaml, and bin/server/deploy.sh starts the stack (see Deploy to One Server). By hand:

docker compose --env-file .env.prod up -d --wait

It selects the frankenphp_prod and prod build targets, and sets for one server what the Kubernetes deploy sets for its hosts. Its two site settings are:

VariableWhat it sets
SITE_HOSTRequired. The site's one public host, such as www.example.com: no scheme, no port. Caddy serves it with its own automatic HTTPS, and it's the host in NUXT_SITE_URL (canonical URLs and og:url), the browser's API URL, the Mercure public URL and BROWSER_SERVER_NAME
SITE_ALIASESOptional. More hosts served the same way, such as example.com, separated by spaces (a comma stops php starting)

compose.prod.yaml doesn't read SERVER_NAME: it builds Caddy's SERVER_NAME as <SITE_HOST> <SITE_ALIASES>, php.local:80, php.local:443. The development stack reads SERVER_NAME.

From the hosts, php's entrypoint builds TRUSTED_HOSTS (^(?:www\.example\.com|example\.com|localhost)$), CORS_ALLOW_ORIGIN (^https://(?:www\.example\.com|example\.com)$) and MERCURE_CORS_ORIGIN (https://www.example.com https://example.com), anchored and escaped as the Kubernetes deploy builds them. Compose can't escape or anchor a value, so the entrypoint does it (api/frankenphp/site-hosts.sh). Set any of the three yourself and your value wins. A host with a scheme, a port or any character other than letters, digits, dots and dashes stops php from starting, with SITE_HOST and SITE_ALIASES must be host names without a scheme or port, not '<host>'.

It refuses to start unless these are set: SITE_HOST, APP_SECRET, NUXT_APP_SECRET, POSTGRES_PASSWORD, CADDY_MERCURE_JWT_SECRET, JWT_SECRET_KEY, JWT_PUBLIC_KEY, JWT_PASSPHRASE, ADMIN_PASSWORD and MAILER_DSN. A missing one stops docker compose before anything starts, for example:

error while interpolating services.php.environment.MAILER_DSN: required variable MAILER_DSN is missing a value: set MAILER_DSN, your mail relay (Symfony Mailer DSN)

CWA_ENVIRONMENT defaults to production for php and the Nuxt app. The file also rotates each service's Docker logs (10 MB, five files), restarts php, the app and the database unless you stop them, and starts php only once the database is healthy.

bin/server/deploy.sh purges the rendered pages and warms the cache after every deploy, so cached pages never point at an old build's /_nuxt files (see Deploy to One Server › What a deploy does). If you restart the app by hand instead, purge them yourself:

docker compose --env-file .env.prod exec php bin/console silverback:api-components:purge-rendered-html

The browser tests boot this same configuration, so CI starts it on every run they're on.

ADMIN_PASSWORD is the super admin's password, which every fixture load resets the admin to, so the public development login admin / admin can't reach a production server by accident. Set it to a long random value. ADMIN_USERNAME and ADMIN_EMAIL are passed to php when you set them, and otherwise default to admin and admin@example.com (see CI/CD Variables › The admin password).

The Nuxt container renders over http://php.local/_api and warms the page cache over https://php.local, where php.local is a network alias of the php service. Plain HTTP works there because php.local:80 is in Caddy's SERVER_NAME; any other internal host, such as http://php, gets a 308 to HTTPS. Listing several ports there is also why the Mercure hub needs a name (in mercure-hub.caddy; see Real-Time Updates (Mercure) › Hub Caddyfile). Keep it if you edit the SERVER_NAME line in compose.prod.yaml. See Choosing the warm origin for why the two URLs differ.

compose.prod.yaml also sets CADDY_EDGE=true, because Caddy faces the internet itself here. The rate limit (Origin Protection › Rate Limit on Cache Misses) then counts a direct client by its own address and never believes an X-Forwarded-For header it sends, so a client can't avoid the limit by inventing one. Cloudflare is still recognised by its address ranges. Set CADDY_EDGE=false only if a proxy other than Cloudflare sits in front, and add that proxy's ranges to CADDY_TRUSTED_PROXIES.

Media on one server

compose.prod.yaml sets MEDIA_STORAGE=local. Uploads and their image variants are stored in the media_data volume, mounted at /app/public/uploads in php, and served at https://<SITE_HOST>/uploads/…. The volume outlives the php container, so rebuilding or recreating php keeps every file. Back it up with the database: it's the only copy.

To store media in a Cloud Storage bucket instead, set MEDIA_STORAGE=gcloud with GCLOUD_BUCKET and GCLOUD_JSON, the service account key. GCLOUD_PUBLIC_URL and GCLOUD_PATH_PREFIX reach php when you set them too. See CI/CD Variables › Media storage.

Caddy serves /uploads/ as plain files and never passes them to PHP. A stored file can keep the extension its uploader gave it, so a file named .php under public/uploads is sent as it is, never run. Keep the Caddyfile's handle /uploads/* block, with only file_server in it, if you edit the Caddyfile.

Origin protection settings

compose.yaml passes these to the php container only when you set them in the shell or .env. Otherwise the defaults in api/frankenphp/Caddyfile and worker.Caddyfile apply:

CACHE_QUERY_ALLOWLIST="page search order tab"
RATE_LIMIT_EVENTS=1200
FRANKENPHP_MAX_WAIT_TIME=3s

The full list is CACHE_QUERY_ALLOWLIST, RATE_LIMIT_ENABLED, RATE_LIMIT_EVENTS, RATE_LIMIT_WINDOW, CADDY_TRUSTED_PROXIES, CLOUDFLARE_IP_RANGES and FRANKENPHP_MAX_WAIT_TIME. See CI/CD Variables › Origin protection for what each one does. Compose uses these bare names; a Kubernetes deploy sets the same settings as CI variables named CWA_API_ plus the name, such as CWA_API_RATE_LIMIT_EVENTS. The Cloudflare purge settings (CLOUDFLARE_PURGE_*) are passed the same way (see CI/CD Variables › CDN purging).

Leave a setting out rather than setting it empty. Caddy uses its default only when a variable is unset, and treats an empty value as a value. That's why compose.yaml lists them bare (RATE_LIMIT_EVENTS:) and never as ${RATE_LIMIT_EVENTS:-}. Keep that form if you add your own.

FRANKENPHP_MAX_WAIT_TIME only applies to the production image: see Origin Protection › Load shedding. The rate limit never applies to private addresses, so your own browser may never trigger it on the dev stack. To try it locally, send an X-Forwarded-For header with a public address: the request comes from a trusted private address, so Caddy believes the header.

The HTTP Cache in Caddy

Souin, inside the php container's Caddy, caches API responses and the page HTML Nuxt renders. This section explains how the template's Caddyfile sets it up. See Page Caching for how long a page is kept, and Purging Every Rendered Page for the API side. If you edit the Caddyfile, check it with bin/test/caddy-validate.sh (see Troubleshooting › Caddy and Souin).

What the page cache stores

The template's @use_cache matcher has two branches. The first caches /_api responses. The second caches the HTML pages that Nuxt renders. The module tags these pages by default; set cwa.pageCache.enabled: false to turn this off. The module adds the IRI of every resource used on the page, and the constant key cwa-html. When the API saves a resource, its existing purge removes every page that used that resource. Saving site config removes every page, through cwa-html.

The HTML branch skips some paths on purpose. Keep these exclusions if you edit it:

  • /.well-known/ — Mercure uses server-sent events. A cached SSE response never finishes, so connections build up and the site looks like it has gone down.
  • /_nuxt/ — these files are already immutable, and caching large bundles would push useful entries out of the in-memory cache.
  • /uploads/, /bundles/, /_cwa/, and the login, /forgot-password, /reset-password, email-verification and user-area pages — their content depends on the user, or they are not rendered by Nuxt.

The HTML branch also caches only paths with no file extension, which are pages, and .xml, which is the sitemap. Any other file, such as a PDF or image in Nuxt's public/ folder, goes past the cache and is streamed. Souin reads the whole body of a cacheable response into memory before it decides anything, so one download of a 187 MB PDF through the cache took the php container past its 1Gi memory limit and it was OOMKilled. The /_api branch skips .pdf files for the same reason. Caddy's {file.ext} includes the dot, so compare against ".pdf", not "pdf".

Don't rely on Souin's max_cacheable_body_bytes to protect memory. Souin checks it only after it has buffered the whole body, so a large file still uses that much memory. Keep large files out of @use_cache instead.

If Cloudflare caches pages in front of Souin, its cache rules must mirror these exclusions. See Cloudflare.

Compressed cache entries

The Caddyfile has order cache before encode, so a page is compressed once, on the miss, and a hit is served as it was stored. With the cache inside encode instead, Caddy compresses every cache hit again, and Brotli caps a php pod at about 110 page views a second.

To keep the number of entries down, the Caddyfile first rewrites Accept-Encoding to br, gzip or nothing (the @encoding_* matchers and request_header), and the cache key ends with the same value. So a page is stored at most three times, whatever the browser sends, and a tag purge removes every copy. encode doesn't offer zstd. If you edit the Caddyfile, keep the encoding matchers, the request_header lines and {http.vars.cwa_cache_encoding} in the key together. bin/test/cache-encoding.sh, in the unit-tests job, runs the Caddyfile against a stub app and fails if the cache is inside encode. See Deployment requirements.

The cache store and backend timeout

The cache block names its store and its backend timeout on their own lines:

api/frankenphp/Caddyfile
timeout {
    backend {$CADDY_CACHE_BACKEND_TIMEOUT:30s}
}

{$CADDY_CACHE_STORAGE:otter}

{$CADDY_CACHE_EXTRA_CONFIG}
  • The backend timeout is 30 seconds, where Souin's own default is 10. After a full flush or a rollout, a CMS page with about 70 resources can take about 12 seconds to render cold, which with a 10-second timeout gives its first visitor a 504 that Cloudflare passes on. 30 seconds covers that, and stays under ingress-nginx's 60 seconds and Cloudflare's 100, so a render that is really stuck still gets Souin's own error first. It doesn't change load shedding: a request that waits for a php worker still gets a 503 after FRANKENPHP_MAX_WAIT_TIME. bin/test/souin-backend-timeout.sh, in the unit-tests job, checks that a 12-second render is served and cached once.
  • The store is otter, Souin's in-memory store, capped at 10,000 entries by count, not by size. It's the same on the dev stack, in the browser tests and on every deploy.
  • CADDY_CACHE_EXTRA_CONFIG is for any other Souin directive. It's empty by default, and must never name a store or hold a timeout block.

On a deploy, set them as CWA_API_CADDY_CACHE_BACKEND_TIMEOUT, CWA_API_CADDY_CACHE_STORAGE and CADDY_CACHE_EXTRA_CONFIG. See CI/CD Variables › HTTP cache, which also explains why the deploy refuses a store or timeout in the extra config.

/_nuxt files are compressed when the app is built

php's Caddy passes every /_nuxt/ request on to Nuxt, and its encode compresses any response that isn't already compressed. Compressing every JavaScript file with Brotli on every request runs php out of memory at about 100 first visits a second. So app/nuxt.config.ts writes a .br and a .gz copy of each build asset under /_nuxt at build time. Nitro serves the copy that matches the browser's Accept-Encoding, and Caddy leaves a response that is already compressed alone. Don't remove it.

It's a small inline module that runs a precompressBuildAssets helper, defined at the top of the file, on Nitro's nitro:build:public-assets hook:

modules: [
  // ...
  (_options, nuxt) => {    nuxt.hook('nitro:build:public-assets', nitro => precompressBuildAssets(join(nitro.options.output.publicDir, nuxt.options.app.buildAssetsDir)))   },],

The helper compresses JavaScript, CSS, SVG, JSON, HTML, text, XML and font files of 1 KB or more, at maximum Brotli and gzip quality. Don't use Nitro's compressPublicAssets instead. It also compresses everything in public/, and one 178 MB PDF there made an app build take about seven minutes instead of one. Nitro serves files in public/ uncompressed. Caddy's encode compresses one on the way out when its type is in the Caddyfile's match list, such as text, JSON, JavaScript or SVG, and leaves a PDF or image as it is.

Put large downloads in media storage, not public/. Every file in public/ is copied into the app image on each build, and it reaches visitors through php's Caddy, which streams it. Upload PDFs and videos to the media storage instead, through an uploadable field.

Tracking parameters

The Caddyfile's uri @tracking_query query { … } block removes utm_*, gclid, fbclid and other tracking parameters, so they don't split the cache. Nuxt and php never receive them. It runs only when the query contains one of them, and never for /_nuxt/*. See Tracking parameters for the list and how to change it.

Caddy's admin API

The admin API on port 2019 replaces Caddy's config and purges or flushes the Souin cache. The template listens on localhost:2019 only and doesn't publish the port, so run admin requests inside the container, for example docker compose exec php curl -s -X PURGE http://localhost:2019/souin-api/souin/flush. CACHE_URL is http://localhost:2019/souin-api/souin.

PURGE and BAN sent to the site return 405

They don't purge anything on the public site. The Caddyfile answers them with 405 instead of passing them on to Nuxt or php. The app's own purges use the admin API above. To purge one page, see Purging one page.

Troubleshooting

Errors from the stack, from local-dev problems to Caddy, Souin and Mercure messages, each have their own entry on Troubleshooting.