CWA 2.0 is in alpha: try it, build with it, give feedback. APIs can still change between releases; see Releases.
Deploying a CWA Site

Troubleshooting

Find a problem by its symptom or error message — local dev, Caddy and Souin, Mercure, Nuxt, the database, Kubernetes deploys and Cloudflare — with a fix for each and a link to the full explanation.

Each problem below has its own heading, named after what you see: the symptom or the error text. Search the page for your error message. Each entry links back to the reference section that explains the part involved.

Troubleshooting for other parts of CWA is listed at the end, in Elsewhere in the docs.

Local development

Every page returns 502 or 503 after you run pnpm install on the host

In development, compose.override.yaml bind-mounts app/ and app/node_modules into the app container, and that container runs pnpm install and then pnpm dev. A pnpm install on your machine, such as for a dependency bump, rewrites the shared node_modules while the container's dev server is still running from it. The dev server then crash-loops with errors like Cannot find module 'typescript', a missing @nuxt/cli/dist/dev/index.mjs or Cannot resolve module "@nuxt/kit", and Caddy returns 502 or 503 for every page. Restart the container so it reinstalls from inside:

docker compose restart app

When you check its logs afterwards, only look at lines after the restart. The crash from before it is still in the log and looks like a new failure. See Docker → Development Workflow.

Every dev page takes about 30 seconds

This is almost always missing PostgreSQL statistics, not slow I/O. CWA's components use Doctrine JOINED inheritance, so loading a page emits a wide multi-table join. The component subclass tables hold only a handful of rows each — permanently below the threshold that triggers PostgreSQL's autoanalyze — so those tables are never analysed, the planner has no statistics, and it wildly over-estimates the join and throws parallel workers at a query that returns almost nothing. Fix it by analysing the database once:

docker compose exec database psql -U app -d app -c 'ANALYZE;'

The current template does this automatically in the php entrypoint on every compose up, so fresh projects don't hit it — but a project scaffolded before that change, or a new small fixture table, can still trigger it. See Docker → Starting the Stack.

This is easy to misdiagnose because it's a pure query-planner problem with no I/O. A near-idle database, tiny tables, and a 99% cache-hit ratio are all true and completely consistent with it — none of them rule the database out. If dev is mysteriously slow, go straight to EXPLAIN (ANALYZE, BUFFERS) and check SELECT relname, reltuples FROM pg_class for tables reporting -1 (never analysed).

composer update fails with a raw.githubusercontent.com 404

If Composer dies downloading a Symfony Flex recipes index with an HTTP 404, the cause is usually a stale GitHub token in the container's /config/composer/auth.json, not a network or GitHub outage. GitHub returns 404, not 401, for a bad token on raw.githubusercontent.com — and a plain curl of the same URL returns 200 because it sends no token, which misleadingly "proves" the network is fine. The /config volume (caddy_config, see Docker → Volumes) persists across compose down/up, so the bad token survives restarts. Clear it:

docker compose exec php rm -f /config/composer/auth.json

Then re-run composer update (or set a valid GITHUB_TOKEN).

Composer or php reports a missing ffi extension

The project's Composer dependencies require ext-ffi for the libvips imagine driver. An image built before you took that change has no libvips or ffi. Rebuild the dev php image:

docker compose build php && docker compose up -d php

Caddy reports unexpected EOF after you edit the Caddyfile

If Caddy fails with unexpected EOF while api/frankenphp/Caddyfile looks valid on disk, it's a stale-config issue, not a syntax error. On macOS/Windows (Docker Desktop), a single-file bind mount caches the file's size, so an edit that makes the file longer — what most editors' atomic save does — is served to the container truncated. Edits that shrink or keep the length work, which makes it look random. Confirm by comparing byte counts:

wc -c < api/frankenphp/Caddyfile
docker compose exec php sh -c 'wc -c < /app/frankenphp/Caddyfile'

