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 runtimeEvery 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:
- Tenants
- Applications
- Routes
- Runtimes: static, proxy, respond, redirect, php, node, python, container
- How managed runtimes are run
- Profiles and detection
- .htaccess
- 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
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) oroff(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-limitioandscalws-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: wordpressEvery key of an application, with its syntax, default and reload behaviour: reference. Points to know:
namefollows the tenant name rules and is unique within the tenant;domainsneeds at least one host name (see Domains).rootis an absolute path: the default document root ofstaticandphpruntimes, the working directory of managed Node.js/Python processes, and the base for.htaccessand profiles.root_symlinksisfollow(default: a symbolic link in the root’s path is followed when the root is opened) orrefuse: the root (and a runtime’s ownroot) 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.htaccessalike. 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.runtimehandles every request not matched by one of theroutes; it is required unless aprofilesupplies it.acmeneedsserver.acme.enabled(see tls.md);https_redirectanswers plain HTTP with301but still answers ACME HTTP-01 challenges;htaccessneedsroot.cache(caching.md) andsecurity(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-9and-, not starting or ending with-; at most 253 characters in all. Internationalised names must be written in punycode (xn--…). - A wildcard
*.example.comcovers exactly one extra label: it matchesa.example.com, but notexample.comand nota.b.example.com. Listexample.comseparately if it should be served too. The suffix must have at least two labels (*.comis 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 andlocalhostare 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:
/apimatches/apiand/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/xgoes to port 8082), whatever the order in the file. Requests matched by no route go to the application’sruntime; a route withpath: /replaces it. - Matching uses the normalised path (percent-decoding,
./..resolved, repeated slashes collapsed), so/%61pior/x/../apicannot bypass a route. - The path is not stripped: a request for
/api/usersreaches the route’s runtime as/api/users. For astaticroute,rootis therefore the directory that contains the prefix directory. pathmust 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:
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:
phpwithfastcgi:for PHP, and aproxyruntime for Node.js, Python and containers. The controller also refusesnodeandpythonruntimes that useupstream:; usetype: proxyinstead.
static
runtime:
type: static
root: /srv/www/example.com/public # default: the application root
index: [index.html] # default
precompressed: true # default: falseEvery 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: trueEvery 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):
| 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.
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.phpEvery 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):
- A path segment ending in a PHP suffix (
.php,.phtml,.php3to.php8,.inc, any case) that names an existing script goes to PHP; the rest of the path becomesPATH_INFO. Anindexfile with such a suffix is a script too. - An existing non-PHP file, or a directory with an
indexfile, is served as a static file. Files with a PHP suffix,.pharand.phpsare never sent as source (404). - Otherwise the
front_controllerhandles the request, if it is set and exists. - Otherwise
404.
fpm (managed pool):
binaryis an absolute path; by defaultphp-fpmonPATHor in the usual sbin/bin directories, else the newest versioned one there (php-fpm8.4on Debian and Ubuntu,php-fpm84on Alpine). Its version (<binary> -v) is asked when the pool starts; checking a configuration (scalwsctl check,scalwsd --check) never runs it.workersdefaults to{min: 2, max: 8};min == maxgivespm = static, otherwisepm = dynamic.inikeys use[A-Za-z0-9_.]; values[A-Za-z0-9 _-.,/:+@%=*], at most 1024 bytes.envnames match[A-Za-z_][A-Za-z0-9_]*and must not start withSCALWS_; 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: /healthzEvery 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:
| 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):
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: trueThe schema is marked as provisional in the code and may still change.
Every key of the container runtime: reference.
Rules not shown there:
imageis required:[A-Za-z0-9./:@_-], at most 255 characters, not starting with-.engine_pathis an absolute path.- With
network: bridge(the default),portis required and published on a loopback port chosen by ScalWS; withhost,portis refused and the application listens on the port given asPORT(or{port}incommand). - Without
commandoruser, the image’s defaults apply;useris a user name oruid[:gid]. Withoutworkers, one container runs. mountstake 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
rootas 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_SOCKETorHOST,SCALWS_WORKER) and yourenv. - Places it in the application’s cgroup before it runs (when cgroups are enforced),
as the tenant’s
userif set, without any of scalwsd’s capabilities and withno_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 withinstartup_timeoutis 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 --jsondetect 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: does in a configuration:
- An unknown profile name is an error.
- When
runtimeis omitted, the profile supplies it, but only profiles whose template needs nothing found by inspecting the application:php,wordpress,woocommerce,laravelandnextjs. The others need the detected start command or Python module; use the runtime thatscalwsctl detectproposes. Loading a configuration never reads application files. The application needs aroot. - A managed
nodeorpythonruntime withouthealth_pathgets the profile’s. - The cache-bypass rules are added to the application’s
cachesettings (only whencacheis enabled), and the denied paths to itssecurity.deny_pathsunlesssecurity.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]),RewriteRulewith[L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C],$nand%nback-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}cacheturns on the response cache for the application (enableddefaults totrueonce the key is present). Only responses that are explicitly cacheable are stored unlessdefault_ttlis set. See caching.md.securityadds 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: laravelWritten 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 themThe 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: trueRedirecting an old domain
- name: old
domains: [old-name.example]
runtime: {type: redirect, to: "https://example.com"} # 301, path and query keptSite file: redirect: "https://example.com", or
redirect: {to: "https://example.com/moved", status: 302, keep_path: false}.