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.yamlEvery 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
- Value formats
serversections- listeners · limits · metrics · logging · runtime_dir · cgroups · cache · security · optimizer · diagnostics · ai · admin · acme · trusted_proxies · sendfile · ktls · open_file_cache · event_loops · ebpf · listener_mode · listen_socket · loop_lag_probe · accept_policy · upstream_client · http1_server · sandbox
upstreams- Tenants and applications
- Complete production example
- Reload or restart?
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 forproxyruntimes (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 decimalKB(orkB),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,hord(500ms,30s,5m,12h,30d). Bare numbers and combinations such as1m30sare rejected; write90s. - CPU quotas (tenant resources): a percentage of one CPU as a quoted string;
"100%"is one CPU,"400%"four. - Addresses:
IP:portwith an IP literal (no host names):"0.0.0.0:443","127.0.0.1:9901","[::]:443". Quote them in YAML. Inserver.listeners, a listener on the IPv6 wildcard[::]:ptakes IPv6 only (IPV6_V6ONLY) when another listener there uses an IPv4 address (0.0.0.0or a specific one) on the same portp, which then takes IPv4. Without such a listener[::]:pkeeps 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[::]:pgets 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: trueon[::]: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: trueis only allowed on TLS listeners.http3: trueneedstlsand is not allowed withlistener_mode: reuseport. The packages include HTTP/3; a scalwsd built from source without thehttp3feature refuses the setting at start.proxy_protocol: trueneedstrusted_proxies: connections from peers outside that list are closed. The header is read withinlimits.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_connectionscounts HTTP/3 connections too and must be at least 1.max_header_bytesmust be between8KiBand1MiB;max_headersbetween 1 and 1024;max_uri_bytesat least 256 bytes and at mostmax_header_bytes.idle_timeoutis also the QUIC idle timeout for HTTP/3, andh2_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_timeoutandwrite_timeoutmust be above zero.shutdown_timeoutis how long connections may drain on shutdown before they are closed.shutdown_delay(default0s) keeps serving for that long after SIGTERM while/readyzanswers 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: 30sPer-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: 5000logging
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: falseruntime_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_connectionsare closed at accept; requests overrequests_per_second(a token bucket) get429. max_connections,requests_per_secondandburstmust be above zero;burstneedsrequests_per_second;table_sizemust 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):
endpointis anhttp://orhttps://URL with a host and no user info, on loopback unlessallow_remoteis set (telemetry then leaves the host);modelis required;api_key_envnames an environment variable (letters, digits,_); the key itself never goes in the file;timeoutis between1sand10m,max_queuebetween 1 and 64,max_tokensbetween 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.
socketmust be an absolute path of at most 100 bytes.remoteis off unless set; when set, all four keys (address,cert,key,client_ca) are required, the paths must be absolute, andaddressmust not be a listener or metrics address. Only clients with a certificate issued byclient_cacan 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.pemacme
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_agreedmust betrue;directorymust be anhttps://URL;- each
contactentry must be amailto:address; check_intervalmust be at least1m;- 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: truescalwsctl 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-Forentry that is not itself a trusted proxy. From untrusted peers the header is ignored and replaced. - Listeners with
proxy_protocol: truerequire this list and close connections from other peers. - The effective client address is used for per-client limits, access logs, PHP
REMOTE_ADDRand 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 60sevent_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
tasksetand 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: oneSO_REUSEPORTsocket per event loop, so several scalwsd instances can serve the same address. Linux only; needs per-core event loops (event_loopsmust not be0); HTTP/3 listeners are not allowed in this mode.inherited: the sockets are received fromlisten_socket(opened by scalws-controller; every instance accepts from the same queue). Linux only; needslisten_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 keepsharedwhere keep-alive latency matters (a proxy, an ingress controller). It has no effect with a socket per event loop (listener_mode: reuseportorSCALWS_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.
| Field | Type | Default | Description |
|---|---|---|---|
members | list | [] | 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[].address | string | required | ip: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[].weight | integer | 1 | Relative share of requests, 1 to 1000. |
members_from | file or api | file | Where the member list comes from: this file, or the admin API (PUT /v1/upstreams/{name}, admin API). |
balance | round_robin, least_conn or hash | round_robin | Smooth weighted round robin; fewest requests in flight per weight; or weighted rendezvous hashing of hash.by. |
hash.by | cookie, header or client_ip | required with balance: hash | Key of the hash: an affinity cookie, a request header, or the client address after trusted_proxies. |
hash.name | string | SCALWS_AFFINITY for cookies | Cookie or header name (token characters, at most 64); required with by: header, not allowed with by: client_ip. |
hash.cookie_max_age | duration | 0s | Max-Age of the affinity cookie; 0s makes a session cookie. Only with by: cookie. |
passive_health.failures | integer | 3 | Consecutive failures before a member is ejected, 0 to 100; 0 turns passive health off. |
passive_health.cooldown | duration | 10s | How long an ejected member stays out, 1ms to 1h. |
retries | integer | 1 | Further attempts on other members, 0 to 5 (only for pools with more than one member; see retries). |
keepalive | integer | 32 | Idle connections kept per member, at most 1024. |
scheme | http or https | http | Protocol towards the members. With https, certificates are always verified. |
tls.ca_file | path | none | Absolute path of a PEM bundle trusted in addition to the system roots. Only with scheme: https. |
tls.server_name | string | the member’s host | SNI 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):
| Field | Type | Default | Description |
|---|---|---|---|
upstream | string or {pool: name} | none | http://host[:port], https://host[:port] or unix:/absolute/path: one address, no retries, no ejection (as before). {pool: name}: a pool from upstreams. |
backends | list | [] | {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):
| Field | Type | Default | Description |
|---|---|---|---|
path | string | required | The prefix, the exact path or the regular expression, by path_type. |
path_type | prefix, exact or regex | prefix | prefix: 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. |
methods | list of strings | [] (any) | Request methods, upper case (GET, POST, …). HEAD must be listed on its own. At most 16. |
headers | list | [] | {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. |
query | list | [] | {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:
exactroutes;prefixroutes, longest path first;regexroutes, 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: /.
| Field | Type | Default | Description |
|---|---|---|---|
rewrite.path | string | none | New 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.prefix | string | none | Replaces the matched prefix (prefix routes only). Exclusive with rewrite.path. |
rewrite.host | string | none | Host sent upstream, host or host:port (implies preserve_host: false). Proxy runtimes only; a warning for others. |
request_headers | {set, add, remove} | none | Changes 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} | none | Changes to every response of the route, including answers generated by ScalWS (401, 403, 413, 504, …). |
max_body | size | limits.max_request_body_bytes | Largest request body for the route, above or below the global limit; 413 above. |
request_timeout | duration | none | Time from the route match until the response head; 504 when it passes. The body is not bounded. |
https_redirect | bool | the application’s | Answer 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. |
cors | map | none | Cross-origin resource sharing, see below. |
basic_auth.realm | string | Restricted | Realm in WWW-Authenticate (1 to 128 printable ASCII characters, no " or \). |
basic_auth.users_file | path | required | Absolute path of an htpasswd file. |
allow | list | [] | Addresses and CIDR blocks (IPv4, IPv6); when set, other clients get 403. At most 1024. |
deny | list | [] | 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/xrewritten withprefix: /v2becomes/v2/%252e%252e/x. A capture that would form a.or..segment, or a result above/, answers400. Validation refuses$nabove 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 inREQUEST_URI. In an application withhtaccess: true,.htaccessfiles 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 forrewrite.pathandrewrite.prefixonstaticandphproutes). - 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, andhostis changed withrewrite.host. - CORS.
allow_origins(required): origins such ashttps://app.example.com,https://*.example.com(one or more labels beforeexample.com; scheme and port must match; no trailing dot on the host), or["*"]alone.allow_methods(defaultGET, PUT, POST, DELETE, PATCH, OPTIONS),allow_headers(defaultDNT, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Range, Authorization),expose_headers(default none),allow_credentials(defaultfalse; refused together with*) andmax_age(default20d, at most 365 days). A preflight (OPTIONSwithOriginandAccess-Control-Request-Method) is answered with204without reaching the runtime. Other requests from an allowed origin getAccess-Control-Allow-Origin(the origin, or*without credentials). The route ownsAccess-Control-Allow-Origin,-Allow-Credentialsand-Expose-Headers: values from the runtime or fromresponse_headersare replaced or removed. Every response of the route hasVary: Origin. - Basic authentication. The users file holds
user:hashlines 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 ofhtpasswd) 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 withserver.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 get401withWWW-Authenticate: Basic realm="..."; more than 64 checks at once get503withRetry-After: 1. TheAuthorizationheader is not passed to the runtime. - Address lists use the client address after
trusted_proxies. Adenymatch is refused even whenallowlists the address. - Routes with
basic_auth,allowordenynever use the response cache (caching.md). In an application whose routes useexactorregexpaths 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:
| Field | Type | Default | Description |
|---|---|---|---|
scheme | http or https | the request’s | Scheme of the target. |
host | string | the request’s host | Host of the target. |
port | integer | see below | Port of the target, 1 to 65535. |
path | string | the request’s path | Replaces the whole path (starts with /, printable ASCII without spaces, ? and #). |
prefix | string | none | Replaces the matched prefix of the path. Only on the runtime of a prefix route or the application’s own runtime; exclusive with path. |
status | 301, 302, 303, 307 or 308 | 301 | Redirect 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/*.yamlCheck 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,
http3orproxy_protocol; - the metrics endpoint (
metrics.enabled,metrics.address); limits.max_connections;cgroups;cache.memory,cache.max_entry_sizeorcache.disk;- turning
security.per_ipon or off, or changing itstable_size; event_loops;- anything under
ai; - the admin socket (
admin.enabled,admin.socket) oradmin.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.