Load Testing
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.
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.
smoke run against https://localhost, from the start banner to the summary.Modes
MODE | What it does | Use it to |
|---|---|---|
smoke (default) | 2 visitors for 30s (DURATION) | Check everything is wired up before a bigger test. |
capacity | Steps 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. |
surge | Ramps 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. |
soak | PEOPLE / 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.
| Variable | Default | Meaning |
|---|---|---|
BASE_URL | (required) | Site origin, such as https://www.example.com. |
CONFIRM | Must be yes for any host that is not local. | |
MODE | smoke | smoke, capacity, surge or soak. |
PEOPLE | 100 | Simultaneous visitors for surge. soak uses a sixth of it, and capacity derives PEAK_RATE from it. |
CACHE | warm | warm, cold or mixed. See below. |
COLD_RATIO | 0.2 | Share of page loads that bust the cache when CACHE=mixed. |
COLD_API | false | true also busts the cache on the /_api calls in cold and mixed loads. |
PAGES | the sitemap | Comma-separated paths to test instead of the sitemap. |
MAX_PAGES | 50 | Most pages taken from the sitemap. |
MAX_ASSETS | 12 | Most /_nuxt files fetched per page load. |
DURATION | 30s / 3m / 15m | Length of smoke, of the surge hold, or of soak. |
STAGE | 30s | Length of each capacity step. |
PEAK_RATE | PEOPLE / 7, at least 2 | The top arrival rate for capacity, in visitors per second. |
PAGE_P95_MS | 2000 | Threshold for the p95 time to first byte of pages. |
INSECURE | true skips TLS verification. Use it only for the local self-signed stack. | |
SUMMARY_JSON | Also 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:
warmsends 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.coldadds a uniquek6cb=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.mixedmakesCOLD_RATIOof page loads cold and the rest warm.
The navigation's /_api calls stay cacheable unless you set COLD_API=true.
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
/_nuxtfiles. Souin does not cache these.
- Souin cache counts each response's
Cache-Statusheader.hitwas served from the cache.misswas forwarded and stored.bypassmeans Souin gave up, for example a render slower than its 10-second backend timeout.no headermeans 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 returning200, 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.
/ is never cached, because it uses a draft component.Traps
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
- Page caching: what a cached page carries and how long it is kept.
- Warming the cache after a deploy: run a warm test after it, so you measure what visitors get.
- Auditing performance after a deploy: the load test tells you how much traffic the site can take. The audit tells you what a single visitor experiences.
CI/CD
The template ships full CI/CD for both GitLab CI and GitHub Actions — Docker Buildx builds, tests, per-branch review apps, and staged Kubernetes deployments via Helm.
Faster Behat Tests
When a project's Behat suite slows CI down — measure where the time goes, reset the database without rebuilding the schema, and shard the job by scenario.