# Tenants, applications and runtimes

URL: https://scalws.com/docs/guide/applications/
Source: `docs/guide/applications.md` in the ScalWS repository

How tenants, applications, routes and the runtimes (static files, reverse proxy, fixed
responses, redirects, PHP, Node.js, Python, containers) are configured, with complete
examples for common applications.

Everything ScalWS serves is described by three levels in `scalws.yaml` (or in
[site files](https://scalws.com/docs/sites/)):

```text
tenants[]                 an account: Unix user, CPU/memory/I/O limits, request quotas
  applications[]          one site: domains, root, profile, cache, security, .htaccess
    runtime               what answers requests (static, proxy, php, node, python, …)
    routes[]              path prefixes answered by a different runtime
```

Every structure rejects unknown keys, so a typo is a configuration error rather than a
silently ignored setting. Check a configuration with `scalwsctl check`; changes take effect
on reload (see [cli.md](https://scalws.com/docs/guide/cli/) and [configuration.md](https://scalws.com/docs/guide/configuration/)).

Contents:

* [Tenants](#tenants)
* [Applications](#applications)
* [Routes](#routes)
* [Runtimes](#runtimes): [static](#static), [proxy](#proxy), [respond](#respond),
  [redirect](#redirect), [php](#php), [node](#node), [python](#python),
  [container](#container)
* [How managed runtimes are run](#how-managed-runtimes-are-run)
* [Profiles and detection](#profiles-and-detection)
* [.htaccess](#htaccess)
* [Examples](#examples)

Units used below: sizes are a number of bytes or a string such as `"512MiB"`, `"2GiB"`,
`"10MB"` (binary `KiB MiB GiB TiB` and decimal `KB MB GB TB`); durations are strings with
a unit: `"500ms"`, `"30s"`, `"5m"`, `"1h"`, `"30d"` (a bare number is rejected).

## Tenants

```yaml
tenants:
  - name: acme
    user: acme                 # optional: Unix account for its processes
    resources:                 # optional: limits for the whole tenant
      cpu: "200%"
      memory: 2GiB
    applications:
      - ...
```

A tenant's [`name`](https://scalws.com/docs/reference/tenants/#tenants-.name) must be unique; it is used in
metrics, logs and cgroup names. Every key of a tenant, with its syntax, default and reload
behaviour: [reference](https://scalws.com/docs/reference/tenants/).

### Tenant user

[`user`](https://scalws.com/docs/reference/tenants/#tenants-.user) must be a Unix account name: up to 32 characters of `a-z`, `0-9`, `_`, `-`, `.`,
starting with a lower-case letter or `_`, and not `root`. It is resolved with `getent
passwd` when the configuration is activated (local accounts, LDAP, …); uid 0 is refused.
The tenant's PHP-FPM, Node.js and Python processes then run as that account, with their
sockets in a private per-tenant directory. Container applications are refused for a tenant
with a `user`.

scalwsd needs extra capabilities for this (`CAP_SETUID`, `CAP_SETGID`, `CAP_CHOWN`,
`CAP_KILL`), and document roots must be readable by the tenant and by group `scalws`; see
[OPERATIONS.md → Tenant users](https://scalws.com/docs/operations/#tenant-users-adr-0026-optional) and
[ADR-0026](https://scalws.com/docs/adr/0026-tenant-unix-users/). Changing a tenant's user restarts its
applications on reload.

Without `user`, managed processes run as the account scalwsd runs as (`scalws` with the
package). A managed PHP-FPM pool started by a scalwsd running as root hands its workers to
`nobody`.

### Resources

All fields of [`resources`](https://scalws.com/docs/reference/tenants/#tenants-.resources) are optional; an
unset field means no limit. Every resource key, with the cgroup file it sets, its syntax
and reload behaviour: [reference](https://scalws.com/docs/reference/tenants/#tenants-.resources).

Rules not shown there: [`cpu`](https://scalws.com/docs/reference/tenants/#tenants-.resources.cpu) is a
percentage of one CPU as a quoted string (`"50%"`, `"400%"` = four CPUs) and must be above
`0%`; `memory` and `pids` (processes and threads) must be above zero; `swap` is only
allowed together with `memory`; `burst` needs `requests_per_second`. Requests over
`max_concurrent_requests` get `503`, and requests over `requests_per_second` get `429`,
both with `Retry-After`.

How they apply ([ADR-0010](https://scalws.com/docs/adr/0010-cgroups-and-tenant-limits/)):

* **Process limits** (`cpu`, `memory`, `swap`, `pids`, `io_weight`, `io_bandwidth`) are
  cgroup v2 limits on `<base>/tenants/<tenant>/`, shared by all the tenant's managed
  processes (PHP-FPM, Node.js, Python, and podman containers). Each application has its own
  child group, and every worker joins its group before it runs its first instruction.
  Static files and proxied requests are handled inside scalwsd, not in the tenant's cgroup.
* Enforcement depends on `server.cgroups.mode`: `auto` (default; enforce when cgroup
  delegation works, otherwise warn and serve without limits), `required` (refuse to start
  without it) or `off` (a configuration with process limits then gets a warning).
* Limits are written on every activation, so changing them takes effect on reload without
  restarting applications.
* **Request quotas** (`max_concurrent_requests`, `requests_per_second`, `burst`) apply to
  every request for the tenant's domains, whatever the runtime. Under scalws-controller each
  instance enforces its share (the limit divided by the number of instances, rounded up).
* Limits can be changed at run time and inspected with `scalws-limitcpu`,
  `scalws-limitram`, `scalws-limitio` and `scalws-highcpu|highram|highio`; see
  [OPERATIONS.md → Tenant resource limits](https://scalws.com/docs/operations/#tenant-resource-limits-adr-0010).

```yaml
tenants:
  - name: acme
    user: acme
    resources:
      cpu: "200%"
      memory: 2GiB
      swap: 512MiB
      pids: 512
      io_weight: 200
      io_bandwidth: 50MiB
      max_concurrent_requests: 200
      requests_per_second: 100
      burst: 300
    applications: []
```

## Applications

```yaml
applications:
  - name: shop
    root: /srv/acme/shop
    domains: [shop.example.com, www.shop.example.com]
    acme: true
    https_redirect: true
    profile: wordpress
```

Every key of an application, with its syntax, default and reload behaviour:
[reference](https://scalws.com/docs/reference/applications/). Points to know:

* [`name`](https://scalws.com/docs/reference/applications/#tenants-.applications-.name) follows the tenant
  name rules and is unique within the tenant;
  [`domains`](https://scalws.com/docs/reference/applications/#tenants-.applications-.domains) needs at least
  one host name (see [Domains](#domains)).
* [`root`](https://scalws.com/docs/reference/applications/#tenants-.applications-.root) is an absolute path:
  the default document root of `static` and `php` runtimes, the working directory of
  managed Node.js/Python processes, and the base for `.htaccess` and profiles.
* [`root_symlinks`](https://scalws.com/docs/reference/applications/#tenants-.applications-.root_symlinks)
  is `follow` (default: a symbolic link in the root's path is followed when the root is
  opened) or `refuse`: the root (and a runtime's own `root`) must then be a canonical
  absolute path without a symbolic link in any component, and it is opened one directory
  at a time without following links, for static files, PHP scripts and `.htaccess` alike.
  Use it when the directories on the way belong to someone else, such as document roots
  in the home directories of hosting accounts ([panel mode](https://scalws.com/docs/guide/control-panels/) sets it on
  every account site). Files below the root never lead outside it, with either value.
* [`runtime`](https://scalws.com/docs/reference/applications/#tenants-.applications-.runtime) handles every
  request not matched by one of the [`routes`](#routes); it is required unless a
  [`profile`](#profiles-and-detection) supplies it.
* [`acme`](https://scalws.com/docs/reference/applications/#tenants-.applications-.acme) needs
  `server.acme.enabled` (see [tls.md](https://scalws.com/docs/guide/tls/));
  [`https_redirect`](https://scalws.com/docs/reference/applications/#tenants-.applications-.https_redirect)
  answers plain HTTP with `301` but still answers ACME HTTP-01 challenges;
  [`htaccess`](#htaccess) needs `root`.
* `cache` ([caching.md](https://scalws.com/docs/guide/caching/)) and `security` ([security.md](https://scalws.com/docs/guide/security/)) are off
  unless present.

### Domains

* Names are normalised: lower-cased, a trailing dot removed. Labels are 1–63 characters
  of `a-z`, `0-9` and `-`, not starting or ending with `-`; at most 253 characters in
  all. Internationalised names must be written in punycode (`xn--…`).
* A wildcard `*.example.com` covers exactly one extra label: it matches
  `a.example.com`, but not `example.com` and not `a.b.example.com`. List `example.com`
  separately if it should be served too. The suffix must have at least two labels
  (`*.com` is refused). An exact name wins over a wildcard.
* A name may appear only once in the whole configuration (all tenants, all site files).
* With `acme: true`, wildcards (they would need DNS-01, which is not supported), IP
  addresses and `localhost` are refused; use a certificate file for those.

## Routes

```yaml
routes:
  - path: /api
    runtime: {type: proxy, upstream: "http://127.0.0.1:8080"}
  - path: /api/v2
    runtime: {type: proxy, upstream: "http://127.0.0.1:8082"}
```

* A route matches its path and everything below it **on whole segments**: `/api` matches
  `/api` and `/api/users`, but not `/apikeys`. An encoded slash ends a segment too:
  `/api%2Fusers` (or `%2f`) selects `/api`, since a backend may decode it to `/api/users`.
* The **longest** matching prefix wins (above, `/api/v2/x` goes to port 8082), whatever
  the order in the file. Requests matched by no route go to the application's `runtime`;
  a route with `path: /` replaces it.
* Matching uses the normalised path (percent-decoding, `.`/`..` resolved, repeated
  slashes collapsed), so `/%61pi` or `/x/../api` cannot bypass a route.
* The path is **not stripped**: a request for `/api/users` reaches the route's runtime as
  `/api/users`. For a `static` route, `root` is therefore the directory that contains the
  prefix directory.
* [`path`](https://scalws.com/docs/reference/routes/#tenants-.applications-.routes-.path) must start with
  `/`, must not end with `/` (except `/` itself), must not contain `?`, `#`, `%`, `\`,
  `*`, spaces or empty/`.`/`..` segments, and must be unique within the application.

Each route has a full [runtime](https://scalws.com/docs/reference/routes/#tenants-.applications-.routes-.runtime)
of its own and is validated like the application's `runtime`. Every key of a route:
[reference](https://scalws.com/docs/reference/routes/).

The rules above are those of prefix routes, the default. A route can also match an exact
path or a regular expression (`path_type`), and only some methods, headers or query
parameters; and it can carry policies: rewrites, request and response header changes, a
body limit, a timeout, CORS, basic authentication and address allow/deny lists. An
application with routes may leave out `runtime`; unmatched requests then get `404`. See
[route matching](https://scalws.com/docs/guide/configuration/#route-matching) and
[route policies](https://scalws.com/docs/guide/configuration/#route-policies).

## Runtimes

`runtime` (and each route's `runtime`) is a map with a `type` key:

| `type` | Serves |
|---|---|
| `static` | Files from a directory. |
| `proxy` | An HTTP/1.1 upstream (TCP, TLS or Unix socket). |
| `respond` | A fixed response. |
| `redirect` | A redirect to another origin. |
| `php` | PHP through FastCGI: a managed PHP-FPM pool or an existing one. |
| `node` | A Node.js process managed by ScalWS, or an external one. |
| `python` | An ASGI/WSGI server managed by ScalWS, or an external one. |
| `container` | An image run with podman or docker. |

`php` (without `fastcgi`), `node` and `python` (without `upstream`) and `container` are
**managed runtimes**: ScalWS starts and supervises their processes. They are Unix-only, and
`node`/`python` need the application `root`.

> **scalws-controller** (several scalwsd instances, ADR-0036) refuses every managed runtime,
> because each instance would start its own copy of the application. Run the application
> yourself (e.g. with systemd) and point ScalWS at it: `php` with `fastcgi:` for PHP, and a
> `proxy` runtime for Node.js, Python and containers. The controller also refuses `node`
> and `python` runtimes that use `upstream:`; use `type: proxy` instead.

### static

```yaml
runtime:
  type: static
  root: /srv/www/example.com/public    # default: the application root
  index: [index.html]                  # default
  precompressed: true                  # default: false
```

Every key of the `static` runtime: [reference](https://scalws.com/docs/reference/runtime-static/). The
document [`root`](https://scalws.com/docs/reference/runtime-static/#tenants-.applications-.runtime.static.root)
is absolute; it or the application `root` is required.
[`index`](https://scalws.com/docs/reference/runtime-static/#tenants-.applications-.runtime.static.index)
takes plain file names only. With
[`precompressed`](https://scalws.com/docs/reference/runtime-static/#tenants-.applications-.runtime.static.precompressed),
a `<file>.br` or `<file>.gz` sibling is served only when it is not older than the file,
with `Vary: Accept-Encoding`.

Only `GET` and `HEAD` are served. Directories are never listed. Dotfiles (except under
`/.well-known/`), encoded slashes and anything resolving outside the root answer `404`.
Conditional requests and single byte ranges are supported.

### proxy

```yaml
runtime:
  type: proxy
  upstream: "http://127.0.0.1:3000"
  preserve_host: true
  connect_timeout: 5s
  response_timeout: 60s
  websocket: true
```

Every key of the `proxy` runtime: [reference](https://scalws.com/docs/reference/runtime-proxy/).
[`upstream`](https://scalws.com/docs/reference/runtime-proxy/#tenants-.applications-.runtime.proxy.upstream)
(or `backends`) is required; `connect_timeout` and `response_timeout` must be above zero;
[`client`](https://scalws.com/docs/reference/runtime-proxy/#tenants-.applications-.runtime.proxy.client) is
`hyper` or `experimental` ([ADR-0045](https://scalws.com/docs/adr/0045-experimental-upstream-client/)).

Upstream formats (HTTP/1.1 in every case):

| Form | Example | Notes |
|---|---|---|
| `http://host[:port]` | `http://127.0.0.1:3000`, `http://[::1]:8080`, `http://app.internal` | Port defaults to 80. |
| `https://host[:port]` | `https://api.example.com` | Port defaults to 443; the host is the TLS server name. |
| `unix:/absolute/path` | `unix:/run/app/app.sock` | Unix domain socket. |

The host may be a name, an IPv4 address or a bracketed IPv6 address. User information
(`user@`) and port 0 are refused, and so is a path: `http://x:1/prefix` is an error,
because requests keep their original path (a trailing `/` alone is accepted; a route's
`rewrite` changes the path sent upstream).

Instead of one address, `upstream: {pool: name}` sends to a pool of members defined under
the top-level `upstreams`, with load balancing, session affinity, passive health checks
and retries, and `backends` splits requests by weight across pools (canary releases). See
[load-balancing.md](https://scalws.com/docs/guide/load-balancing/).

```yaml
runtime: {type: proxy, upstream: {pool: api}}
```

### respond

```yaml
runtime: {type: respond, status: 200, body: "ok\n", content_type: "text/plain"}
```

Every key of the `respond` runtime: [reference](https://scalws.com/docs/reference/runtime-respond/).
[`content_type`](https://scalws.com/docs/reference/runtime-respond/#tenants-.applications-.runtime.respond.content_type)
must be printable ASCII.

### redirect

```yaml
runtime: {type: redirect, to: "https://example.com"}
```

Every key of the `redirect` runtime: [reference](https://scalws.com/docs/reference/runtime-redirect/).
[`to`](https://scalws.com/docs/reference/runtime-redirect/#tenants-.applications-.runtime.redirect.to) is
required: an `http://` or `https://` URL with a host and no user information. With
[`keep_path`](https://scalws.com/docs/reference/runtime-redirect/#tenants-.applications-.runtime.redirect.keep_path)
on (the default), `to` must be an origin only (no path, query or fragment; a trailing `/`
is allowed). With `keep_path: false` every request goes to exactly `to`, which may then contain a path.

### php

PHP is served through FastCGI. Without `fastcgi`, ScalWS runs a **managed PHP-FPM pool**
for the application; with `fastcgi`, it uses an **existing pool**.

```yaml
# Managed pool
runtime:
  type: php
  front_controller: /index.php
  fpm:
    workers: {min: 2, max: 16}
    ini: {memory_limit: 256M, upload_max_filesize: 64M}
    env: {APP_ENV: production}

# Existing pool (also the way to run PHP under scalws-controller)
runtime:
  type: php
  fastcgi: "unix:/run/php/php8.3-fpm.sock"     # or "tcp:127.0.0.1:9000"
  front_controller: /index.php
```

Every key of the `php` runtime, including `fpm`: [reference](https://scalws.com/docs/reference/runtime-php/).
[`fastcgi`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fastcgi) and
[`fpm`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fpm) exclude each
other. The document [`root`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.root)
is absolute; it or the application `root` is required. `index` takes plain file names.
[`front_controller`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.front_controller)
must be an absolute path to a `.php` file inside the root, without dot segments.

How a request is handled (like the usual nginx + PHP-FPM setup):

1. A path segment ending in a PHP suffix (`.php`, `.phtml`, `.php3` to `.php8`, `.inc`,
   any case) that names an existing script goes to PHP; the rest of the path becomes
   `PATH_INFO`.
   An `index` file with such a suffix is a script too.
2. An existing non-PHP file, or a directory with an `index` file, is served as a static
   file. Files with a PHP suffix, `.phar` and `.phps` are never sent as source (`404`).
3. Otherwise the `front_controller` handles the request, if it is set and exists.
4. Otherwise `404`.

`fpm` (managed pool):

* [`binary`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fpm.binary)
  is an absolute path; by default `php-fpm` on `PATH` or in the usual sbin/bin directories,
  else the newest versioned one there (`php-fpm8.4` on Debian and Ubuntu, `php-fpm84` on
  Alpine). Its version (`<binary> -v`) is asked when the pool starts; checking a
  configuration (`scalwsctl check`, `scalwsd --check`) never runs it.
* [`workers`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fpm.workers)
  defaults to `{min: 2, max: 8}`; `min == max` gives `pm = static`, otherwise
  `pm = dynamic`.
* [`ini`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fpm.ini) keys
  use `[A-Za-z0-9_.]`; values `[A-Za-z0-9 _-.,/:+@%=*]`, at most 1024 bytes.
* [`env`](https://scalws.com/docs/reference/runtime-php/#tenants-.applications-.runtime.php.fpm.env) names
  match `[A-Za-z_][A-Za-z0-9_]*` and must not start with `SCALWS_`; values contain no
  quotes, backslashes or newlines.

The pool listens on a private Unix socket under `server.runtime_dir`, clears the
environment (`clear_env = yes`) and only executes `.php` files. Values such as
`max_execution_time: "120"` are quoted so YAML keeps them as strings. PHP-FPM sizes its own
pool between `min` and `max`; the optimizer only gives advice for PHP.

### node

```yaml
runtime:
  type: node
  command: [node, server.js]
  env: {NODE_ENV: production}
  workers: {min: 2, max: 4, autoscale: auto}
  health_path: /healthz
```

Every key of the `node` runtime: [reference](https://scalws.com/docs/reference/runtime-node/).

Exactly one of [`command`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.command)
(managed mode, e.g. `[node, server.js]` or `[npm, start]`) and
[`upstream`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.upstream)
(same formats as `proxy.upstream`) is required.
[`env`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.env) names match
`[A-Za-z_][A-Za-z0-9_]*`; the `SCALWS_` prefix is reserved. Without
[`workers`](#workers), one process runs. Use
[`listen`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.listen)`: tcp`
for frameworks that only accept a port number.
[`health_path`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.health_path)
is an absolute request path. A process not ready within `startup_timeout` is restarted;
`stop_timeout` is the time between SIGTERM and SIGKILL. With `command`, the application
must listen on `process.env.PORT`, which holds the socket path (`listen: unix`) or the port
number (`listen: tcp`); Node's `server.listen(process.env.PORT)` accepts both. Each
process also gets `SCALWS_SOCKET` (the socket path, `unix` only), `HOST` (`tcp` only) and
`SCALWS_WORKER` (its index, `0`, `1`, …). Requests are balanced across ready processes;
WebSocket upgrades are tunnelled.

### python

```yaml
runtime:
  type: python
  mode: asgi
  app: "main:app"
  virtualenv: /srv/api/.venv
  workers: {min: 2, max: 8}
```

Every key of the `python` runtime: [reference](https://scalws.com/docs/reference/runtime-python/).

[`mode`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.mode)
(`asgi` or `wsgi`) is required.
[`server`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.server) is
`uvicorn`, `hypercorn` or `gunicorn`; WSGI with `app` needs `gunicorn`.
[`virtualenv`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.virtualenv)
is an absolute path; the server binary is taken from `<virtualenv>/bin`, and
`VIRTUAL_ENV`/`PATH` are set. `env`, `listen`, `health_path` and the timeouts follow the
same rules as for `node`; without `workers` the server's own default applies.

Exactly one of [`app`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.app),
[`command`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.command)
and [`upstream`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.upstream)
is required. With `app`, ScalWS builds the
command line:

| mode | server | Command |
|---|---|---|
| asgi | uvicorn | `uvicorn APP --uds SOCK [--workers N] --no-access-log` |
| asgi | hypercorn | `hypercorn APP --bind unix:SOCK [--workers N]` |
| asgi | gunicorn | `gunicorn APP -k uvicorn.workers.UvicornWorker --bind unix:SOCK [--workers N]` |
| wsgi | gunicorn | `gunicorn APP --bind unix:SOCK [--workers N]` |

(`--host`/`--port` or `--bind HOST:PORT` with `listen: tcp`; `N` is `workers.min`.)

With `command`, either include the placeholder `{socket}` (with `listen: unix`) or
`{port}` (with `listen: tcp`) in the arguments, or start `uvicorn`, `hypercorn` or
`gunicorn`, and ScalWS appends the bind arguments (and `--workers` unless the command has
`--workers` or `-w`):

```yaml
runtime:
  type: python
  mode: asgi
  command: [/srv/api/.venv/bin/uvicorn, "main:app", --proxy-headers]
```

Without [`process_pool`](https://scalws.com/docs/reference/runtime-python/#tenants-.applications-.runtime.python.process_pool), ScalWS supervises one server process that manages its own
`workers.min` workers; that count is fixed (the optimizer only recommends). With
`process_pool: true` and `workers`, ScalWS runs between `min` and `max` single-worker
servers itself, so the pool can be scaled and restarted without dropping requests, at the
cost of the server's copy-on-write sharing.

The process also gets `FORWARDED_ALLOW_IPS=*` (only ScalWS can reach its socket or loopback
port) and `PYTHONUNBUFFERED=1`.

### container

```yaml
runtime:
  type: container
  image: docker.io/library/nginx:1.27
  port: 80
  workers: {min: 1, max: 3}
  read_only: true
  drop_capabilities: true
```

The schema is marked as provisional in the code and may still change.

Every key of the `container` runtime: [reference](https://scalws.com/docs/reference/runtime-container/).
Rules not shown there:

* [`image`](https://scalws.com/docs/reference/runtime-container/#tenants-.applications-.runtime.container.image)
  is required: `[A-Za-z0-9./:@_-]`, at most 255 characters, not starting with `-`.
* `engine_path` is an absolute path.
* With [`network`](https://scalws.com/docs/reference/runtime-container/#tenants-.applications-.runtime.container.network)`: bridge`
  (the default), [`port`](https://scalws.com/docs/reference/runtime-container/#tenants-.applications-.runtime.container.port)
  is required and published on a loopback port chosen by ScalWS; with `host`, `port` is
  refused and the application listens on the port given as `PORT` (or `{port}` in
  `command`).
* Without `command` or `user`, the image's defaults apply; `user` is a user name or
  `uid[:gid]`. Without [`workers`](#workers), one container runs.
* [`mounts`](https://scalws.com/docs/reference/runtime-container/#tenants-.applications-.runtime.container.mounts)
  take absolute paths without commas.

Each container runs in the foreground as `podman run --rm --init --security-opt
no-new-privileges …` and is supervised like any other worker; it is removed after every
exit. With podman and cgroup enforcement the containers are created under the
application's cgroup, so tenant limits include them. With `engine: docker` the daemon places
the containers and tenant limits do not apply (the configuration gets a warning).
Containers are refused for tenants with a `user`. See also
[OPERATIONS.md → Containers](https://scalws.com/docs/operations/#containers) and
[ADR-0020](https://scalws.com/docs/adr/0020-container-runtime/).

### Workers

`node`, `python`, `container` and `php.fpm` take the same `workers` map (keys for node:
[reference](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.workers);
the other runtimes have the same keys on their reference pages). `min` and `max` are
required, with 1 ≤ `min` ≤ `max` ≤ 256. With
[`autoscale`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.workers.autoscale)`: recommend`
(the default) decisions are only logged.
[`target_concurrency`](https://scalws.com/docs/reference/runtime-node/#tenants-.applications-.runtime.node.workers.target_concurrency)
defaults to 4 (1 for WSGI) and must be above zero.

When `workers` is omitted, Node.js and containers run one process. For pools ScalWS manages
itself (Node.js, Python with `process_pool`), `min` processes start, endpoints for `max` are
reserved, and with `autoscale: auto` the optimizer moves the count between `min` and `max`
for the application's main `runtime` (scale up at high utilisation, down when idle, with
automatic rollback if errors or latency get worse). Decisions are listed by `scalwsctl
recommendations`.

## How managed runtimes are run

For `node`, `python`, managed `php` and `container`, ScalWS ([ADR-0008](https://scalws.com/docs/adr/0008-process-supervision/)):

* **Starts** each process directly from its argument vector, never through a shell, in
  its own process group, with the application `root` as working directory (PHP-FPM:
  `root`, else the PHP root). The environment is cleared and rebuilt: `PATH` (system
  directories), `LANG=C.UTF-8`, `HOME` (the working directory), the listen variables
  (`PORT`, `SCALWS_SOCKET` or `HOST`, `SCALWS_WORKER`) and your `env`.
* **Places** it in the application's cgroup before it runs (when cgroups are enforced),
  as the tenant's `user` if set, without any of scalwsd's capabilities and with
  `no_new_privs`; it receives SIGTERM if scalwsd dies.
* **Waits for readiness**: the socket or port accepts a connection and, with
  `health_path`, answers that path with a non-5xx status within two seconds. Requests are
  routed only to ready workers. A worker not ready within `startup_timeout` is restarted.
* **Restarts** a process that exits, with exponential backoff from 0.5 s up to 30 s. A
  process that stayed up for 60 s counts as healthy again; five crashes within 60 s mark the
  worker as failed in status and metrics (it is still retried at the backoff interval).
* **Stops** gracefully on reload or shutdown: SIGTERM to the process group (SIGQUIT for
  PHP-FPM), SIGKILL after `stop_timeout`, then anything left in its cgroup is killed.
* **Logs** stdout and stderr into scalwsd's log, tagged with tenant, application and
  worker, at most 100 lines per second per worker (bursts of 200), lines cut at 8 KiB.

A reload that does not change a runtime's settings keeps its processes running; a changed
runtime starts a new set and drains the old one.

## Profiles and detection

A profile ([ADR-0011](https://scalws.com/docs/adr/0011-application-profiles/)) bundles what a framework needs:
a runtime template, a health check, cache-bypass rules and denied paths.

```sh
scalwsctl profiles              # list the built-in profiles
scalwsctl detect /srv/blog      # which profile fits, with evidence and a proposed block
scalwsctl detect /srv/blog --json
```

`detect` only reads the directory (bounded, symlinks not followed) and writes nothing. It
prints every matching profile with its confidence and an `applications:` entry for the best
one, to review and paste.

| Profile | Runtime template | Health path | Adds |
|---|---|---|---|
| `php` | php, managed pool 2–8, `front_controller: /index.php` | `/` | denies `/composer.json`, `/composer.lock`, `/vendor` |
| `wordpress` | php, pool 2–16, `front_controller: /index.php`, `memory_limit: 256M`, uploads 64M, `max_execution_time: 120` | `/wp-login.php` | cache bypass for `/wp-admin`, `/wp-login.php`, `/wp-json`, `/xmlrpc.php`, `/wp-cron.php` and the login/comment cookies; denies `/wp-config.php`, `/wp-content/debug.log`, `/wp-content/uploads/*.php`, `/readme.html`, `/license.txt` |
| `woocommerce` | as WordPress, pool 4–32, `memory_limit: 512M`, `max_execution_time: 180` | `/wp-login.php` | WordPress rules plus cart/checkout/account paths, `wc-ajax`/`add-to-cart` queries and WooCommerce cookies; denies `/wp-content/uploads/wc-logs` |
| `laravel` | php with root `<root>/public`, pool 2–16, `front_controller: /index.php`, `memory_limit: 256M` | `/up` | cache bypass for `laravel_session`, `XSRF-TOKEN`; denies `/.env`, `/storage/logs`, `/vendor` |
| `node` | node, the detected start command, `NODE_ENV=production` | `/` | denies `/node_modules`, `/.env`, `/package-lock.json` |
| `nextjs` | node, `npm run start`, `listen: tcp`, `NODE_ENV=production`, `startup_timeout: 60s` | `/` | cache bypass for `/api`, `/_next/data`; denies `/.next/cache`, `/.env`, `/.env.local` |
| `python` | python wsgi, the detected callable, pool 2–8 | `/` | n/a |
| `django` | python wsgi, the detected WSGI module, 2–8 workers | `/` | cache bypass for `/admin` and `sessionid`, `csrftoken`; denies `/manage.py`, `/db.sqlite3` |
| `flask` | python wsgi, the detected `Flask(...)` object | `/` | cache bypass for the `session` cookie |
| `fastapi` | python asgi, the detected `FastAPI(...)` object | `/docs` | cache bypass for `/docs`, `/openapi.json` |

What [`profile:`](https://scalws.com/docs/reference/applications/#tenants-.applications-.profile) does in a configuration:

* An unknown profile name is an error.
* When `runtime` is omitted, the profile supplies it, but only profiles whose template
  needs nothing found by inspecting the application: `php`, `wordpress`, `woocommerce`,
  `laravel` and `nextjs`. The others need the detected start command or Python module; use
  the runtime that `scalwsctl detect` proposes. Loading a configuration never reads
  application files. The application needs a `root`.
* A managed `node` or `python` runtime without `health_path` gets the profile's.
* The cache-bypass rules are added to the application's `cache` settings (only when
  `cache` is enabled), and the denied paths to its `security.deny_paths` unless
  `security.ignore_profile_rules: true`.

You can keep a profile and still write the runtime yourself, e.g. to point WordPress at an
existing PHP-FPM pool; the profile's rules still apply.

## .htaccess

[`htaccess: true`](https://scalws.com/docs/reference/applications/#tenants-.applications-.htaccess) makes ScalWS read Apache `.htaccess` files from the application's `root`
down to the requested directory ([ADR-0031](https://scalws.com/docs/adr/0031-htaccess/)). It needs the
application `root`: the files are looked up there, not under a runtime's own `root`.

Supported:

* **mod_rewrite**: `RewriteEngine`, `RewriteBase`, `RewriteCond` (server variables such
  as `%{REQUEST_FILENAME}`, `%{HTTP:Header}`, `-f`, `-d`, `-s`, `=`, `<`, `>`, regular
  expressions, `[NC,OR]`), `RewriteRule` with `[L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C]`,
  `$n` and `%n` back-references.
* **mod_alias**: `Redirect`, `RedirectPermanent`, `RedirectTemp`, `RedirectMatch`.
* **Access**: `Require all granted|denied`, `Require [not] ip …`, `Order`/`Allow`/`Deny`,
  also inside `<Files>` and `<FilesMatch>`.
* `ErrorDocument`, `DirectoryIndex`, `Header set|append|unset`, `ExpiresActive`,
  `ExpiresByType`, `ExpiresDefault`, `<IfModule>`, `Options` (no effect: directories are
  never listed).

Behaviour as in Apache: rewrite rules apply from the deepest `.htaccess` that has them, an
internal rewrite restarts rule processing (at most 10 rounds) unless `[END]`, other
directives are merged with deeper files winning. A rewritten path selects the route again.
Edits are noticed within 2 seconds.

Not supported, ignored and logged: proxying (`[P]`), `RewriteMap`, `SetHandler`/
`AddHandler`, `php_value`/`php_flag`/`php_admin_*`, and regular expressions the
linear-time engine cannot run (look-around, back-references inside a pattern; that rule is
inactive). Access rules ScalWS cannot evaluate (HTTP authentication, `Require
valid-user|user|group|host|env`, `<RequireAll>`, `<RequireAny>`, `<Limit>`, `<If>`, …) are
**not** ignored: they deny the directory with `403`, so nothing meant to be protected is
published. Files are limited to 64 KiB and 1000 directives. See
[OPERATIONS.md → .htaccess](https://scalws.com/docs/operations/#htaccess-adr-0031).

## Cache and security

Both are per application and summarised here; the details are in the other guides.

```yaml
cache:
  default_ttl: 60s                  # for 200 responses without caching headers; default: none
  stale_while_revalidate: 30s
  bypass_paths: [/account]
  bypass_cookies: [session_]
  bypass_query: [preview]
security:
  deny_paths: [/backup, "/uploads/*.php"]
  ignore_profile_rules: false
  rate_limits:
    - {path: /login, requests_per_second: 2, burst: 5}
```

* [`cache`](https://scalws.com/docs/reference/applications/#tenants-.applications-.cache) turns on the response cache for the application (`enabled` defaults to `true`
  once the key is present). Only responses that are explicitly cacheable are stored unless
  `default_ttl` is set. See [caching.md](https://scalws.com/docs/guide/caching/).
* [`security`](https://scalws.com/docs/reference/applications/#tenants-.applications-.security) adds denied path patterns (`*` matches any characters) and per-client rate
  limits for matching paths (first match applies, at most 64 rules). See
  [security.md](https://scalws.com/docs/guide/security/).

## Examples

All examples are entries of a tenant's `applications:` list unless shown with `tenants:`;
the short [site file](https://scalws.com/docs/sites/) form follows where one exists.

### WordPress

```yaml
tenants:
  - name: blog
    user: blog
    resources: {cpu: "200%", memory: 2GiB}
    applications:
      - name: blog
        root: /srv/blog
        domains: [blog.example.com]
        acme: true
        https_redirect: true
        profile: wordpress
        htaccess: true          # honour the rules plugins write into .htaccess
        cache: {}
```

The profile supplies the managed PHP-FPM runtime, the WordPress cache bypasses and denied
paths. The same as a site file:

```yaml
# /etc/scalws/sites.d/blog.example.com.yaml
domain: blog.example.com
root: /srv/blog
user: blog
resources: {cpu: "200%", memory: 2GiB}
profile: wordpress
htaccess: true
https_redirect: true
cache: {}
```

With an existing PHP-FPM pool (required under scalws-controller), keep the profile and
give the runtime:

```yaml
domain: blog.example.com
root: /srv/blog
profile: wordpress
php: {fastcgi: "unix:/run/php/php8.3-fpm.sock", front_controller: /index.php}
```

### Laravel

```yaml
- name: shop
  root: /srv/shop               # project root; the profile serves /srv/shop/public
  domains: [shop.example.com]
  profile: laravel
```

Written out, without the profile:

```yaml
- name: shop
  root: /srv/shop
  domains: [shop.example.com]
  runtime:
    type: php
    root: /srv/shop/public
    front_controller: /index.php
    fpm:
      workers: {min: 2, max: 16}
      ini: {memory_limit: 256M}
      env: {APP_ENV: production}
  security:
    deny_paths: [/.env, /storage/logs, /vendor]
```

### Node.js application

```yaml
- name: app
  root: /srv/app
  domains: [app.example.com]
  runtime:
    type: node
    command: [node, server.js]
    env: {NODE_ENV: production}
    workers: {min: 2, max: 6, autoscale: auto}
    health_path: /healthz
  routes:
    - path: /assets
      runtime: {type: static, root: /srv/app/public, precompressed: true}   # serves /srv/app/public/assets/…
```

`server.js` listens on `process.env.PORT`. Site file:

```yaml
domain: app.example.com
root: /srv/app
node: {command: [node, server.js], env: {NODE_ENV: production}, workers: {min: 2, max: 6, autoscale: auto}}
```

or simply `node: "node server.js"` for a single process.

### FastAPI (ASGI)

```yaml
- name: api
  root: /srv/api
  domains: [api.example.com]
  profile: fastapi              # adds health_path /docs and cache bypasses
  runtime:
    type: python
    mode: asgi
    app: "main:app"
    virtualenv: /srv/api/.venv
    workers: {min: 2, max: 8}
```

ScalWS runs `/srv/api/.venv/bin/uvicorn main:app --uds <socket> --workers 2
--no-access-log`. Site file shorthand (uvicorn, one server, defaults otherwise):

```yaml
domain: api.example.com
root: /srv/api
python: "main:app"
```

### Django (WSGI)

```yaml
- name: site
  root: /srv/mysite
  domains: [www.example.org]
  profile: django
  runtime:
    type: python
    mode: wsgi
    app: "mysite.wsgi:application"
    virtualenv: /srv/mysite/.venv
    process_pool: true
    workers: {min: 2, max: 8, autoscale: auto, target_concurrency: 1}
  routes:
    - path: /static              # STATIC_ROOT = /srv/mysite/public/static
      runtime: {type: static, root: /srv/mysite/public}
```

gunicorn serves WSGI. Because route paths are not stripped, the static route's `root` is
the directory that contains `static/`.

### Reverse proxy with an API route

```yaml
- name: web
  domains: [example.com, www.example.com]
  acme: true
  https_redirect: true
  runtime:
    type: proxy
    upstream: "http://127.0.0.1:3000"
  routes:
    - path: /api
      runtime:
        type: proxy
        upstream: "unix:/run/api/api.sock"
        response_timeout: 120s
        websocket: false
    - path: /healthz
      runtime: {type: respond, body: "ok\n"}
```

Site file for the main part:

```yaml
domain: example.com
www: alias
proxy: "http://127.0.0.1:3000"
routes:
  - path: /api
    runtime: {type: proxy, upstream: "unix:/run/api/api.sock", response_timeout: 120s}
```

### Static single-page application

```yaml
- name: spa
  root: /srv/spa/dist
  domains: [spa.example.com]
  htaccess: true
  runtime:
    type: static
    precompressed: true          # serves app.js.br / app.js.gz when the client accepts them
```

The static runtime has no "fall back to `index.html`" setting; client-side routes can be
sent there with a `.htaccess` file in `/srv/spa/dist`:

```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
```

`.htaccess` rules run before routes are chosen: if the site also has routes (for example
an `/api` proxy), exclude them with `RewriteCond %{REQUEST_URI} !^/api(/|$)` before the
`RewriteRule` ([single-page application](https://scalws.com/docs/howto/spa/)).

Site file:

```yaml
domain: spa.example.com
root: /srv/spa/dist
static: {precompressed: true}
htaccess: true
```

### Redirecting an old domain

```yaml
- name: old
  domains: [old-name.example]
  runtime: {type: redirect, to: "https://example.com"}            # 301, path and query kept
```

Site file: `redirect: "https://example.com"`, or
`redirect: {to: "https://example.com/moved", status: 302, keep_path: false}`.
