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: 1m

An 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::

  • static routes, unless default_ttl is set. Static files are already served from the page cache and the open file cache; with default_ttl they are stored like any other 200 response without caching headers.
  • respond routes (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     # optional

Every 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: false switches the cache off for this application while keeping the block.
  • default_ttl applies to 200 responses that carry no caching headers at all (no Cache-Control, no Expires); it must be greater than zero.
  • stale_while_revalidate keeps serving an expired entry while one background request refreshes it; a response’s own stale-while-revalidate directive 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-admin also 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 .htaccess rewrite, the rewritten one.
  • bypass_cookies: a request is bypassed when any Cookie name starts with one of the prefixes (case-sensitive), e.g. wordpress_logged_in_ matches wordpress_logged_in_3f2a….
  • bypass_query: a request is bypassed when its query string contains a parameter with exactly this name, e.g. add-to-cart matches ?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 GET and HEAD;
  • requests with an Authorization header;
  • requests on a route with basic_auth or an allow/deny list (its responses are not stored either, so another route on the same URL never serves them);
  • requests with a Range header;
  • requests with Cache-Control: no-cache or no-store, or Pragma: 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-Cookie header;
  • its Cache-Control does not contain private, no-store or no-cache;
  • it does not have Vary: *;
  • it has a lifetime: s-maxage, otherwise max-age, otherwise Expires (relative to Date), or, for a 200 without any Cache-Control or Expires header, 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=2 and ?b=2&a=1 are different entries.
  • http:// and https:// responses are kept apart, and so are responses of different applications (a host moved to another application never gets the previous owner’s entries).
  • Vary is honoured: the request header values named in Vary select 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 Age and X-Cache: HIT while it is fresh.
  • After it expires it may be served as X-Cache: STALE for the stale-while-revalidate window (from the response’s stale-while-revalidate=N directive, otherwise the application’s stale_while_revalidate). One background request per URL refreshes it. must-revalidate or proxy-revalidate in 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.
  • HEAD requests are answered from a stored GET response; a HEAD miss is not stored.
  • A successful (2xx or 3xx) POST, PUT, PATCH or DELETE removes the cached entries of exactly that URL.

Response headers

Responses that went through the cache carry X-Cache:

ValueMeaning
HITServed from the cache (with Age).
STALEServed from the cache after expiry, while a refresh runs.
MISSFetched from the application (and stored if storable).
BYPASSThe 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
OptionsRemoves
--host H --path PThe entry for exactly that path and query (all variants).
--host H --prefix PEvery entry of host H whose path and query start with P.
--tag TEvery entry whose response carried tag T in a Cache-Tag header (comma- or space-separated list).
--allThe 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 purge talks 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

MetricMeaning
scalws_cache_lookups_total{tenant,app,result}hit, stale, miss, bypass per application.
scalws_cache_entries, scalws_cache_bytesMemory cache size.
scalws_cache_disk_entries, scalws_cache_disk_bytesDisk tier size.

See also