Internals: decision records

ADR-0048: Site files and the `redirect` runtime

Status: accepted, 2026-10-12

Context

Every site lived in scalws.yaml under tenants: → applications:, three levels deep, next to listeners, limits and the rest of the server. Adding a domain meant editing that file correctly; a www. redirect needed a second application with its own root and an .htaccess. Operators coming from nginx or Apache expect one file per site (sites-enabled/, vhosts.d/), and a tool that adds a site should not have to rewrite the whole configuration.

Decision

  • include: (a path or a list) in the main file names site files: a file, or a directory and a file pattern with one * (sites.d/*.yaml), relative to the main file. Matches are read in name order; hidden files are skipped; the main file alone may include. A missing directory is an error, an empty one is not.
  • A site file holds one site in a short form (domain, aliases, www: none|alias|redirect, root, tls: auto|manual, one runtime shorthand: static, php, node, python, proxy, redirect or the full runtime, plus profile, routes, cache, security, htaccess, and the tenant settings tenant, user, resources), or a tenants: list in the full syntax. Tenant and application names default to the domain (shop.example.com → shop-example-com). Sites of one tenant share it; its settings may come from one place only. tls defaults to auto when server.acme is enabled.
  • The loader expands site files into ordinary tenants and applications before profile resolution and validation: the server, the optimizer and the controller see one Config. Messages about a site name its file (sites.d/a.yaml: runtime.upstream: …); a domain served twice is the existing duplicate-domain error.
  • scalws-controller stores a configuration with include expanded (one self-contained document) as the generation, so a generation never changes when a site file does. A configuration without include is still stored byte for byte.
  • On Unix a site file and its directory must belong to root (or the main file’s owner) and must not be writable by group or others: writing a site file means serving any domain from any directory the server can read.
  • redirect runtime (to, status 301/302/303/307/308, keep_path): the answer carries Location: <to><request path and query>; the target is an origin, so the request path cannot change the host. www: redirect uses it.
  • https_redirect: true on an application (and in site files) answers plain-HTTP requests with a 301 to https://<routed host><path and query> after the routing and access checks; ACME HTTP-01 challenges are answered before it. One boolean test per request on the routed application.
  • scalwsctl site add|list|remove writes <domain>.yaml (0640, the main file’s group), refuses a domain that is already served, runs the reload check and removes the file again when it fails, then reloads (through scalws-controller when it runs). remove keeps the file as a hidden backup next to it.
  • scalwsctl migrate nginx|apache turns the virtual hosts of an existing configuration into site files (read-only on the source); conflicts with served domains are asked about per domain, only a domain’s own site file is ever replaced, and a run that fails the check or the reload is undone as a whole.
  • The console validates drafts with the site files of the configuration’s directory and stages the expanded document for scalwsd --check; its statistics include them.

Consequences

  • The package installs /etc/scalws/sites.d (root:scalws, 0750) and a default scalws.yaml with include: sites.d/*.yaml; existing configurations are unchanged (noreplace) and keep working without include.
  • Site files are not edited by the console’s forms yet; the console shows their sites and validates against them.
  • Data-plane changes: the redirect handler (a fixed response with a Location header) and the https_redirect test.