Troubleshooting
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.
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"
A cache matcher never matches when a cookie is absent
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-levelpublisher_jwtandsubscriber_jwtdirectives are still there. Move the keys into anissuerblock, as described in Hub Caddyfile.an issuer identifier is required in modern mode: the Caddyfile is still set up for compatibility mode (protocol_version_compatibilitywith top-levelpublisher_jwtandsubscriber_jwt), but the binary was built without thedeprecated_topicanddeprecated_claimtags, so the hub runs in modern mode anyway. Remove the directive and move the keys into anissuerblock.
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:
- No
DOMAIN, or one that starts or ends with a dot: the environment url orKUBE_INGRESS_BASE_DOMAINis empty. Seecheck_kube_domainin The Devops Scripts. - The php memory budget doesn't fit: see CI/CD Variables → php memory budget.
Site environment variables (NUXT_*, CWA_API_*) that can't be passed through: see Reserved names.CWA_ENVIRONMENT must be letters, digits, '.', '_' or '-': see The environment name.- The PostgreSQL persistence settings differ from the live database: see In-cluster PostgreSQL persistence.
CADDY_CACHE_CDN_CONFIG is incomplete, so php would not start: see Cloudflare → Secrets.- A new hostname's certificate wasn't issued: point its DNS at the ingress first. See Changing a live site's hostnames.
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_cacheskips the probe path for the kubelet'skube-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
- Real-time Updates → Troubleshooting: live updates don't arrive.
- Debugging & Profiler → Error Status Codes: what each API error status means.
- Date Pickers → Troubleshooting: the picker shows no time.
- Styling & CSS Isolation: your global styles leak into the admin UI.
- Load Testing → Traps: a load test reports failures against a healthy server.
- Releases: many errors above come from copying one part of a release without the rest, so follow each release's upgrade notes in full.