The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
Deployment

Load Testing

Stress test a CWA site with the template's k6 script — how many visitors it serves, how quickly, and whether they got the page cache or a server-side render.

The template ships a k6 script at bin/load-test/launch.js. It tells you how many visitors your site can serve and how quickly. It also tells you whether those visitors got the page cache or a fresh server-side render (SSR), because the two differ by orders of magnitude.

It is a manual tool. It is not part of CI, on purpose. A load test sends real traffic and costs money on an autoscaled cluster, so someone should decide to run it.

Install k6

brew install k6                      # macOS
sudo apt-get install k6              # Debian/Ubuntu, after adding Grafana's apt repo

Or skip the install and use Docker. Pass the variables with -e and the script on stdin:

docker run --rm -i -e BASE_URL=https://staging.example.com -e CONFIRM=yes \
  grafana/k6 run - < bin/load-test/launch.js

See the k6 install guide for other platforms.

Run it from a cloud VM near the cluster, not a laptop. Use a VM in the same region for anything bigger than smoke. A laptop runs out of sockets and bandwidth first. At around 300 visitors k6 reports dial: i/o timeout long before the cluster notices anything, and the numbers measure your Wi-Fi. A small VM (2 vCPU) is enough for a few hundred visitors.

Run a test

# The local stack has a self-signed certificate. Local hosts need no confirmation.
BASE_URL=https://localhost INSECURE=true k6 run bin/load-test/launch.js

# Any other host needs CONFIRM=yes.
BASE_URL=https://staging.example.com CONFIRM=yes MODE=capacity k6 run bin/load-test/launch.js
BASE_URL=https://www.example.com CONFIRM=yes MODE=surge PEOPLE=300 k6 run bin/load-test/launch.js

Start with smoke. It checks the script, the target and the cache headers before you point real load at anything.

By default the pages come from /sitemap.xml, read the same way as the cache warm after a deploy. Set PAGES=/,/about to test specific paths instead.

Image placeholder: a terminal showing a smoke run against https://localhost, from the start banner to the summary.

Modes

MODEWhat it doesUse it to
smoke (default)2 visitors for 30s (DURATION)Check everything is wired up before a bigger test.
capacitySteps the arrival rate up to PEAK_RATE visitors per second, STAGE (30s) per step, then holds.Find the point where latency runs away. Size your pods against this.
surgeRamps to PEOPLE visitors over 60s, holds for 3m (DURATION), then ramps down.Test a launch moment: a URL on a screen and a room reaching for their phones.
soakPEOPLE / 6 visitors for 15m (DURATION)Catch leaks and slow degradation under steady traffic.

Each visitor loads a page and its /_nuxt files, then reads for 2–6 seconds. Next it makes one client-side navigation, which in CWA is two /_api calls (the route and its resource manifest), not another server render. Then it reads for 3–9 seconds more.

Environment variables

Only BASE_URL is required.

VariableDefaultMeaning
BASE_URL(required)Site origin, such as https://www.example.com.
CONFIRMMust be yes for any host that is not local.
MODEsmokesmoke, capacity, surge or soak.
PEOPLE100Simultaneous visitors for surge. soak uses a sixth of it, and capacity derives PEAK_RATE from it.
CACHEwarmwarm, cold or mixed. See below.
COLD_RATIO0.2Share of page loads that bust the cache when CACHE=mixed.
COLD_APIfalsetrue also busts the cache on the /_api calls in cold and mixed loads.
PAGESthe sitemapComma-separated paths to test instead of the sitemap.
MAX_PAGES50Most pages taken from the sitemap.
MAX_ASSETS12Most /_nuxt files fetched per page load.
DURATION30s / 3m / 15mLength of smoke, of the surge hold, or of soak.
STAGE30sLength of each capacity step.
PEAK_RATEPEOPLE / 7, at least 2The top arrival rate for capacity, in visitors per second.
PAGE_P95_MS2000Threshold for the p95 time to first byte of pages.
INSECUREtrue skips TLS verification. Use it only for the local self-signed stack.
SUMMARY_JSONAlso write k6's full summary data to this file.

