Users' guide
Response cache
ScalWS has a built-in HTTP response cache that sits in front of the application runtimes (PHP, Node.js, Python, containers, proxies). It is designed to be safe by default: it only stores responses that the application explicitly marks as cacheable, or that you opt in with a default lifetime, and it never stores responses that set cookies or are marked private.
Design records: ADR-0012 (memory cache) and ADR-0024 (disk tier).
Turning it on
The cache has two levels of configuration:
server.cache: the server-wide memory budget and the optional disk tier. It always exists (with defaults), but caches nothing on its own.cache:on an application, opts that application in and sets its rules.
server:
cache:
memory: 256MiB
tenants:
- name: blog
applications:
- name: blog
domains: [blog.example.com]
root: /srv/www/blog
profile: wordpress
cache:
default_ttl: 5m
stale_while_revalidate: 1mAn application without a cache: block (or with enabled: false) never uses the cache.
A profile on its own does not enable caching either; it only adds bypass
rules once you do.
Two kinds of routes skip the cache even in an application with cache::
staticroutes, unlessdefault_ttlis set. Static files are already served from the page cache and the open file cache; withdefault_ttlthey are stored like any other200response without caching headers.respondroutes (fixed answers), which are cheaper than a cache lookup.
server.cache
Every key of server.cache, with its syntax, default and reload behaviour:
reference.
memory counts body plus headers plus
a small per-entry overhead.
max_entry_size must be
greater than zero and not larger than memory.
The memory cache evicts with TinyLFU admission weighted by size, so rarely requested large responses do not push out popular ones.
Disk tier
server:
cache:
memory: 256MiB
disk:
path: /var/lib/scalws/cache
size: 20GiB
max_entry_size: 256MiB # optionalEvery key of server.cache.disk:
reference. path and size are required; path must be
absolute; when the tier reaches size,
the least recently used files are removed first; max_entry_size may be at most size.
The disk tier receives entries that the memory cache evicts for lack of space and
responses too large for memory (up to disk.max_entry_size). On a memory miss it is
consulted; a disk hit that fits is copied back into memory. The disk tier survives
restarts: at start the directory is indexed and expired or partial files are deleted.
Writes are asynchronous and never slow down a request: when the write queue is full, the write is skipped. A response written a moment ago may therefore still be a miss.
If the directory cannot be used, the server does not start. With the packaged systemd
unit only /var/lib/scalws is writable, so put the cache below it. Cache files contain
response headers and bodies: keep the directory private to the scalws user.
Changing these settings
server.cache (memory, entry size, disk) cannot change on a reload; the reload is refused
with “requires a restart”. The per-application cache: settings below change on reload.
Per-application cache:
Every key of an application’s cache:, with its syntax, default and reload behaviour:
reference. In short:
enabled: falseswitches the cache off for this application while keeping the block.default_ttlapplies to200responses that carry no caching headers at all (noCache-Control, noExpires); it must be greater than zero.stale_while_revalidatekeeps serving an expired entry while one background request refreshes it; a response’s ownstale-while-revalidatedirective takes precedence.- The bypass lists are added to the profile’s.
Durations are written like 30s, 5m, 1h, 1d.
Matching rules for the bypass lists:
bypass_paths: plain prefixes (no*), compared case-insensitively against the start of the normalised path./wp-adminalso matches/wp-admin/edit.php, and/wp-administrator, since the match is not segment-aligned. Entries must start with/and must not contain?or#. The rule is checked against both the URL the client asked for and, after an.htaccessrewrite, the rewritten one.bypass_cookies: a request is bypassed when anyCookiename starts with one of the prefixes (case-sensitive), e.g.wordpress_logged_in_matcheswordpress_logged_in_3f2a….bypass_query: a request is bypassed when its query string contains a parameter with exactly this name, e.g.add-to-cartmatches?add-to-cart=42.
What is cached
Requests that never use the cache
These are passed straight to the application and get X-Cache: BYPASS:
- methods other than
GETandHEAD; - requests with an
Authorizationheader; - requests on a route with
basic_author anallow/denylist (its responses are not stored either, so another route on the same URL never serves them); - requests with a
Rangeheader; - requests with
Cache-Control: no-cacheorno-store, orPragma: no-cache; - requests matching the application’s bypass rules (profile plus configuration).
A request carrying cookies that match no bypass prefix can be answered from the cache.
If your application personalises responses based on a cookie, list that cookie in
bypass_cookies, send Cache-Control: private, or send Vary: Cookie.
Responses that are stored
A response is stored only when all of these hold:
- the status is one of
200, 203, 204, 300, 301, 308, 404, 405, 410, 414, 501; - it has no
Set-Cookieheader; - its
Cache-Controldoes not containprivate,no-storeorno-cache; - it does not have
Vary: *; - it has a lifetime:
s-maxage, otherwisemax-age, otherwiseExpires(relative toDate), or, for a200without anyCache-ControlorExpiresheader,default_ttl. A lifetime of zero is not stored. There is no heuristic freshness; - the body was received completely and fits the size limits.
public is not required. If a response cannot be stored, that URL skips the cache for the
next 10 seconds (“hit-for-pass”), so uncacheable pages do not pay for lookups.
Keys and variants
- The cache key is the request host plus the path and query exactly as the client sent
them.
?a=1&b=2and?b=2&a=1are different entries. http://andhttps://responses are kept apart, and so are responses of different applications (a host moved to another application never gets the previous owner’s entries).Varyis honoured: the request header values named inVaryselect the variant. At most 8 variants are kept per URL; the oldest is dropped first.
Freshness, stale content and refreshes
- A cached response is served with
AgeandX-Cache: HITwhile it is fresh. - After it expires it may be served as
X-Cache: STALEfor the stale-while-revalidate window (from the response’sstale-while-revalidate=Ndirective, otherwise the application’sstale_while_revalidate). One background request per URL refreshes it.must-revalidateorproxy-revalidatein the response disables stale serving. - Concurrent misses for the same URL are coalesced: one request goes to the application, the others wait for it (up to 5 seconds) and are then answered from the cache.
HEADrequests are answered from a storedGETresponse; aHEADmiss is not stored.- A successful (
2xxor3xx)POST,PUT,PATCHorDELETEremoves the cached entries of exactly that URL.
Response headers
Responses that went through the cache carry X-Cache:
| Value | Meaning |
|---|---|
HIT | Served from the cache (with Age). |
STALE | Served from the cache after expiry, while a refresh runs. |
MISS | Fetched from the application (and stored if storable). |
BYPASS | The request did not use the cache (see above). |
Requests that do not go through the cache stage at all (applications without cache:,
and the routes listed under Turning it on) get no X-Cache header.
A quick check:
curl -sI https://blog.example.com/ | grep -iE '^(x-cache|age|cache-control)'Purging
Purges remove entries from memory and from the disk tier.
scalwsctl purge --host blog.example.com --path /2026/10/hello-world/ # one URL
scalwsctl purge --host blog.example.com --prefix /2026/ # everything below
scalwsctl purge --tag product-42 # by Cache-Tag
scalwsctl purge --all # everything| Options | Removes |
|---|---|
--host H --path P | The entry for exactly that path and query (all variants). |
--host H --prefix P | Every entry of host H whose path and query start with P. |
--tag T | Every entry whose response carried tag T in a Cache-Tag header (comma- or space-separated list). |
--all | The whole cache. |
Exactly one of these forms is accepted. --path and --prefix are matched against the
URL as the client sent it (after an internal .htaccess rewrite, the client’s URL is still
the key).
Tag purges let an application invalidate related pages without knowing their URLs: send
Cache-Tag: product-42, category-7 with the response, then purge by tag when the product
changes.
The same operation is available on the admin socket as POST /v1/cache/purge with the
query parameters host + path, host + prefix, tag, or all=1 (see
cli.md and OPERATIONS.md).
WordPress and other profiles
Profiles add bypass rules for the admin area and logged-in users. With profile: wordpress
the application bypasses the cache for:
- paths
/wp-admin,/wp-login.php,/wp-json,/xmlrpc.php,/wp-cron.php; - cookies starting with
wordpress_logged_in_,wp-postpass_,comment_author_,wordpress_sec_.
profile: woocommerce adds /cart, /checkout, /my-account, /wc-api, the query
parameters wc-ajax and add-to-cart, and the cookies woocommerce_items_in_cart,
woocommerce_cart_hash, wp_woocommerce_session_. Other profiles (Django, Laravel,
Flask, FastAPI, Next.js) bring their own session cookies and admin paths.
Your own bypass_* entries are added to the profile’s; they never replace them. If your
site uses a custom login URL or a membership plugin with its own cookie, add it:
profile: wordpress
cache:
default_ttl: 10m
stale_while_revalidate: 1m
bypass_paths: [/members]
bypass_cookies: [pmpro_]WordPress pages usually carry no caching headers, so nothing is stored until you set
default_ttl. Pages that start a PHP session or set cookies are never stored. After
publishing content, purge the affected URLs (or --prefix /): the cache does not know
when WordPress changes a page.
Several instances
Under scalws-controller every scalwsd instance has its own cache:
-
Memory caches are per instance; each fills on its own, so the hit ratio is lower right after a scale-out or a restart.
-
With a disk tier, each instance uses its own subdirectory
<path>/<instance-id>. Sharing one directory between instances is not supported. -
scalwsctl purgetalks to one admin socket. To purge every instance, use the controller, which forwards the purge to each serving instance and removes the disk caches of instances that are not running:scalws-controller purge 'host=blog.example.com&prefix=/2026/' scalws-controller purge 'tag=product-42' scalws-controller purge 'all=1'The ScalWS Console’s cache purge goes through the controller as well.
Metrics
| Metric | Meaning |
|---|---|
scalws_cache_lookups_total{tenant,app,result} | hit, stale, miss, bypass per application. |
scalws_cache_entries, scalws_cache_bytes | Memory cache size. |
scalws_cache_disk_entries, scalws_cache_disk_bytes | Disk tier size. |
See also
- applications.md: profiles and runtimes.
- configuration.md: sizes, durations, reload rules.
- troubleshooting.md: when pages are not cached or stay stale.
- THREAT_MODEL.md: cache poisoning and personalised content.