Users' guide

Configuration: `scalws.yaml`

ScalWS reads one YAML file, by default /etc/scalws/scalws.yaml. This page is an overview of that file: its layout, what each part of the server: section is for, the caveats worth knowing, and an example per section. Every key, with its syntax, default, context and reload behaviour, is listed in the configuration reference. Tenants, applications and runtimes have their own guide: applications.md.

Validate a file before you apply it:

scalwsctl check                          # validate + prepare exactly like a reload
scalwsctl check /path/to/scalws.yaml

Every section rejects unknown keys, so a typo such as idle_timout is an error, not a silently ignored setting. Semantic problems are collected and reported together, each with its location (for example server.limits.max_headers: must be between 1 and 1024), so you can fix a file in one pass. Some checks produce warnings instead of errors; they are logged at start-up.

The file may be at most 16 MiB.

Contents

File layout

The file has four top-level keys:

  • include: site files read in addition to this file (ADR-0048, sites.md).
  • server (required): server-wide settings: listeners, limits, observability, caches, security and so on.
  • upstreams (default empty): named pools of upstream members for proxy runtimes (load-balancing.md). Only the main file may define them.
  • tenants (default []): tenants and their applications. See applications.md.

Every top-level key: reference.

include: sites.d/*.yaml

server:
  listeners:
    - name: http
      address: "0.0.0.0:80"

tenants:
  - name: acme
    applications: [...]

include

include names site files: one short file per site (or a file with its own tenants: list). The loader reads them, appends their tenants and applications to tenants, and then validates the whole result as one configuration. scalwsctl site add|list|remove manages these files; see sites.md for their format.

  • A single string (include: sites.d/*.yaml) or a list (include: [sites.d/*.yaml, extra.yaml]).
  • Relative paths are resolved against the directory of the main file.
  • Each entry is either one file, or a directory plus a file pattern with exactly one * in the file name (sites.d/*.yaml). A * in the directory part, more than one *, or naming a directory itself (sites.d) is an error.
  • Hidden files (names starting with .) are skipped; matches are read in sorted order, and a file matched by several entries is read once.
  • At most 10,000 site files; each at most 16 MiB.
  • On Unix, each site file and its directory must belong to root (or to the owner of the main file) and must not be writable by group or others (chmod go-w): whoever can write a site file can serve any domain from any directory the server can read.
  • Only the main file may use include; site files cannot include further files.

The packaged default configuration already contains include: sites.d/*.yaml.

Value formats

Several keys take values with units; the full rules are in value formats. In short:

  • Sizes: an integer (bytes) or a string with a unit: binary KiB, MiB, GiB, TiB (powers of 1024) or decimal KB (or kB), MB, GB, TB (powers of 1000). Examples: 16KiB, 10MiB, 10MB, 1048576. Units are case-sensitive and the number must be whole.
  • Durations: a string, a whole number followed by one unit: ms, s, m, h or d (500ms, 30s, 5m, 12h, 30d). Bare numbers and combinations such as 1m30s are rejected; write 90s.
  • CPU quotas (tenant resources): a percentage of one CPU as a quoted string; "100%" is one CPU, "400%" four.
  • Addresses: IP:port with an IP literal (no host names): "0.0.0.0:443", "127.0.0.1:9901", "[::]:443". Quote them in YAML. In server.listeners, a listener on the IPv6 wildcard [::]:p takes IPv6 only (IPV6_V6ONLY) when another listener there uses an IPv4 address (0.0.0.0 or a specific one) on the same port p, which then takes IPv4. Without such a listener [::]:p keeps the system’s default: dual-stack on Linux (net.ipv6.bindv6only=0), serving IPv4 too. HTTP/3’s UDP sockets follow the same rule. Under scalws-controller a reload that changes the pairing of [::]:p gets a new TCP socket of the right mode (instances of the old generation keep the old one until they drain); for a UDP socket in use (http3: true on [::]:p) the reload is refused with the reason, because a UDP address cannot be bound twice: stop the controller, its instances and the QUIC demux to change it.
  • Names: listener, tenant and application names are 1 to 63 characters of a-z, 0-9, - and _, starting with a lower-case letter. They appear in metrics and logs.

server

Only listeners is required; every other section is optional and has production defaults. The sections below follow the order of the configuration model. All keys directly under server: reference.

listeners

Required, a list with at least one entry. Each listener accepts HTTP/1.1 and HTTP/2 (and, optionally, HTTP/3) on one address. A listener without tls serves plain HTTP. strict_sni prevents domain fronting across tenants (ADR-0013); http3 adds HTTP/3 over UDP on the same port, off by default (tls.md, ADR-0019); proxy_protocol reads the client address from a load balancer (ADR-0022).

Constraints:

  • Listener names and addresses must be unique. Port 0 (an ephemeral port chosen by the OS) is exempt from the duplicate check.
  • A listener may not use the same address as the metrics endpoint or the remote admin endpoint.
  • strict_sni: true is only allowed on TLS listeners.
  • http3: true needs tls and is not allowed with listener_mode: reuseport. The packages include HTTP/3; a scalwsd built from source without the http3 feature refuses the setting at start.
  • proxy_protocol: true needs trusted_proxies: connections from peers outside that list are closed. The header is read within limits.header_read_timeout; a missing or malformed header closes the connection.

tls

A TLS listener needs at least one entry in certificates, or acme: true to also serve the ACME-managed certificates of applications with acme: true (see acme).

Within one listener, a server name may be assigned to only one certificate, and at most one certificate may be the default. The default entry may have server_names: []; it is then served only to clients that send no SNI name or one no other entry lists. Every other entry needs at least one name. Names are compared case-insensitively, without a trailing dot; a wildcard covers exactly one label and must have a multi-label suffix (*.example.com, not *.com). Use punycode for internationalised names. Certificate files are read when the configuration is prepared (start, reload, scalwsctl check), not during validation.

More on certificates: tls.md. Every key of server.listeners, including tls and its certificates, with its syntax, default and reload behaviour: reference.

server:
  listeners:
    - name: http
      address: "0.0.0.0:80"
    - name: https
      address: "0.0.0.0:443"
      strict_sni: true
      tls:
        acme: true
        certificates:
          - cert: /etc/scalws/tls/example.com.pem   # leaf first, then intermediates
            key: /etc/scalws/tls/example.com.key
            server_names: [example.com, "*.example.com"]
            default: true
          - cert: /etc/scalws/tls/shop.example.org.pem
            key: /etc/scalws/tls/shop.example.org.key
            server_names: [shop.example.org]

limits

Connection and request limits. Every key is optional; the defaults are conservative production values. The ones most often changed are max_request_body_bytes (uploads) and the timeouts.

Caveats and constraints not shown in the reference:

  • max_connections counts HTTP/3 connections too and must be at least 1.
  • max_header_bytes must be between 8KiB and 1MiB; max_headers between 1 and 1024; max_uri_bytes at least 256 bytes and at most max_header_bytes.
  • idle_timeout is also the QUIC idle timeout for HTTP/3, and h2_max_concurrent_streams (1 to 10000) also limits bidirectional streams per HTTP/3 connection.
  • tls_handshake_timeout, header_read_timeout, body_read_timeout, idle_timeout, upgraded_idle_timeout and write_timeout must be above zero.
  • shutdown_timeout is how long connections may drain on shutdown before they are closed.
  • shutdown_delay (default 0s) keeps serving for that long after SIGTERM while /readyz answers 503, so a load balancer or Kubernetes stops sending requests first; the drain (shutdown_timeout) starts after it. A second SIGTERM ends the delay. Both are read when the signal arrives, so a reload changes them.

Every key of server.limits, with its syntax, default and reload behaviour: reference.

server:
  limits:
    max_request_body_bytes: 64MiB    # e.g. for uploads
    idle_timeout: 30s

Per-client limits are in security.per_ip; per-tenant quotas are tenant resources (applications.md).

metrics

A loopback HTTP endpoint for monitoring (ADR-0005). It serves /metrics (Prometheus text format) and /healthz, plus read-only views used by scalwsctl and a read-only dashboard at /dashboard (see ../OPERATIONS.md). hosts adds per-domain counters (ADR-0042).

The endpoint has no authentication. Its address must differ from every listener address; a non-loopback address is accepted but produces a warning; keep it on loopback or behind a firewall.

The packaged default configuration sets address: "127.0.0.1:9900"; the built-in default, when the key is omitted, is 127.0.0.1:9901.

Every key of server.metrics: reference.

server:
  metrics:
    address: "127.0.0.1:9900"
    max_hosts: 5000

logging

Log format, level and access logging. The SCALWS_LOG environment variable, if set, takes precedence over level. format and level are applied when scalwsd starts.

Every key of server.logging: reference.

server:
  logging:
    format: text
    level: "info,scalws_proxy=debug"
    access_log: false

runtime_dir

runtime_dir (default /run/scalws) is the directory for managed runtimes’ sockets and generated configuration (ADR-0008); also the default location of the admin socket. It must be writable by scalws.

Constraints: an absolute path, at most 64 bytes (Unix socket paths below it must fit the kernel limit), using only A-Z a-z 0-9 / _ . -.

cgroups

cgroup v2 placement of managed applications, which enforces tenant resources such as cpu, memory, pids and I/O limits (ADR-0010). mode auto (the default) enforces limits when cgroup delegation works and otherwise warns and runs without enforcement; required refuses to start without working delegation; off uses no cgroups.

Constraint: path must start with /sys/fs/cgroup/.

With mode: off, tenant process limits are not enforced (a warning lists the tenants that set them), and ebpf.network cannot be used.

Every key of server.cgroups: reference.

server:
  cgroups: {mode: required}

cache

Server-wide budget of the response cache (ADR-0012). Nothing is cached unless an application enables cache: (see caching.md). max_entry_size must be above zero and at most memory.

cache.disk

The disk tier (ADR-0024) receives entries evicted from memory and entries larger than max_entry_size, and keeps them across restarts. When it is configured but unusable, the server does not start. The directory holds response headers and bodies: keep it private to the service user.

path and size are required; path must be absolute, size above zero, and the disk tier’s own max_entry_size above zero and at most size.

Every key of server.cache, including disk: reference.

server:
  cache:
    memory: 256MiB
    max_entry_size: 8MiB
    disk: {path: /var/lib/scalws/cache, size: 10GiB}

security

Server-wide security settings (ADR-0013). Path rules and per-route rate limits belong to applications; see security.md. Without per_ip there are no per-client limits.

security.per_ip

Clients are identified by IPv4 address or by IPv6 /64 network. Behind a load balancer, set trusted_proxies so the real client address is used.

  • Excess connections over max_connections are closed at accept; requests over requests_per_second (a token bucket) get 429.
  • max_connections, requests_per_second and burst must be above zero; burst needs requests_per_second; table_size must be at least 64.

When the table is full, the least recently seen idle entry is evicted; if every entry is in use, a new client is admitted untracked (and counted) while the global limits.max_connections still applies. Connections from trusted proxies are exempt from the per-client connection limit; their requests are limited per client.

Every key of server.security: reference.

server:
  security:
    per_ip: {max_connections: 256, requests_per_second: 50, burst: 200}

optimizer

Deterministic optimizer (ADR-0014): evaluates every application each interval and records decisions (for example worker-count recommendations). View them with scalwsctl recommendations. Diagnostics are evaluated on the same interval.

Constraints: interval between 1s and 1h; journal_size between 1 and 100000.

Whether the optimizer may act (rather than only recommend) is set per worker pool with workers.autoscale; see applications.md.

Every key of server.optimizer: reference.

diagnostics

Diagnostics (ADR-0015); findings are evaluated every optimizer.interval. View them with scalwsctl diagnose [tenant/app]. slow_request must be above zero and recent_requests at most 10000.

Every key of server.diagnostics: reference.

server:
  diagnostics: {slow_request: 2s, recent_requests: 50}

ai

Local LLM advisor (ADR-0016). Disabled by default and advisory only: it explains diagnoses (scalwsctl explain tenant/app); it never changes configuration, never runs commands, and the server and optimizer work the same when it is down. Input is limited to aggregated, redacted telemetry.

The endpoint is any OpenAI-compatible chat-completions URL (llama.cpp server, Ollama, vLLM, …). When enabled is true, these checks apply (when it is false, the other keys are not checked):

  • endpoint is an http:// or https:// URL with a host and no user info, on loopback unless allow_remote is set (telemetry then leaves the host);
  • model is required;
  • api_key_env names an environment variable (letters, digits, _); the key itself never goes in the file;
  • timeout is between 1s and 10m, max_queue between 1 and 64, max_tokens between 64 and 8192.

Every key of server.ai: reference.

server:
  ai:
    enabled: true
    endpoint: "http://127.0.0.1:11434/v1/chat/completions"
    model: "qwen2.5:7b-instruct"

admin

Admin API for scalwsctl on a Unix socket (ADR-0017), and optional remote administration over mutual TLS (ADR-0023). Usage: cli.md, ../OPERATIONS.md.

  • socket must be an absolute path of at most 100 bytes.
  • remote is off unless set; when set, all four keys (address, cert, key, client_ca) are required, the paths must be absolute, and address must not be a listener or metrics address. Only clients with a certificate issued by client_ca can connect.

Every key of server.admin: reference.

server:
  admin:
    socket: /run/scalws/admin.sock
    remote:
      address: "0.0.0.0:9443"
      cert: /etc/scalws/admin.pem
      key: /etc/scalws/admin.key
      client_ca: /etc/scalws/operators-ca.pem

acme

Automatic certificates (ADR-0018) for applications with acme: true, using HTTP-01 challenges. The default directory is Let’s Encrypt production. See also tls.md.

When enabled is true:

  • terms_of_service_agreed must be true;
  • directory must be an https:// URL;
  • each contact entry must be a mailto: address;
  • check_interval must be at least 1m;
  • at least one TLS listener must have tls: {acme: true};
  • at least one plain HTTP listener must exist (port 80 in production) for HTTP-01.

Applications with acme: true require enabled: true. Their domains must be public DNS names: wildcards (*.example.com, which would need DNS-01), IP addresses and localhost are rejected. Use a static certificate for those.

Every key of server.acme: reference.

Try a new setup against the Let’s Encrypt staging directory first:

server:
  listeners:
    - {name: http, address: "0.0.0.0:80"}                       # HTTP-01 challenges
    - {name: https, address: "0.0.0.0:443", tls: {acme: true}}
  acme:
    enabled: true
    directory: https://acme-staging-v02.api.letsencrypt.org/directory
    contact: ["mailto:[email protected]"]
    terms_of_service_agreed: true

scalwsctl certs shows each application’s names, days left and errors. Renewed certificates are activated without a restart.

trusted_proxies

trusted_proxies (default []) lists the peers allowed to report the client address via X-Forwarded-For or the PROXY protocol (ADR-0022), typically your load balancers. Entries are CIDRs (192.0.2.0/24, 2001:db8::/32) or single addresses (192.0.2.10, treated as /32 or /128).

  • For requests from a trusted peer, the client is the right-most X-Forwarded-For entry that is not itself a trusted proxy. From untrusted peers the header is ignored and replaced.
  • Listeners with proxy_protocol: true require this list and close connections from other peers.
  • The effective client address is used for per-client limits, access logs, PHP REMOTE_ADDR and diagnostics.
server:
  trusted_proxies: ["192.0.2.0/24", "192.0.2.10"]

sendfile

sendfile (default true) sends large static files on plain-TCP HTTP/1 connections with sendfile(2) (Linux, ADR-0027). For HTTPS see ktls.

ktls

ktls (default false, opt-in) enables kernel TLS for HTTPS/1.1 file bodies (Linux, ADR-0030), so large files can use sendfile over TLS too. Load the kernel module first (modprobe tls, and persist it in /etc/modules-load.d/tls.conf). scalwsd probes kernel support at start; if the module is missing it logs a warning and keeps TLS in user space. HTTP/2 connections stay on user-space TLS.

open_file_cache

Open file cache for static files (ADR-0028), like nginx’s open_file_cache / open_file_cache_valid. max_entries is per document root (0 disables the cache). With valid unset (or 0s), every hit is checked against the file, so changed files are served at once; a longer value trades that for fewer checks.

Every key of server.open_file_cache: reference.

server:
  open_file_cache: {max_entries: 20000, valid: 60s}   # changes visible within 60s

event_loops

event_loops (an integer from 0 to 65535, unset by default) sets the per-core event loops (ADR-0029):

  • unset: one event loop per CPU scalwsd may run on (honours taskset and cgroup CPU limits);
  • N: N event loops;
  • 0: one shared work-stealing runtime.

Per-core loops are Linux only; elsewhere scalwsd always uses the shared runtime. When the file leaves it unset, scalwsd --worker-threads N sets the count.

ebpf

Optional eBPF enrichment (ADR-0025). Opt-in. network exports per-application network bytes and packets (scalws_app_network_bytes, scalws_app_network_packets).

Requirements: a build with the ebpf feature (a build without it refuses the setting at start), cgroup enforcement (cgroups.mode must not be off), and CAP_BPF + CAP_NET_ADMIN (or root). The packaged systemd unit does not grant CAP_BPF. If the programs cannot be loaded, scalwsd logs a warning and runs without them. Traffic over Unix sockets (scalws to its applications, by default) is not counted.

Every key of server.ebpf: reference.

listener_mode

listener_mode (shared, reuseport or inherited; default shared) sets how listening sockets are opened (ADR-0033):

  • shared (default): one socket per address, shared by this process’s event loops.
  • reuseport: one SO_REUSEPORT socket per event loop, so several scalwsd instances can serve the same address. Linux only; needs per-core event loops (event_loops must not be 0); HTTP/3 listeners are not allowed in this mode.
  • inherited: the sockets are received from listen_socket (opened by scalws-controller; every instance accepts from the same queue). Linux only; needs listen_socket.

You normally do not set this by hand: scalws-controller starts its instances with the right mode (--reuseport or --listen-socket). See ../adaptive-runtime.md.

listen_socket

listen_socket is the Unix socket that hands out the listening sockets for listener_mode: inherited. Set by scalws-controller through --listen-socket; required when listener_mode is inherited.

loop_lag_probe

loop_lag_probe (default false) measures event-loop lag (a 100 ms timer per loop) and exports scalws_event_loop_lag_seconds, the Adaptive Runtime’s pressure signal (ADR-0034). Off by default; turned on for instances started by the controller.

accept_policy

accept_policy (shared or fountain; default shared) chooses which event loop takes a new connection from the shared accept queue (ADR-0044):

  • shared (default): whichever loop wakes first.
  • fountain (opt-in): loops fill in order; a loop takes connections only while the loops before it are busy or hold twice their fair share of the open connections (at least 8 each). Established connections never move. It saves CPU with many short connections; connections opened in one burst can still end up on a few loops, so keep shared where keep-alive latency matters (a proxy, an ingress controller). It has no effect with a socket per event loop (listener_mode: reuseport or SCALWS_REUSEPORT=1): the kernel then picks the loop, and scalwsd logs this once at startup.

upstream_client

upstream_client (hyper or experimental; default hyper) is the HTTP/1 client used for every proxy and managed application (Node.js, Python) (ADR-0045).

  • hyper (default).
  • experimental: scalws’s own client. Experimental, opt-in, not the default; it is meant for measurement until it has proven itself on real applications.

A proxy route’s client: overrides this per route (see applications.md). This server-wide value is read at start-up: a reload keeps the running value, while route-level client changes apply on reload.

http1_server

http1_server (hyper or experimental; default hyper) selects the HTTP/1 server implementation (ADR-0046).

  • hyper (default).
  • experimental: scalws’s own HTTP/1.1 server side, with stricter request framing. Experimental, opt-in, not the default. HTTP/2, HTTP/3 and TLS connections that negotiate another protocol keep their current implementation.

A change applies to connections accepted after a reload.

sandbox

sandbox (off, audit or on; default off) confines scalwsd with Landlock and seccomp. The first install of the package adds sandbox: on. When it applies, what it allows and how to use audit: sandbox.md.

upstreams

upstreams (default empty) is a map of named pools of upstream members. A proxy runtime sends to a pool with upstream: {pool: name} or splits requests across pools with backends (see proxy upstream and backends). Balancing, affinity, passive health, retries and run-time member changes are explained in load-balancing.md; the design is ADR-0049.

Pool names follow the name rule (1 to 63 characters of a-z, 0-9, - and _, starting with a lower-case letter). Pools live in the main file only; site files can use them.

FieldTypeDefaultDescription
memberslist[]Members of the pool, each {address, weight}. Optional with members_from: api, where it only seeds a new pool. A file pool without members loads with a warning; its requests get 503.
members[].addressstringrequiredip:port, [ipv6]:port, host:port (resolved when a connection is made) or unix:/absolute/path (only as the single member of a plain-HTTP pool). The port is required; no scheme, no path, no duplicates; at most 10,000 members.
members[].weightinteger1Relative share of requests, 1 to 1000.
members_fromfile or apifileWhere the member list comes from: this file, or the admin API (PUT /v1/upstreams/{name}, admin API).
balanceround_robin, least_conn or hashround_robinSmooth weighted round robin; fewest requests in flight per weight; or weighted rendezvous hashing of hash.by.
hash.bycookie, header or client_iprequired with balance: hashKey of the hash: an affinity cookie, a request header, or the client address after trusted_proxies.
hash.namestringSCALWS_AFFINITY for cookiesCookie or header name (token characters, at most 64); required with by: header, not allowed with by: client_ip.
hash.cookie_max_ageduration0sMax-Age of the affinity cookie; 0s makes a session cookie. Only with by: cookie.
passive_health.failuresinteger3Consecutive failures before a member is ejected, 0 to 100; 0 turns passive health off.
passive_health.cooldownduration10sHow long an ejected member stays out, 1ms to 1h.
retriesinteger1Further attempts on other members, 0 to 5 (only for pools with more than one member; see retries).
keepaliveinteger32Idle connections kept per member, at most 1024.
schemehttp or httpshttpProtocol towards the members. With https, certificates are always verified.
tls.ca_filepathnoneAbsolute path of a PEM bundle trusted in addition to the system roots. Only with scheme: https.
tls.server_namestringthe member’s hostSNI and certificate name (a DNS name without wildcard, or an IP address). Only with scheme: https.
upstreams:
  api:
    members:
      - {address: "192.0.2.5:8080", weight: 2}
      - {address: "192.0.2.6:8080"}
    balance: least_conn
    passive_health: {failures: 5, cooldown: 30s}
  sessions:
    members_from: api                # members are set with scalwsctl upstreams set
    balance: hash
    hash: {by: cookie, cookie_max_age: 1d}

Every change under upstreams applies on reload. A pool whose settings (everything except members) are unchanged is kept with its connections and health; a pool whose settings changed is rebuilt. Members of an api pool are kept across reloads, but not across a restart.

Tenants and applications

tenants is a list of tenants; each tenant has a name, an optional Unix user, optional resources (cgroup limits and request quotas) and a list of applications. An application has a name, its domains, an optional root and profile, a runtime (static, proxy, respond, redirect, php, node, python, container), optional path routes, and per-application cache, security, htaccess, https_redirect and acme settings.

In domains, *.example.com matches one label and **.example.com one or more (never example.com itself; not with acme: true); "*" (at most one application per listener) serves the hosts no other domain matches. The lookup order is exact, *., **. (longest suffix first), "*". listeners names the listeners that serve the application (default: every listener). A domain may be served once per listener; applications with different listeners may share a domain. The Kubernetes ingress controller generates configuration with the catch-all domain and route https_redirect; its Gateway API input generates listener-scoped applications and **. domains.

tenants:
  - name: acme
    resources: {cpu: "400%", memory: 4GiB, pids: 256}
    applications:
      - name: www
        root: /srv/acme/www
        domains: [example.com, www.example.com]
        acme: true
        runtime: {type: static}

The guide is applications.md; the keys are in the reference pages for tenants, applications, routes and each runtime (for example php). Site files (include, sites.md) are a shorter way to write the same tenants and applications.

Proxy upstream and backends

A proxy runtime sends to one address, to one pool, or to several pools by weight (load-balancing.md):

FieldTypeDefaultDescription
upstreamstring or {pool: name}nonehttp://host[:port], https://host[:port] or unix:/absolute/path: one address, no retries, no ejection (as before). {pool: name}: a pool from upstreams.
backendslist[]{pool, weight} or {status, weight} entries: a weighted split per request across pools, at most 64. status (500 to 599) answers that share with the status and a short text body instead of using a pool; it does not count as a pool or as an upstream failure. Weights 0 to 1000 (default 1); 0 sends no traffic; at least one must be above 0.

upstream or backends is required, and they are mutually exclusive. An unknown pool name is an error. preserve_host, connect_timeout, response_timeout and websocket apply to pools as well (the timeouts per attempt). tls_ca_file is only for a single https:// address (pools have tls.ca_file), and client is ignored with a warning on pool routes, which use server.upstream_client.

tenants:
  - name: acme
    applications:
      - name: api
        domains: [api.example.com]
        runtime:
          type: proxy
          backends:
            - {pool: api-v1, weight: 90}
            - {pool: api-v2, weight: 10}

Route matching

A route chooses its requests by path and, optionally, by method, headers and query (ADR-0050):

FieldTypeDefaultDescription
pathstringrequiredThe prefix, the exact path or the regular expression, by path_type.
path_typeprefix, exact or regexprefixprefix: whole segments (/api matches /api and /api/x, not /apikeys). exact: the decoded, normalised path exactly. regex: a regular expression anchored at the start of the path and not at the end.
methodslist of strings[] (any)Request methods, upper case (GET, POST, …). HEAD must be listed on its own. At most 16.
headerslist[]{name, value} or {name, regex}: the header must be present with that value (exact, case-sensitive) or a value the regex finds. Names are case-insensitive; a header sent several times matches if any value does. At most 16.
querylist[]{name, value} or {name, regex} on the decoded value of the first occurrence of the query parameter (+ is not turned into a space). At most 16.

All matchers of a route must match. Header and query regexes search anywhere in the value unless they anchor themselves (^...$).

Precedence within an application:

  1. exact routes;
  2. prefix routes, longest path first;
  3. regex routes, in the order they are written.

Among routes with the same path type and path, the one with a method matcher is tried first, then the one with more header matchers, then the one with more query matchers, then the earlier one. Order changes where same-path routes differ in the kind of matcher: a method list counts once however many methods it names, and header matchers outrank any number of query matchers. Configurations whose same-path routes use only header matchers, or only query matchers, keep their order; the routes the ingress controller generates keep theirs. A route whose matchers do not match is skipped and the next candidate is tried; when no route matches, the application’s runtime answers, or 404 when the application has only routes (runtime may then be omitted). Two routes with the same path type, path and matchers are an error. A prefix route / without matchers matches every request, so regex routes after it are never used (a warning at load).

Paths are matched after normalisation and after .htaccess rewrites. When .htaccess rewrote the request, the route the client’s own (normalised) path selects keeps its access policies: the allow/deny of both routes must pass, and the client route’s basic_auth is checked (the selected route’s only when the client route has none, since one Authorization header cannot satisfy two users files). So a front controller (RewriteRule ^ index.php) does not take /admin/users out of an /admin route with basic_auth. prefix paths follow the rules in applications.md (an encoded slash, %2F in any case, ends a segment there: /admin%2Fx selects /admin); exact paths start with /, are normalised and contain no ?, #, %, \, * or spaces.

Regular expressions (path_type: regex and regex matchers) use a linear-time engine without backtracking (the regex crate’s syntax without look-around or back-references). They are case-sensitive unless the pattern starts with (?i). Limits, checked at load:

  • pattern text at most 4096 bytes, nesting at most 64 levels;
  • at most 256 KiB compiled per pattern;
  • compiled size plus a 32 KiB search cache per pattern, summed over every regex of the configuration, at most 8 MiB.

A regex route’s captures are available to its rewrite as $1 to $9 ($0 is the whole match).

routes:
  - path: /healthz
    path_type: exact
    runtime: {type: respond, body: "ok\n"}
  - path: /api
    methods: [GET, HEAD]
    headers:
      - {name: x-canary, value: "always"}
    runtime: {type: proxy, upstream: {pool: api-canary}}
  - path: /api
    runtime: {type: proxy, upstream: {pool: api}}
  - path: "/users/([0-9]+)/avatar"
    path_type: regex
    rewrite: {path: "/avatars/$1.png"}
    runtime: {type: static, root: /srv/media}

Route policies

Every route can carry these fields; all are optional. An application gets them for all its requests through a route path: /.

FieldTypeDefaultDescription
rewrite.pathstringnoneNew path: $1 to $9 from a regex route, $0 the match, $$ a literal $. The part after a ? replaces the query; without ? the query is kept. Must start with /; literals are written percent-encoded.
rewrite.prefixstringnoneReplaces the matched prefix (prefix routes only). Exclusive with rewrite.path.
rewrite.hoststringnoneHost sent upstream, host or host:port (implies preserve_host: false). Proxy runtimes only; a warning for others.
request_headers{set, add, remove}noneChanges to the request headers: set replaces, add appends, remove deletes. Proxies apply them after their X-Forwarded-* headers; every other runtime sees them as the request arrives (PHP as HTTP_* parameters).
response_headers{set, add, remove}noneChanges to every response of the route, including answers generated by ScalWS (401, 403, 413, 504, …).
max_bodysizelimits.max_request_body_bytesLargest request body for the route, above or below the global limit; 413 above.
request_timeoutdurationnoneTime from the route match until the response head; 504 when it passes. The body is not bounded.
https_redirectboolthe application’sAnswer plain-HTTP requests that select this route with a 301 to HTTPS, or (false) serve them over HTTP although the application redirects. The route chosen by the client’s own path decides, before .htaccess.
corsmapnoneCross-origin resource sharing, see below.
basic_auth.realmstringRestrictedRealm in WWW-Authenticate (1 to 128 printable ASCII characters, no " or \).
basic_auth.users_filepathrequiredAbsolute path of an htpasswd file.
allowlist[]Addresses and CIDR blocks (IPv4, IPv6); when set, other clients get 403. At most 1024.
denylist[]Addresses and CIDR blocks refused with 403. At most 1024.

Order for a matched route: allow/deny, CORS preflight, basic authentication, max_body, tenant quotas, rewrite, request headers, runtime, response headers. The application’s security (deny paths, rate limits) and .htaccess run before the route, on the path as received; a rewritten path is checked against the deny paths again (404). The full sequence is in request-processing.md.

  • Rewrite. The request is never decoded twice: text copied from the request (the rest after the prefix, each capture) is percent-encoded once, configuration literals are taken as written, and the result is normalised once. /app/%252e%252e/x rewritten with prefix: /v2 becomes /v2/%252e%252e/x. A capture that would form a . or .. segment, or a result above /, answers 400. Validation refuses $n above the regex’s capture groups, $ on routes that are not regex routes, and more than one ?. The rewritten path is what the runtime sees (the upstream path, the static file looked up); PHP keeps the client’s URI in REQUEST_URI. In an application with htaccess: true, .htaccess files are read for the client’s path before the route’s rewrite: the access rules of the rewritten path’s directories are not applied (a warning at load for rewrite.path and rewrite.prefix on static and php routes).
  • Header changes. At most 64 names per block, values at most 8 KiB of printable ASCII. Hop-by-hop and framing headers (connection, keep-alive, proxy-connection, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade, content-length) cannot be changed, and host is changed with rewrite.host.
  • CORS. allow_origins (required): origins such as https://app.example.com, https://*.example.com (one or more labels before example.com; scheme and port must match; no trailing dot on the host), or ["*"] alone. allow_methods (default GET, PUT, POST, DELETE, PATCH, OPTIONS), allow_headers (default DNT, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Range, Authorization), expose_headers (default none), allow_credentials (default false; refused together with *) and max_age (default 20d, at most 365 days). A preflight (OPTIONS with Origin and Access-Control-Request-Method) is answered with 204 without reaching the runtime. Other requests from an allowed origin get Access-Control-Allow-Origin (the origin, or * without credentials). The route owns Access-Control-Allow-Origin, -Allow-Credentials and -Expose-Headers: values from the runtime or from response_headers are replaced or removed. Every response of the route has Vary: Origin.
  • Basic authentication. The users file holds user:hash lines with bcrypt ($2a$, $2b$, $2y$, cost at most 12), SHA-256 or SHA-512 crypt ($5$, $6$, at most 100,000 rounds) or Apache MD5 ($apr1$, the default of htpasswd) hashes. Plain text, {SHA} and DES crypt entries are refused with a warning and never match. The file is at most 1 MiB, read at load and on reload (and watched with server.reload.watch); on Linux it and its directory must belong to root or to the user scalwsd runs as and must not be writable by group or others. Missing or wrong credentials get 401 with WWW-Authenticate: Basic realm="..."; more than 64 checks at once get 503 with Retry-After: 1. The Authorization header is not passed to the runtime.
  • Address lists use the client address after trusted_proxies. A deny match is refused even when allow lists the address.
  • Routes with basic_auth, allow or deny never use the response cache (caching.md). In an application whose routes use exact or regex paths or matchers, cached responses are kept per route: two routes on one URL (a canary route selected by a header, say) never share an entry.
routes:
  - path: /admin
    allow: ["198.51.100.0/24", "192.0.2.10"]
    basic_auth: {realm: "Admin", users_file: /etc/scalws/auth/admin.htpasswd}
    max_body: 50MiB
    request_timeout: 2m
    runtime: {type: proxy, upstream: {pool: api}}
  - path: /api
    rewrite: {prefix: /v2, host: api.internal}
    request_headers:
      set: {x-env: prod}
      remove: [x-debug]
    response_headers:
      set: {x-frame-options: DENY}
      remove: [server]
    cors:
      allow_origins: ["https://app.example.com", "https://*.example.com"]
      allow_credentials: true
      max_age: 1d
    runtime: {type: proxy, upstream: {pool: api}}

Redirect fields

The redirect runtime takes either to (an origin, as before) or Gateway-style fields that replace parts of the request URL:

FieldTypeDefaultDescription
schemehttp or httpsthe request’sScheme of the target.
hoststringthe request’s hostHost of the target.
portintegersee belowPort of the target, 1 to 65535.
pathstringthe request’s pathReplaces the whole path (starts with /, printable ASCII without spaces, ? and #).
prefixstringnoneReplaces the matched prefix of the path. Only on the runtime of a prefix route or the application’s own runtime; exclusive with path.
status301, 302, 303, 307 or 308301Redirect status.

to cannot be combined with these fields. As in the Gateway API, an unset port is the well-known port of a configured scheme, else the request’s port; a well-known port (80 for http, 443 for https) is not written. The query is kept.

routes:
  - path: /old-docs
    runtime: {type: redirect, scheme: https, host: docs.example.com, prefix: /docs, status: 308}

Complete production example

A single host serving HTTP and HTTPS with automatic certificates, sites in /etc/scalws/sites.d/, per-client limits and a response cache. Everything not shown keeps its default.

include: sites.d/*.yaml

server:
  listeners:
    - name: http
      address: "0.0.0.0:80"
    - name: https
      address: "0.0.0.0:443"
      strict_sni: true
      tls:
        acme: true
        certificates:                       # optional: a static fallback certificate
          - cert: /etc/scalws/tls/example.com.pem
            key: /etc/scalws/tls/example.com.key
            server_names: [example.com, "*.example.com"]
            default: true

  limits:
    max_request_body_bytes: 64MiB

  metrics:
    address: "127.0.0.1:9900"

  logging:
    format: json
    level: info

  acme:
    enabled: true
    contact: ["mailto:[email protected]"]
    terms_of_service_agreed: true

  cache:
    memory: 256MiB
    max_entry_size: 8MiB

  security:
    per_ip: {max_connections: 256, requests_per_second: 50, burst: 200}

  # Uncomment behind a load balancer:
  # trusted_proxies: ["192.0.2.0/24"]

  cgroups: {mode: auto}

tenants: []        # sites come from sites.d/*.yaml

Check it with scalwsctl check, then apply it with scalwsctl reload (or start the service). The full commented reference configuration ships as /usr/share/doc/scalws/examples/scalws.yaml.

Reload or restart?

Each key’s Reload line in the reference says whether a change applies on reload, needs a restart, or is only read when the process starts. In summary:

scalwsctl reload (or systemctl reload scalwsd) applies most changes without dropping connections: tenants and applications, routes (with their matchers and policies, and the basic_auth users files), upstreams, certificates and server names, strict_sni, limits other than max_connections and the two upgraded-connection limits, the values of the per-IP limits, trusted_proxies, logging’s access_log, http1_server, route-level client, reload.watch, reload.interval, health.metrics and limits.shutdown_delay.

Every change under upstreams applies on reload. Pools are matched by name: a pool whose settings (everything except members) are unchanged keeps its connections and passive health state, and a file pool’s members that stay keep their connections; a pool whose settings changed is rebuilt. Members set at run time for a members_from: api pool are kept across reloads but not across a restart, after which the pool starts with the file’s members (or empty). Per-application rate-limit and tenant quota state is kept across a reload as long as the limit’s definition is unchanged. See load-balancing.md.

A reload of a single scalwsd process is refused with “configuration change requires a restart” when any of these change; use systemctl restart scalwsd:

  • listeners: adding, removing or renaming one, or changing its address, TLS on/off, http3 or proxy_protocol;
  • the metrics endpoint (metrics.enabled, metrics.address);
  • limits.max_connections;
  • cgroups;
  • cache.memory, cache.max_entry_size or cache.disk;
  • turning security.per_ip on or off, or changing its table_size;
  • event_loops;
  • anything under ai;
  • the admin socket (admin.enabled, admin.socket) or admin.remote;
  • the health endpoint (health.address).

Some settings are only read when scalwsd starts and are kept across reloads: logging.format, logging.level, limits.max_upgraded_connections, limits.upgraded_idle_timeout, optimizer.journal_size, listener_mode, listen_socket, accept_policy, ebpf.network, ktls, upstream_client and sandbox. HTTP/3 listeners take their connection limits when they are built, so for HTTP/3 the limits apply after a restart.

Under scalws-controller (listener_mode: inherited) a reload starts a new configuration generation and replaces instances one by one, and listeners can be added, removed or moved without restarting the controller; see ../adaptive-runtime.md and ../listener-generations.md. Day-to-day commands are in ../OPERATIONS.md.