If they differ, recreate the container (restart isn't enough): docker compose up -d --force-recreate php. The CWA template avoids this by reading dev config through the ./api directory mount rather than mounting single files — you'll only hit it if you reintroduce a single-file mount in compose.override.yaml. Production is unaffected: the image bakes the Caddyfile in.

The dev server loads, but nothing on the page responds

On Nuxt 4.5, if pages render but never hydrate, so buttons do nothing and the login form reloads the page, check the browser console for a SyntaxError about __unhead_devtoolsPlugin being declared twice. @unhead/bundler 3.4.1 registers its DevTools runtime import once for each of Nuxt's client and server Vite servers. Turn off unhead's own DevTools panel in nuxt.config.ts:

export default defineNuxtConfig({
  unhead: {
    vite: {
      devtools: false,
    },
  },
})

This only affects development. Nuxt DevTools and useSeoMeta still work, and the production build is unchanged. The template sets this (#95). Remove it once an unhead release fixes the duplicate.

The rate limit never triggers on the dev stack

The rate limit never applies to private addresses, so your own browser may never trigger it locally. To try it, send an X-Forwarded-For header with a public address: the request comes from a trusted private address, so Caddy believes the header. See Docker → Origin protection settings.

Caddy and Souin

Check every Caddyfile change with bin/test/caddy-validate.sh. frankenphp adapt only parses the Caddyfile, so errors raised while Caddy loads its modules, like several below, pass it. The script runs frankenphp validate, which loads every module, for both SERVER_NAME shapes the template uses: Helm's :80 and compose's several ports. Run it locally with docker compose exec -T php sh -s < bin/test/caddy-validate.sh. It checks the Caddyfile at /etc/caddy/Caddyfile, which is the copy built into the image, so rebuild php after editing. CI runs it too: from template 2.0.0-alpha.6 (843082a), the unit-tests job does.

How the template's Caddyfile caches is in Docker → The HTTP Cache in Caddy.

php won't start with unrecognized directive after you copy the template's Caddyfile

From template 2.0.0-alpha.4 (4cbe05a), the Caddyfile uses two Caddy modules that older php images don't have: query_allowlist, the template's own handler for the query allowlist, and rate_limit, from mholt/caddy-ratelimit. An old image refuses the file. In dev, the --watch reload keeps the old config running, and the next restart crash-loops. Rebuild the image, or let CI build it:

docker compose build php && docker compose up -d php

php keeps restarting with token recognition error at: '#'

Comments inside the @use_cache expression break Caddy, and frankenphp adapt does not warn you. The text between the backticks is a CEL expression, not Caddyfile syntax. CEL has no comments. A # line inside the backticks is a parse error, but frankenphp adapt does not check the text inside the backticks, so it reports no problem. The error only appears at runtime, and the php container keeps restarting. Put comments above the block. After changing it, run adapt, then recreate the container and check that it becomes healthy:

docker compose up -d --force-recreate php
docker compose ps php      # should reach "healthy", not "Restarting"

If you edit the Souin @use_cache expression in the Caddyfile, note that an absent cookie does not equal "" in Caddy matchers — unlike a missing header, which does. A clause like {http.request.cookie.api_component} == "" only matches when the cookie is present but empty, so requests with no cookie at all (SSR, curl, health checks) silently miss the cache. Match on the Cookie header instead, so an absent or empty cookie both count as "not authenticated":

!{http.request.header.Cookie}.matches("api_component=[^;]+")

Nothing is cached after a full flush

The php log shows BADGER-INSERTION-ERROR or DB Closed. Unpatched Souin v1.7.9 closes the badger and nuts storages on a full flush, and they stay closed until php restarts. A full flush is Purge all cached data, purge-http-cache, every fixture load, and every deploy with provider cloudflare. The default in-memory otter storage is not affected. Template 2.0.0-alpha.7 adds v1.7.9-flush-no-close.patch, which empties the store and leaves it open (#114, 8ebf5e9). If you use badger or nuts, take the patch and the matching lines in api/Dockerfile, then rebuild the API image. Until then, restart php after each full flush.

Requests that waited on a failed render get an empty 200

Souin sends only one request upstream for a URL that isn't cached, and the others for that URL wait for its response. In unpatched Souin v1.7.9, when that response was a 500, 502, 503 or 504, the waiting requests got a 200 with an empty body and no Cache-Status header, and an SSR render could cache a broken page. Template 2.0.0-alpha.20 adds v1.7.9-singleflight-5xx.patch, so they get the same error (#137, 6df1b15). A 5xx that is private or sets a cookie isn't shared: each waiting request goes upstream itself. bin/test/souin-coalesced-5xx.sh, in the unit-tests job, fails on an unpatched binary. To take it, copy the patch, add its COPY and patch lines to api/Dockerfile straight after the singleflight fix's, and rebuild the API image.

php is OOMKilled while serving a large file

Souin reads the whole body of a cacheable response into memory before it decides anything, so a large file served through the cache can take php past its memory limit. Keep large files out of @use_cache: see Docker → What the page cache stores.

PURGE or BAN sent to the site returns 405

That's intended: they never purged anything. See Docker → PURGE and BAN.

Mercure

The module's own checks for live updates are in Real-time Updates → Troubleshooting.

php won't start after rebuilding with Mercure 1.0

FrankenPHP 1.13 ships Mercure 1.0, which treats unknown directives as errors and has removed demo and ui. If MERCURE_EXTRA_DIRECTIVES sets either, use debugger instead, in development only. The template's Caddyfile uses an issuer block, which a 0.x Mercure doesn't know, so rebuild the API image before you restart php with the new Caddyfile. Two errors mean the Caddyfile and the binary don't match:

  • the "publisher_jwt", "subscriber_jwt", … directives work only in compatibility mode: the old top-level publisher_jwt and subscriber_jwt directives are still there. Move the keys into an issuer block, as described in Hub Caddyfile.
  • an issuer identifier is required in modern mode: the Caddyfile is still set up for compatibility mode (protocol_version_compatibility with top-level publisher_jwt and subscriber_jwt), but the binary was built without the deprecated_topic and deprecated_claim tags, so the hub runs in modern mode anyway. Remove the directive and move the keys into an issuer block.

caddy adapt reports neither error. Only a running hub does, so check php's logs after it starts.

php won't start with only one unnamed Mercure hub is allowed per configuration

An API image built with Mercure 1.0.3 or later refuses the compose stack's Caddyfile unless the mercure block has a name. Compose's SERVER_NAME has several ports, so Caddy runs one server per port, each with its own hub. The healthcheck never passes, and the error only shows in the php container's log. Add name cwa to the mercure block, as template 2.0.0-alpha.5 does (#113), then recreate php. See Hub Caddyfile.

Admins get no private updates locally

With a Mercure 1.0 hub, the cookie is __Secure-mercure_access_token, and browsers only accept it over HTTPS. The template's dev stack serves the hub at https://localhost/.well-known/mercure, so this works out of the box. mercure.yaml pins the name as cookie_name, because mercure-bundle 0.6 drops the __Secure- prefix when kernel.debug is on, as it is in dev, and the hub then wouldn't read the cookie (template 2.0.0-alpha.18, b71fd4d). If you change CADDY_MERCURE_PUBLIC_URL to a plain http:// URL, change that cookie_name to a prefix-less name, and set the same name in the Caddyfile's mercure block (see The subscriber cookie).

If the API and the front end are on different subdomains, also check the cookie's SameSite: see Docker → Environment Variables.

Nuxt

The sitemap is empty, or the Nuxt log shows UND_ERR_HEADERS_OVERFLOW

Node refuses a response whose headers pass 16 KB by default, and a large collection's Surrogate-Key can pass that. The app image raises Node's limit with NODE_OPTIONS=--max-http-header-size=65536; a deploy that sets its own NODE_OPTIONS replaces that. See Cloudflare → Purges must reach Cloudflare.

Database

The in-cluster PostgreSQL restarts empty, or the API can't connect after a credentials change

Without persistence, changing the user or database name on a live site restarts the PostgreSQL pod empty, and a new password alone leaves the API unable to connect. See CI/CD Variables → Database.

Kubernetes deploys

The review job is orange and says No namespace '<name>'

The branch has no Kubernetes namespace, so it has no review app. Create the namespace and its role bindings, then run the job again. See CI/CD → Review Apps.

The deploy fails before Helm runs

The deploy checks several things before it changes anything in the cluster, and its log says which one failed and what to change:

The API pod stays unready, and every probe times out

The php probes request /_api/_/site_config_parameters.jsonld, a path that would pass through the Souin cache. Souin combines concurrent requests for the same cache key into one request to php. In unpatched Souin 1.7.9, a request that is given up while php is still working keeps hold of that key, so every later probe waits for the 10-second backend timeout and fails, and the pod stays unready for good. The template prevents this three ways:

  • The template's Souin build applies the fix from darkweak/souin#850 (api/frankenphp/souin/v1.7.9-singleflight-fix.patch), so a request that times out releases the key. The patch can be removed once a Souin release includes it.
  • The Caddyfile's @use_cache skips the probe path for the kubelet's kube-probe/ user agent, so probes always reach php.
  • Liveness uses the same request as readiness, so a pod whose php has stopped answering is restarted.

Also keep timeoutSeconds: 5 on both probes. See Kubernetes → Health Checks.

Visitors get 502 while a deploy rolls out

A pod that closes its listener as soon as it's told to stop still gets requests for a few seconds, until the ingress and php's proxy drop its endpoint. The chart's preStop hooks hold both pods open for 5 seconds first. If your chart has no lifecycle.preStop blocks, copy them from the template's deployment.yaml and pwa-deployment.yaml. The first deploy with them still rolls the old pods without the hook. See Kubernetes → Stopping a pod without 502s.

The image variants job ends with ran out of memory (exit 137)

The Job generating variants went over its own memory limit. The site isn't affected, because the Job doesn't serve requests. Raise CWA_CI_IMAGE_VARIANTS_MEMORY, or lower CWA_CI_IMAGE_VARIANTS_MAX_MEMORY so each Job stops sooner and the next one carries on. See CI/CD → Generating image variants after a deploy.

Visitors get 503 under load

php gives up on a request that waits too long for a free worker, rather than letting it queue into a 504. See Origin Protection → Load shedding.

The per-visitor rate limit never applies

Every visitor looks internal when the ingress controller's Service uses externalTrafficPolicy: Cluster. Set it to Local, and check visitor_ip in php's access log. See Origin Protection → Ingress rate limits.

Cloudflare

Cloudflare returns 526 for a new hostname or after a staging deploy

The origin's certificate isn't valid yet. See Cloudflare → DNS and TLS and Each deploy track.

A certificate can never be issued or renewed behind Cloudflare

Always Use HTTPS redirects the HTTP-01 challenge. Turn it off. See Each deploy track.

An edit doesn't show at the edge

See Cloudflare → Checking purges for the log messages and how to check that purges arrive.

Pages stay DYNAMIC under the Cache Rule

Cloudflare doesn't cache a response that carries Set-Cookie. See The cache rules must match the template's exclusions.

Pages built from page data all show one page's content after client-side navigation

The API rule is missing the component positions exclusion. See Cloudflare → API responses.

Browsing quickly gets a 429 from Cloudflare

A rate limiting rule is set too low. See Free protection worth turning on.

Elsewhere in the docs