Cached or rendered: CACHE

Page HTML and /_api responses are served by the Souin cache in front of Nuxt and php. A test that only hits the cache says nothing about SSR, and the reverse is true too. So choose what you are measuring:

  • warm sends plain anonymous requests, as a visitor does. After the first request for each page, these should be cache hits. This is what your visitors get.
  • cold adds a unique k6cb= value to each page request, so every one misses the cache and is rendered by Nuxt. This is the cost of SSR, and the worst case after a purge or a deploy.
  • mixed makes COLD_RATIO of page loads cold and the rest warm.

The navigation's /_api calls stay cacheable unless you set COLD_API=true.

The cache buster must not be a tracking parameter. The template's Caddyfile drops tracking parameters such as utm_source, gclid and fbclid before the cache sees the request. A buster named like one of those would be stripped, and every "cold" request would be served from the cache. k6cb is not on that list, so it stays in the cache key. Keep it that way if you edit the script.

Flush the cache after a cold run

Every busted request is stored as its own cache entry. With the default in-memory store, those entries stay until they expire, the cache is flushed or the pod restarts. After a cold test against a real site, flush the cache from the API pod through Caddy's admin port:

kubectl exec -n <namespace> deploy/<release> -- \
  curl -s -X PURGE http://localhost:2019/souin-api/souin/flush

Caddy's admin API listens only inside the container, so run the request there. Locally, that's docker compose exec php curl -s -X PURGE http://localhost:2019/souin-api/souin/flush. The silverback:api-components:purge-http-cache command does the same flush. See Purging all cached data.

Reading the summary

The script replaces k6's default summary with this:

=== smoke against https://localhost (CACHE=cold) ===
Time to first byte
  Pages (HTML)         avg 116ms  med 83ms  p95 244ms  max 273ms
  API (/_api)          avg 19ms  med 1ms  p95 108ms  max 113ms
  Static (/_nuxt)      avg 6ms  med 6ms  p95 11ms  max 11ms
Souin cache
  Pages                0% hits  (hit 0, miss 6, bypass 0, no header 0)
  API                  83% hits  (hit 10, miss 2, bypass 0, no header 0)
  => page latency above is mostly SSR rendering (cache misses)
Totals
  ...
  • Time to first byte is split into pages, API calls and static files. A cache hit, a render, an API call and a static file differ too much to average together. Read p95, not the average.
    • Pages measures the cache when hits are high, and Nuxt's SSR when they are low.
    • API is php behind Souin. Once warm, almost all of these are hits.
    • Static is Nuxt serving the build's /_nuxt files. Souin does not cache these.
  • Souin cache counts each response's Cache-Status header.
    • hit was served from the cache.
    • miss was forwarded and stored.
    • bypass means Souin gave up, for example a render slower than its 10-second backend timeout.
    • no header means the request never went through the cache: an excluded path, a cookie, or no Souin in front.
  • The => line tells you whether the page numbers measured the cache or SSR.
  • Thresholds: page p95 under PAGE_P95_MS, 99% of pages returning 200, API and static p95 under 1 second, and under 1% failed requests. k6 exits non-zero if any of them fail.

Requests are tagged kind=page|api|asset, and pages also get cache_mode=warm|cold. The per-kind numbers are available in any k6 output, such as --out json=results.json.

Local warm runs can show misses. Dev pages expire after 60 seconds, so a warm run that starts more than a minute after the last one begins with misses. On the template's fixture data, the home page / is never cached, because it uses a draft component.

Traps

Keep Accept-Encoding: gzip. The script pins it on every request. Caddy prefers brotli or zstd, which k6 cannot decode, and k6 reports an undecodable response just like a server error. One run reported 89% failed requests against a healthy server.
CONFIRM=yes is required for any host that is not local. Local means localhost, 127.x.x.x, [::1], host.docker.internal, or a name ending in .local, .localhost or .test. The check runs before any request is sent. A surge is a small denial-of-service attack by design. Only test a site you are responsible for, and tell its owners first. Expect the autoscaler to add pods, which costs money, and watch the site while the test runs.

Next steps