Docker
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.
| Service | Image | Role |
|---|---|---|
php | FrankenPHP | Symfony API, Caddy front door, embedded Mercure hub; proxies non-API traffic to app |
app | Node 26 alpine | Nuxt SSR application (port 3000, published on 3001 in dev) |
database | PostgreSQL 16 alpine | Primary database |
mailpit | axllent/mailpit | Development 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.
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:
| Volume | Mounted at | Holds |
|---|---|---|
caddy_data | /data in php | Caddy's data, including the local certificate authority your browser trusts for https://localhost |
caddy_config | /config in php | Caddy's saved config, and Composer's /config/composer/auth.json |
database_data | /var/lib/postgresql/data in database | The 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:
| File | Read by | Use it for |
|---|---|---|
.env in the project root (yours, not committed) | Docker Compose, to fill the ${…} values in compose.yaml and compose.override.yaml | What Compose passes to the containers: SERVER_NAME, POSTGRES_*, CADDY_MERCURE_*, API_URL, API_URL_BROWSER, XDEBUG_MODE, MAILER_DSN |
api/.env (committed) | Symfony, inside php | The API's defaults |
api/.env.local (yours, not committed) | Symfony, inside php, after api/.env | Your 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
phprequestshttp://127.0.0.1:2080/_api/_/health, the bundle's readiness route (see Kubernetes & Helm › The API's health endpoints). It answers200once PHP runs and the database responds, and503while 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 a404), and works whateverSERVER_NAMEis. It sends theHostfromBROWSER_SERVER_NAME(defaultlocalhost), so it passesTRUSTED_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 firstcomposer installand the migrations.apprequestshttp://127.0.0.1:3000/_cwa/healthcheck, which the@cwa/nuxtmodule answers from Node without calling the API, so an API outage doesn't markappunhealthy. The start period is 300 seconds in the dev image, which runspnpm installfirst, 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.
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 (
appservice):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 athttps://localhost/uploads/… - Xdebug: installed in the dev image but off by default (
XDEBUG_MODE=off); run withXDEBUG_MODE=debugto 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).
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.
builder— compiles FrankenPHP withxcaddy, adding these Caddy modules:- Mercure 1.0, Vulcain, and Souin with its otter, badger, nuts and Redis storages
caddy-cbrotli, for Brotli compressionmholt/caddy-ratelimit, for the rate limit on cache missesqueryallowlist, the template's own handler for the query allowlist (see Origin Protection › Query Parameters on Pages), fromapi/frankenphp/caddy/queryallowlist
Souin and its Redis storage are built from patched copies (see Patches in the build).cwa_php_base— the base: FrankenPHP frombuilder, the Debian packages and the PHP extensions. It holds none of your project's files.frankenphp_base— startsFROM ${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 thatFROMline, never incwa_php_base, which a build that pulls the base skips.frankenphp_dev/frankenphp_prod— the two targets you build.xdebugis built into the base but turned off;frankenphp_devturns it on (withXDEBUG_MODE=offuntil 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; orcwa_php_base, which builds the base from source in your own build.
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_basein the root.env(it's commented out in.env.dist), thendocker compose build php.compose.override.yamlandcompose.prod.yamlpass it to the build only when it's set. Never set it to an empty value: the build fails onFROM "". - 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.
Souin is built from a copy of v1.7.9 with six patches from api/frankenphp/souin/, applied in this order:
v1.7.9-purge-fix.patch: purging one cache tag keeps the index of every other tag that contains itv1.7.9-singleflight-fix.patch: stops a request that times out from blocking its URL for the life of the process (see Troubleshooting › The API pod stays unready, and every probe times out)v1.7.9-singleflight-5xx.patch: requests that waited on another request's5xxget that5xx, not an empty200(see Troubleshooting › Requests that waited on a failed render get an empty200)v1.7.9-flush-no-close.patch: stops a full flush from closing the badger or nuts storage (see Troubleshooting › Nothing is cached after a full flush)v1.7.9-cloudflare-purge.patch: makes the Cloudflare purge work (see Cloudflare › Purges must reach Cloudflare). It also queues purges, paces them to the plan's rate limit and retries refused ones (see Cloudflare › Queued and paced purges)v1.7.9-writer-flush.patch: gives Souin's buffering response writer aFlush()that does nothing, so a PHPflush()behind the cache doesn't logthe current responseWriter is not a flusher. Without it the warning comes on every/_apicache miss in dev, where Symfony's debug runner callsflush(), and on any streamed response in production. Nothing behaves differently: Souin buffers the whole body before it sends it, with or without the patch.bin/test/souin-php-flush.shchecks it in the unit-tests job.
The build also adds Souin's Redis store (darkweak/storages/go-redis), with api/frankenphp/storages/go-redis-v0.0.20-atomic-sets.patch applied so that php pods sharing one Redis all drop every page under a purged tag. It's there for running more than one php pod, which needs a cache the pods share (#85). Only Scaled Mode uses it: by default the cache stays in each pod's memory and php runs one replica, and a deploy refuses a redis store without scaled mode (see Scaled Mode › The shared cache). bin/test/souin-redis-shared-purge.sh runs in the unit-tests job against a Redis service to check the patch.
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 uploadsVIPS_CONCURRENCY=2: two threads per image, where libvips would otherwise use one per CPU, and memory grows with the thread countMALLOC_ARENA_MAX=2andMALLOC_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.
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
base— corepack/pnpm setup. Itsapk addis limited to 90 seconds and tried three times, switching to another Alpine mirror after a failure, so a stalled mirror can't hang the builddev—pnpm devwith HMRbuilder—pnpm build(outputs.output/)prod— copies.output/into/srv/appand runsnode server/index.mjsas an unprivileged user
What stays out of the images
api/.dockerignorekeepsconfig/jwt/*.pem, decrypted Symfony secrets andpublic/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/.dockerignoreleaves out.env,.env.localand.env.*.local, becausenuxt buildreadsapp/.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/vipsbinds libvips through FFI, soconf.d/10-app.inisetsffi.enable = true. The web server only allows FFI withtrue.api/config/preload_vips.phpstarts 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 withcannot retrieve class for invalid (unclassed) type. The production image runs it fromconfig/preload.php, and the dev image preloads it directly fromconf.d/20-app.dev.ini. Keep it while the driver is vips.- Every filter set in the template's
liip_imagine.yamlsaves WebP (format: webp,quality: 80), whatever the upload's format. See WebP and Other Output Formats. - GD is still installed, and
liip_imagine.yamlhas a commented# driver: gdline abovedriver: 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, setdriver: gdand lower themaxPixelslimit onImage::$file, and on any other image field, to 20 MP. A field with#[UploadedImage]and nomaxPixelstakes its limit fromuploadable.image_max_pixelsinstead.
If Composer or PHP reports a missing ffi extension, the dev php image needs rebuilding: see Troubleshooting.
Production Docker Compose
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:
| Variable | What it sets |
|---|---|
SITE_HOST | Required. 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_ALIASES | Optional. 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)
MAILER_DSNis your mail relay, as a Symfony Mailer DSN. Without it the API would send toapi/.env'ssmtp-relay:25, which this stack doesn't have.MAILER_EMAILandERROR_MAIL_TOreach php when you set them (see CI/CD Variables › Application and Kubernetes & Helm › Error mail).- Media is stored on the server, so no bucket is needed (see Media on one server).
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.
/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).
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".
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:
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
504that 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 a503afterFRANKENPHP_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_CONFIGis for any other Souin directive. It's empty by default, and must never name a store or hold atimeoutblock.
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.
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.
Deploying a CWA Site
Choose between one server with Docker Compose (a preview) and Kubernetes with Helm, then take a new site to production on Kubernetes step by step, with the template's GitLab or GitHub pipeline.
Deploy to One Server
Run a CWA site on one Linux server with Docker Compose and the template's bin/server scripts - set up the server, write the site's settings with init, deploy with deploy.sh, schedule backups, restore one, and run commands by hand. A preview.