Users' guide

Tenants, applications and runtimes

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

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 and configuration.md).

Contents:

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

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 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.

Tenant user

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 and ADR-0026. 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 are optional; an unset field means no limit. Every resource key, with the cgroup file it sets, its syntax and reload behaviour: reference.

Rules not shown there: 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):

  • 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.
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

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. Points to know:

  • name follows the tenant name rules and is unique within the tenant; domains needs at least one host name (see Domains).
  • 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 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 sets it on every account site). Files below the root never lead outside it, with either value.
  • runtime handles every request not matched by one of the routes; it is required unless a profile supplies it.
  • acme needs server.acme.enabled (see tls.md); https_redirect answers plain HTTP with 301 but still answers ACME HTTP-01 challenges; htaccess needs root.
  • cache (caching.md) and security (security.md) 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

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 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 of its own and is validated like the application’s runtime. Every key of a route: reference.

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 and route policies.

Runtimes

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

typeServes
staticFiles from a directory.
proxyAn HTTP/1.1 upstream (TCP, TLS or Unix socket).
respondA fixed response.
redirectA redirect to another origin.
phpPHP through FastCGI: a managed PHP-FPM pool or an existing one.
nodeA Node.js process managed by ScalWS, or an external one.
pythonAn ASGI/WSGI server managed by ScalWS, or an external one.
containerAn 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

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. The document root is absolute; it or the application root is required. index takes plain file names only. With 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

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. upstream (or backends) is required; connect_timeout and response_timeout must be above zero; client is hyper or experimental (ADR-0045).

Upstream formats (HTTP/1.1 in every case):

FormExampleNotes
http://host[:port]http://127.0.0.1:3000, http://[::1]:8080, http://app.internalPort defaults to 80.
https://host[:port]https://api.example.comPort defaults to 443; the host is the TLS server name.
unix:/absolute/pathunix:/run/app/app.sockUnix 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.

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

respond

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

Every key of the respond runtime: reference. content_type must be printable ASCII.

redirect

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

Every key of the redirect runtime: reference. to is required: an http:// or https:// URL with a host and no user information. With 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.

# 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. fastcgi and fpm exclude each other. The document root is absolute; it or the application root is required. index takes plain file names. 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 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 defaults to {min: 2, max: 8}; min == max gives pm = static, otherwise pm = dynamic.
  • ini keys use [A-Za-z0-9_.]; values [A-Za-z0-9 _-.,/:+@%=*], at most 1024 bytes.
  • 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

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.

Exactly one of command (managed mode, e.g. [node, server.js] or [npm, start]) and upstream (same formats as proxy.upstream) is required. env names match [A-Za-z_][A-Za-z0-9_]*; the SCALWS_ prefix is reserved. Without workers, one process runs. Use listen: tcp for frameworks that only accept a port number. 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

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

Every key of the python runtime: reference.

mode (asgi or wsgi) is required. server is uvicorn, hypercorn or gunicorn; WSGI with app needs gunicorn. 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, command and upstream is required. With app, ScalWS builds the command line:

modeserverCommand
asgiuvicornuvicorn APP --uds SOCK [--workers N] --no-access-log
asgihypercornhypercorn APP --bind unix:SOCK [--workers N]
asgigunicorngunicorn APP -k uvicorn.workers.UvicornWorker --bind unix:SOCK [--workers N]
wsgigunicorngunicorn 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):

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

Without 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

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. Rules not shown there:

  • image is required: [A-Za-z0-9./:@_-], at most 255 characters, not starting with -.
  • engine_path is an absolute path.
  • With network: bridge (the default), 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, one container runs.
  • 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 and ADR-0020.

Workers

node, python, container and php.fpm take the same workers map (keys for node: reference; the other runtimes have the same keys on their reference pages). min and max are required, with 1 ≤ min ≤ max ≤ 256. With autoscale: recommend (the default) decisions are only logged. 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):

  • 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) bundles what a framework needs: a runtime template, a health check, cache-bypass rules and denied paths.

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.

ProfileRuntime templateHealth pathAdds
phpphp, managed pool 2–8, front_controller: /index.php/denies /composer.json, /composer.lock, /vendor
wordpressphp, pool 2–16, front_controller: /index.php, memory_limit: 256M, uploads 64M, max_execution_time: 120/wp-login.phpcache 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
woocommerceas WordPress, pool 4–32, memory_limit: 512M, max_execution_time: 180/wp-login.phpWordPress rules plus cart/checkout/account paths, wc-ajax/add-to-cart queries and WooCommerce cookies; denies /wp-content/uploads/wc-logs
laravelphp with root <root>/public, pool 2–16, front_controller: /index.php, memory_limit: 256M/upcache bypass for laravel_session, XSRF-TOKEN; denies /.env, /storage/logs, /vendor
nodenode, the detected start command, NODE_ENV=production/denies /node_modules, /.env, /package-lock.json
nextjsnode, npm run start, listen: tcp, NODE_ENV=production, startup_timeout: 60s/cache bypass for /api, /_next/data; denies /.next/cache, /.env, /.env.local
pythonpython wsgi, the detected callable, pool 2–8/n/a
djangopython wsgi, the detected WSGI module, 2–8 workers/cache bypass for /admin and sessionid, csrftoken; denies /manage.py, /db.sqlite3
flaskpython wsgi, the detected Flask(...) object/cache bypass for the session cookie
fastapipython asgi, the detected FastAPI(...) object/docscache bypass for /docs, /openapi.json

What 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 makes ScalWS read Apache .htaccess files from the application’s root down to the requested directory (ADR-0031). 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.

Cache and security

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

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 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.
  • 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.

Examples

All examples are entries of a tenant’s applications: list unless shown with tenants:; the short site file form follows where one exists.

WordPress

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:

# /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:

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

Laravel

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

Written out, without the profile:

- 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

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

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)

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

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

Django (WSGI)

- 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

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

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

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

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).

Site file:

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

Redirecting an old domain

- 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}.