Getting started

Files, directories and services

Reference for everything the scalws package puts on a system and for the day-to-day mechanics: where configuration and state live, the systemd units and their sandbox, logs, reloads, backups and upgrades. Installation itself is covered in installation.

Configuration: /etc/scalws

/etc/scalws is 0750 root:scalws: the server (group scalws) can read it, only root can write it.

PathOwner, modeContent
/etc/scalws/scalws.yamlroot:scalws 0640Server configuration: listeners, limits, ACME, tenants and applications. Installed with ports 80 and 443, ACME and the welcome page (first start); never replaced on upgrade. See configuration.
/etc/scalws/controller.yamlroot:scalws 0640scalws-controller configuration: state directory, control socket, instance count and CPUs, autoscaling. Never replaced on upgrade. Reference: docs/adaptive-runtime.md.
/etc/scalws/sites.d/root:scalws 0750One file per site, included by include: sites.d/*.yaml (site files).
/etc/scalws/console/root:root 0700credentials: console accounts (0600).
/etc/scalws/tls/not created by the packageConventional place for certificate and key files you manage yourself (used by the example configuration).

scalws.yaml

The packaged file:

# Default configuration installed by the scalws package: ready to publish sites.
#
#   1. Point your domain's DNS (A, and AAAA if the server has IPv6) at this server.
#   2. sudo scalwsctl site add example.com
#      (static files in /srv/www/example.com; --php-fastcgi, --proxy, --redirect: see
#      `scalwsctl site add --help` and https://scalws.com/docs/sites/)
#
# Each site gets a Let's Encrypt certificate automatically (server.acme below). Ports 80
# and 443 must be reachable from the internet; with ufw: sudo ufw allow 'ScalWS'.
# Full reference: /usr/share/doc/scalws/examples/scalws.yaml and https://scalws.com/docs/
include: sites.d/*.yaml
server:
  listeners:
    - name: http
      address: "0.0.0.0:80"
    - name: https
      address: "0.0.0.0:443"
      tls:
        acme: true
    # IPv6 (the package comments these out on hosts without IPv6).
    - name: http6
      address: "[::]:80"
    - name: https6
      address: "[::]:443"
      tls:
        acme: true
  metrics:
    address: "127.0.0.1:9900"
  # Certificates from Let's Encrypt for every site with `tls: auto` (the default while this
  # is enabled). Using it accepts the Let's Encrypt Subscriber Agreement
  # (https://letsencrypt.org/repository/). Add `contact: ["mailto:[email protected]"]` to get
  # expiry notices.
  acme:
    enabled: true
    terms_of_service_agreed: true
  # Admin API (scalwsctl): /run/scalws/admin.sock, group scalws.
tenants:
  # Answers every host name that no site serves (the server's IP address, a domain whose
  # site is not added yet) with a short page. Remove it to answer such requests with 404.
  - name: default
    applications:
      - name: welcome
        domains: ["*"]
        root: /usr/share/scalws/welcome
        runtime: {type: static, index: [index.html]}

The welcome page is /usr/share/scalws/welcome/index.html. On a host without IPv6 the package comments out the http6 and https6 listeners on installation.

Every section rejects unknown keys, so a typo fails the check instead of being ignored. The fully commented reference is /usr/share/doc/scalws/examples/scalws.yaml.

Site files

include: takes a path or a list of paths, relative to the directory of scalws.yaml; an entry may have one * in the file name. Only the main file may include. The rules the loader enforces:

  • files are read in name order; names starting with . and files not matching the pattern are ignored (editor backups, site remove backups);
  • each site file and the directory holding it must belong to root (or to the owner of the main configuration file) and must not be writable by group or others. Whoever can write there can serve any domain from any directory the server can read;
  • a domain may be served once across all files.

scalwsctl site add writes files as root, mode 0640, group scalws. site remove renames a file to .<name>.removed-<time>; migrate replaces a file only after keeping it as .<name>.replaced-<time>.

Certificate files

Certificates you manage yourself are referenced from a TLS listener (tls: {certificates: [{cert, key, server_names, default}]}, see TLS). The server runs as scalws, so it needs read access to them, for example:

sudo install -d -m 0750 -o root -g scalws /etc/scalws/tls
sudo install -m 0640 -o root -g scalws example.com.pem example.com.key /etc/scalws/tls/

State: /var/lib

PathOwner, modeContent
/var/lib/scalwsscalws:scalws 0750Home of the scalws user; default parent of the ACME state.
/var/lib/scalws/acmewritten by the serverserver.acme.state_dir: account.json, certs/<tenant>/<app>/{cert.pem,key.pem,meta.json}, challenges/, order.lock. Shared by all instances under the controller (one of them orders, the others wait).
/var/lib/scalws-controllerscalws:scalwsController state (controller.state_dir): generations/ (every configuration it applied, with site files expanded into it), instances/, logs/ (one log per instance, web-<n>.log), shares/ (limit-share, tenant-limits.json: what the instances read), quic-lb/ (the QUIC demux socket).
/var/lib/scalws-consoleroot:root 0700Console state: audit.jsonl (append-only audit log, 0600), history/ (every configuration applied or found), tls/ (generated certificate and key, 0600).

Because the controller stores each generation with the site files expanded, a rollback to an earlier generation restores exactly the sites of that generation, even after site files changed.

Runtime directories

PathContent
/run/scalwsserver.runtime_dir (default /run/scalws): sockets and generated configuration of managed runtimes. Under scalwsd.service it is mode 0750 and holds admin.sock.
/run/scalws/admin.sockAdmin API of scalwsd.service (mode 0660 scalws:scalws), used by scalwsctl. Members of group scalws may use it; every change is logged under scalws::audit with the caller’s uid, gid and pid.
/run/scalws/web-<n>/Under the controller: each instance’s own runtime directory, with its admin.sock.
/run/scalws-controller/control.sockController control API (mode 0600, user scalws): `scalws-controller status

scalwsctl finds the admin socket from scalws.yaml (server.admin.socket, default <runtime_dir>/admin.sock). Under the controller that path does not exist; name an instance’s socket for read commands, for example:

sudo scalwsctl --socket /run/scalws/web-0/admin.sock status

scalwsctl reload, site, migrate, limit and high discover a running controller themselves through /etc/scalws/controller.yaml and its control socket (--controller-config to change the file, except for reload) and reload or apply limits through it.

Metrics and health

ServerAddress
scalwsd.serviceserver.metrics.address: 127.0.0.1:9900 in the packaged configuration (127.0.0.1:9901 when the key is omitted).
scalws-controllerInstance i listens on 127.0.0.1:(metrics_base_port + i), default 9910, 9911, …

Each serves /metrics (Prometheus), /healthz, read-only views and a read-only dashboard at /dashboard. Keep these on loopback.

Binaries

PathPurpose
/usr/bin/scalwsdThe web server. scalwsd --config FILE [--check].
/usr/bin/scalwsctlOperator CLI (CLI).
/usr/bin/scalws-limitcpu, scalws-limitram, scalws-limitio`scalwsctl limit cpu
/usr/bin/scalws-highcpu, scalws-highram, scalws-highio`scalwsctl high cpu
/usr/bin/scalws-controllerAdaptive Runtime controller.
/usr/bin/scalws-consoleWeb console.

systemd units

Two units run the web server and exclude each other; enable one. The console is independent. When to use which: installation.

scalwsd.service

User=scalws
Group=scalws
ExecStartPre=/usr/bin/scalwsd --config /etc/scalws/scalws.yaml --check
ExecStart=/usr/bin/scalwsd --config /etc/scalws/scalws.yaml --restrict-suid-sgid
ExecReload=/bin/kill -HUP $MAINPID
TimeoutStopSec=45
Restart=on-failure
LimitNOFILE=1048576
Delegate=yes
KillMode=control-group
RuntimeDirectory=scalws
RuntimeDirectoryMode=0750
StateDirectory=scalws
StateDirectoryMode=0750
  • The configuration is checked before every start; a broken file keeps the unit from starting instead of failing half-way.
  • Delegate=yes: the server manages a cgroup v2 subtree for tenant limits. It moves itself to <unit>/server and places applications in <unit>/tenants/<tenant>/<app>. Stopping the unit kills the whole control group, including application processes.
  • Application processes never inherit the server’s capabilities and receive SIGTERM if scalwsd dies.

scalws-controller.service

User=scalws
Group=scalws
ExecStart=/usr/bin/scalws-controller run --config /etc/scalws/controller.yaml --restrict-suid-sgid
Conflicts=scalwsd.service
KillMode=process
Restart=on-failure
LimitNOFILE=1048576
Delegate=yes
DelegateSubgroup=controller
RuntimeDirectory=scalws-controller scalws
RuntimeDirectoryPreserve=yes
StateDirectory=scalws-controller scalws
StateDirectoryMode=0750
  • KillMode=process: stopping or restarting the unit stops only the controller. Its scalwsd instances keep serving and a restarted controller adopts them. To take the whole service down:

    sudo systemctl stop scalws-controller
    sudo pkill -TERM -f 'scalwsd .*--instance-id'

    (scalws-controller scale 0 is not allowed.)

  • DelegateSubgroup=controller (systemd 254 or later): the controller runs in <unit cgroup>/controller, so instances can enable the cgroup controllers for tenant limits in the unit’s group. Older systemd (EL8, EL9, Debian 12, Ubuntu 22.04) starts the controller in the unit’s group itself, which cgroup v2 refuses while instances there use those controllers. On such a system the package adds /usr/lib/systemd/system/scalws-controller.service.d/10-systemd-before-254.conf with KillMode=control-group: the instances stop with the controller, and a restart starts new ones (a short interruption instead of a handover).

  • Instances are started with taskset on their CPU slice and inherit the right to bind ports 80 and 443.

scalws-console.service

ExecStart=/usr/bin/scalws-console run \
    --controller-config /etc/scalws/controller.yaml \
    --credentials /etc/scalws/console/credentials \
    --state-dir /var/lib/scalws-console \
    --listen 0.0.0.0:8090 \
    --controller-restart-command "systemctl restart scalws-controller"
After=network-online.target scalws-controller.service
Restart=on-failure
UMask=0077
Nice=10
CPUWeight=20
MemoryMax=256M

It runs as root, because it must reach the controller socket and replace /etc/scalws/scalws.yaml atomically with its owner and mode, with a reduced capability set and a read-only file system except /etc/scalws and /var/lib/scalws-console. It starts after the controller but does not depend on it. Its low CPU weight and memory limit keep it from competing with the web server. Options: console operations.

Sandbox summary

Settingscalwsdscalws-controllerscalws-console
Userscalwsscalwsroot
CapabilitiesCAP_NET_BIND_SERVICE (ambient and bounding)CAP_NET_BIND_SERVICE (ambient and bounding)bounding set CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER
NoNewPrivilegesyesyesyes
ProtectSystemstrictstrictstrict (ReadWritePaths=/etc/scalws /var/lib/scalws-console /srv/www /home)
ProtectHomeread-onlyread-onlyread-only (/home writable, /root and /run/user not)
PrivateTmpyesyesyes
PrivateDevicesyes–yes
ProtectKernelTunablesyes–yes
ProtectKernelModulesyesyesyes
ProtectControlGroupsno (would break delegation)–yes
RestrictNamespacesyes–yes
RestrictSUIDSGID--restrict-suid-sgid instead (below)--restrict-suid-sgid instead (below)yes
LockPersonalityyesyesyes
MemoryDenyWriteExecuteno (Node.js V8 and PHP’s opcache JIT need it)–yes
SystemCallArchitectures=nativeyesyesyes
SystemCallFilter––@system-service and pidfd_open; other calls fail with ENOSYS
ReadOnlyPaths=/srvyesyes–

Consequences:

  • Document roots under /home (for example /home/<user>/public_html) can be served: /home is read-only for the server. User scalws needs search (x) permission on every directory on the way and read permission on the files; scalwsctl site add warns when the mode bits do not allow it. /root and /run/user are not visible.

  • /srv is read-only for the server and for managed applications. If an application started by scalwsd.service must write there, add a drop-in:

    # /etc/systemd/system/scalwsd.service.d/writable.conf
    [Service]
    ReadWritePaths=/srv/www/blog/wp-content

    then sudo systemctl daemon-reload && sudo systemctl restart scalwsd.

  • scalwsd.service and scalws-controller.service start their binaries with --restrict-suid-sgid instead of setting RestrictSUIDSGID=: the same seccomp rules (no set-user-ID or set-group-ID mode on a file or directory created by open, openat, creat, mkdir, mkdirat, mknod or mknodat, or set by chmod, fchmod, fchmodat or fchmodat2), inherited by the instances, the QUIC demux and the applications, but openat2 stays available. systemd cannot read openat2’s mode and refuses the whole call, which would leave the static files without the kernel’s RESOLVE_BENEATH lookups (every instance would log openat2 unavailable). The one creation the rules cannot see is openat2 with O_CREAT and such a mode (see security).

  • Running tenants’ processes as their own Unix users (tenants[].user) needs four more capabilities in a drop-in; see security and operations.

Never edit the unit files in /usr/lib/systemd/system; upgrades replace them. Use drop-ins (sudo systemctl edit scalwsd).

Kernel setting: /usr/lib/sysctl.d/90-scalws-adaptive-runtime.conf

net.ipv4.tcp_migrate_req = 1

When a scalwsd instance leaves a SO_REUSEPORT group, connections still queued on its listening sockets move to another instance instead of being reset (Linux 5.14 or later). It matters for the controller’s listen: reuseport mode. The package applies it on install and systemd loads it at every boot. Override it, if needed, with a file of the same name in /etc/sysctl.d/.

Logs

All three units write to the journal:

journalctl -u scalwsd -f
journalctl -u scalws-controller -f
journalctl -u scalws-console -f

Under the controller, each instance’s output goes to its own file instead: /var/lib/scalws-controller/logs/web-<n>.log. The packages rotate these files with /etc/scalws-logrotate-instances.conf: daily, and within the hour once a file passes 100 MB, keeping 7 (compressed). Only the hourly run reads that file, with a state file of its own (it is not in /etc/logrotate.d, so the system’s daily run leaves these logs alone): scalws-logrotate.timer on systemd hosts and /etc/periodic/hourly/scalws-logrotate on Alpine (crond’s periodic jobs; rc-update add crond if it is not in a runlevel). The package enables the timer once; systemctl disable --now scalws-logrotate.timer stays across upgrades. The deb and rpm recommend logrotate, the apk depends on it; without it the files grow until the file system is full. Under another controller.state_dir, change the path in /etc/scalws-logrotate-instances.conf (it keeps su scalws scalws). The file lies outside /etc/scalws on purpose: logrotate runs as root and runs the scripts of its configuration, and the console (root) may write everything in /etc/scalws.

scalwsd logs JSON lines by default, including one access-log line per request. Settings in server.logging:

KeyDefaultMeaning
formatjsonjson or text.
levelinfotracing filter, e.g. info,scalws_proxy=debug.
access_logtrueLog every request.

Useful log targets: scalws::access (requests), scalws::audit (admin API changes), scalws::acme (certificates). The console keeps its own audit log in /var/lib/scalws-console/audit.jsonl.

How reload works

A reload validates the configuration (with all site files), prepares it (certificates, document roots, runtimes) and swaps it in atomically. If anything fails, the active configuration keeps serving and the error is reported and logged. Run the same check without touching the server with:

sudo scalwsctl check          # alias: scalwsctl config-test
ServerReloadWhat happens
scalwsd.servicescalwsctl reload, sudo systemctl reload scalwsd (SIGHUP)In-process swap. Changes to listeners, the metrics/admin endpoints, connection limits, cgroups, cache memory, per-IP limits or the AI advisor are refused with “requires a restart”: sudo systemctl restart scalwsd.
scalws-controllersudo scalws-controller reloadThe controller validates scalws.yaml as a new generation, binds any new listening addresses first, then rolls instances onto it and drains the old ones. A bind failure rejects the reload and leaves the current sockets as they were.
ConsoleApply in the browserThe console validates the draft (with the site files), writes scalws.yaml and reloads through the controller, then waits up to 120 s (--health-timeout-secs) for the new generation.

scalwsctl site add|remove, migrate and limit --persist reload for you, through the controller when it runs.

Under scalwsd.service the server keeps earlier configurations for comparison and rollback:

scalwsctl history
scalwsctl diff                       # previous -> active
scalwsctl rollback                   # back to the previous one (or --generation G)

Backup and restore

scalwsctl backup copies the configuration file, its site files and the files they reference (TLS certificates and keys, tls_ca_file bundles) into a new directory with a manifest.json:

sudo scalwsctl backup /var/backups/scalws-$(date +%F)
sudo scalwsctl restore /var/backups/scalws-2026-10-05            # dry run: what would change
sudo scalwsctl restore /var/backups/scalws-2026-10-05 --apply
  • The target directory must not exist or must be empty. Files are stored under files/<absolute path>.
  • restore without --apply only lists each file as unchanged, changed or new. With --apply it keeps every replaced file as <file>.bak.<unix time>, writes the restored files with their original mode (and the replaced file’s owner), then checks the restored configuration. Apply it with scalwsctl reload or sudo scalws-controller reload.

What scalwsctl backup does not contain: ACME certificates and the account in /var/lib/scalws/acme, controller.yaml, console state and credentials, document roots and application data. For a full configuration backup, also archive the directories:

sudo tar -C / -czf /var/backups/scalws-files-$(date +%F).tar.gz \
    etc/scalws var/lib/scalws var/lib/scalws-console

Upgrades

Upgrading the package, what happens to running services, and rollback: Upgrading.

See also