Getting started

Installation

This guide installs ScalWS from the signed apt, dnf, zypper or apk repository, explains what the package puts on the system, and gets the server answering on ports 80 and 443. For your first site continue with the quick start; for every file and unit the package installs see files, directories and services.

Kubernetes: container image and Helm chart, see Kubernetes.

Requirements

  • One of the supported systems below, on x86_64 (amd64).
  • systemd, or OpenRC on Alpine Linux. With systemd 254 or later (and on OpenRC) a restart of scalws-controller hands the running instances over; with older systemd the instances restart with it (systemd before 254).
  • cgroup v2, if you want per-tenant resource limits (cgroup v1).
  • For managed PHP: PHP-FPM 7.2 or later (PHP on EL8) on PATH: php-fpm, else the newest versioned binary (php-fpm8.4; on Alpine php-fpm84). For Node.js or Python applications: the runtime and, for Python, the application server (uvicorn, hypercorn or gunicorn).

Supported systems

SystemVersionsPackageNotes
AlmaLinux8, 9, 10.rpm, dnf repository8: cgroup v1 by default. 8, 9: systemd before 254. 10: the x86_64_v2 build is fully tested; the standard (x86-64-v3) build: see EL10.
Rocky Linux8, 9, 10.rpm, dnf repository8: cgroup v1 by default. 8, 9: systemd before 254. 10: see EL10.
Oracle Linux8, 9, 10.rpm, dnf repository8: cgroup v1 by default. 8, 9: systemd before 254. 10: see EL10.
Fedora44.rpm, dnf repositorySee Fedora 44 and systemd 259.5.
Debian12, 13.deb, apt repository12: systemd before 254.
Ubuntu22.04, 24.04, 26.04.deb, apt repository22.04: systemd before 254. 26.04: see the panel matrix.
openSUSE Leap16.0, 15.6.rpm, zypper repository16.0: SELinux enforcing. 15.6: runs unconfined (SUSE).
SUSE Linux Enterprise Server16.0, 15 SP7.rpm, zypper repository16.0: SELinux enforcing. 15 SP7: runs unconfined. See SUSE.
Alpine Linux3.24.apk, apk repositoryOpenRC instead of systemd, musl instead of glibc: see Alpine Linux. No control panel supports Alpine.

One build serves all of them except Alpine: the binaries are linked against glibc 2.28. The .deb depends on libc6 (>= 2.28) and adduser. The .rpm depends on glibc >= 2.28, shadow-utils or shadow (SUSE), /usr/bin/setpriv (util-linux) and /usr/bin/awk, and recommends policycoreutils (for the SELinux policy module).

On a server with cPanel, DirectAdmin or Plesk, see Control panels for the panel and system matrix.

SUSE

openSUSE Leap 16.0 and SLES 16.0 enable SELinux on new installations: ScalWS runs confined under the same policy module as on the RHEL family (SELinux). Leap 15.6 and SLES 15 SP7 use AppArmor or no security module: the package loads no SELinux module there, ScalWS ships no AppArmor profile and runs unconfined, as on Debian and Ubuntu. The same holds for a Leap 16 server switched to AppArmor. All four use cgroup v2 and systemd 254 or later. SLES registration (for SUSE’s own update repositories) is between you and SUSE: the ScalWS repository needs none. SLES was tested with SUSE’s free SLE Base Container Images, and SELinux on a Leap 16 virtual machine, which is built from the SLES 16 sources. On SUSE 15 the first-install firewall opening was not tested on a virtual machine (containers only).

EL10

The userlands of Rocky Linux 10, Oracle Linux 10 and the standard AlmaLinux 10 need an x86-64-v3 CPU (AVX2). That is the distribution’s requirement, not ScalWS’s. These three were tested under CPU emulation; full verification follows in the next release. AlmaLinux 10’s x86_64_v2 build is fully tested and also runs on older CPUs.

Install from the apt repository

Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04. Run as root:

FPR=0B49CE4488DC3A3E4F631218CBBBF99C69B99EF0
apt-get install -y curl gnupg
key=$(mktemp)
curl -fsSL https://apt.scalws.com/scalws-archive-keyring.gpg -o "$key"
# The file must hold exactly one primary key, the pinned one: stop if this says WRONG.
test "$(gpg --show-keys --with-colons "$key" \
    | awk -F: '$1=="pub"{p=1;next} p&&$1=="fpr"{print $10;p=0}')" = "$FPR" \
    && echo "repository key OK" || echo "WRONG repository key: stop here"
# Install only that key, re-exported from a scratch keyring (never the downloaded file).
export GNUPGHOME=$(mktemp -d)
gpg --quiet --import "$key"
gpg --export "$FPR" > /usr/share/keyrings/scalws-archive-keyring.gpg
rm -rf "$GNUPGHOME" "$key"; unset GNUPGHOME
echo 'deb [signed-by=/usr/share/keyrings/scalws-archive-keyring.gpg] https://apt.scalws.com stable main' \
    > /etc/apt/sources.list.d/scalws.list
apt update && apt install scalws

The repository is signed with the “ScalWS Archive Signing Key”, fingerprint 0B49 CE44 88DC 3A3E 4F63 1218 CBBB F99C 69B9 9EF0. ScalWS is not in the distributions’ own archives.

Install from the dnf repository

AlmaLinux, Rocky Linux and Oracle Linux 8, 9 and 10, Fedora 44. Run as root:

curl -o /etc/yum.repos.d/scalws.repo https://rpm.scalws.com/scalws.repo
dnf install scalws

The repository file turns on both checks: gpgcheck=1 (every package is signed) and repo_gpgcheck=1 (the repository metadata, repomd.xml, is signed). The key is https://rpm.scalws.com/RPM-GPG-KEY-scalws, the same “ScalWS Archive Signing Key” as the apt repository. dnf asks once to import it: compare the fingerprint it shows with 0B49 CE44 88DC 3A3E 4F63 1218 CBBB F99C 69B9 9EF0 before you answer y.

Install from the zypper repository

openSUSE Leap 16.0 and 15.6, SLES 16.0 and 15 SP7: the same repository and packages as for dnf. Import the key first, after checking its fingerprint, so zypper never has to ask about it. Run as root:

FPR=0B49CE4488DC3A3E4F631218CBBBF99C69B99EF0
key=$(mktemp)
curl -fsSL https://rpm.scalws.com/RPM-GPG-KEY-scalws -o "$key"
# The file must hold exactly one primary key, the pinned one; anything else is not imported.
if test "$(gpg --show-keys --with-colons "$key" \
    | awk -F: '$1=="pub"{p=1;next} p&&$1=="fpr"{print $10;p=0}')" = "$FPR"; then
    rpm --import "$key" && echo "repository key OK"
else
    echo "WRONG repository key: not imported, stop here"
fi
rm -f "$key"
zypper addrepo https://rpm.scalws.com/scalws.repo
zypper install scalws

The repository file turns on both signature checks (packages and metadata) and autorefresh, so zypper up sees new releases. Do not answer a key question from zypper with “trust always”, and do not use --gpg-auto-import-keys: with the key imported as above, zypper asks nothing. If it does ask, the key is not the one above: stop.

Alpine Linux

Alpine Linux 3.24 (x86_64), installed to disk (setup-alpine, “sys” mode). The .apk carries a static build of the same four programs (linked against musl, so it needs no C library on the system) and OpenRC services instead of the systemd units. Run as root:

KEY=[email protected]
SUM=d809d59bdec7e7e440558fd4b648a48dfbc69c96ee0bbc21dc853808d516abc1
key=$(mktemp)
wget -qO "$key" "https://apt.scalws.com/alpine/$KEY"
# The key is trusted only if its SHA-256 is the pinned one; anything else is not installed.
if echo "$SUM  $key" | sha256sum -c -s; then
    mv "$key" "/etc/apk/keys/$KEY" && chmod 0644 "/etc/apk/keys/$KEY" \
        && { grep -qxF https://apt.scalws.com/alpine/v3.24/main /etc/apk/repositories \
            || echo https://apt.scalws.com/alpine/v3.24/main >> /etc/apk/repositories; } \
        && apk update && apk add scalws
else
    rm -f "$key"
    echo "WRONG repository key: not installed, nothing added; stop here"
fi

apk checks the signature of the repository index and of every package against the keys in /etc/apk/keys: an index or a package that was not signed with this key, or was changed afterwards, is refused. The key file must keep its name (apk finds a key by the name recorded in the signature). The packages and the index are signed with RSA and SHA-256.

apk add scalws also installs openrc, setpriv, util-linux-misc (taskset), iproute2-minimal, iproute2-ss and ca-certificates-bundle. As on the other systems, the first install starts the server on ports 80 and 443 with the welcome page and ACME, and the console on port 8090: it adds scalws-controller and scalws-console to the default runlevel and starts them (Alpine’s own packages start nothing; ScalWS does, as everywhere else). To undo that:

rc-service scalws-controller shutdown && rc-update del scalws-controller
rc-service scalws-console stop && rc-update del scalws-console

Alpine has no sudo by default; the messages say doas.

OpenRC services

ServiceCommands
scalws-controller (default)rc-service scalws-controller start, stop, restart, status; rc-service scalws-controller shutdown stops the controller and drains the instances
scalwsd (single process, for managed runtimes)rc-service scalwsd start, stop, reload (validated, applied without a restart)
scalws-consolerc-service scalws-console start, stop, restart

scalws-controller and scalwsd refuse to start while the other one runs. To switch to scalwsd:

rc-service scalws-controller shutdown && rc-update del scalws-controller
rc-update add scalwsd default && rc-service scalwsd start

Settings (configuration file, the console’s address, the drain time) are in /etc/conf.d/scalws-controller, /etc/conf.d/scalwsd and /etc/conf.d/scalws-console.

What the systemd units do, and how the OpenRC services do it:

systemd featureOn Alpine (OpenRC)Status
Socket handover across controller restarts (ADR-0037)Same code: stop and restart signal only the controller; the instances keep serving and the restarted controller adopts them. The QUIC demux (HTTP/3) is stopped with the controller, on systemd too (ExecStopPost=), so HTTP/3 is no longer answered; the running instances keep their UDP 443 sockets (bound, not answering QUIC) until the controller starts again or they end (rc-service scalws-controller shutdown)full
Stopping everythingrc-service scalws-controller shutdown, and stop when the system shuts down or reboots: every process in ScalWS’s cgroup (/sys/fs/cgroup/scalws, whatever group it is in) and every instance the controller recorded drains (SIGTERM, then SIGKILL, cgroup.kill on the whole group, after 30 seconds), as KillMode= on the unit’s group; apk del names any process still in that groupfull
cgroup v2 delegation for tenant limitsThe service creates /sys/fs/cgroup/scalws, hands it to user scalws and starts the controller in scalws/controller; scalwsd uses /sys/fs/cgroup/scalwsd. Both need OpenRC’s cgroups service, which they startfull
Restart=on-failuresupervise-daemon restarts a service 2 seconds after it fails, at most 5 times in 10 secondsfull
User, capabilities, NoNewPrivilegesThe controller and scalwsd run as scalws with only CAP_NET_BIND_SERVICE and no_new_privs; the console runs as root with only CAP_DAC_OVERRIDE, CAP_CHOWN and CAP_FOWNER, no_new_privs (with the service sandbox it starts with CAP_SYS_ADMIN and CAP_SETPCAP too, to make its read-only view, and drops both before it serves)full
LimitNOFILE, Nice, CPUWeight, MemoryMaxrc_ulimit, --nicelevel 10, the console’s cgroup gets cpu.weight 20 and memory.max 256Mfull
journaldNo journal: the output goes to /var/log/scalws/controller.log, scalwsd.log and console.log, rotated weekly (and at 100 MB) by /etc/logrotate.d/scalws; logrotate is a dependency of the apk. The instances’ logs are rotated hourly from /etc/scalws-logrotate-instances.conf (files and services)degraded: files instead of a journal
ProtectSystem=strict, ProtectHome=read-only, ReadWritePaths=The services start with --service-sandbox (service_sandbox=yes in /etc/conf.d/<service>): a Landlock write fence leaves only the service’s own directories writable (/run/scalws*, /var/lib/scalws*, /var/log/scalws, /var/cache/scalws, /sys/fs/cgroup, /tmp, /var/tmp, /dev, /proc; for the console /etc/scalws, /var/lib/scalws-console, /srv/www, /home, /tmp, /var/tmp, /dev/shm, /dev/null, /dev/zero, /dev/full and the control file of the controller’s supervisor, never /proc, the rest of /dev or the rest of /run/openrc: it keeps uid 0, and it also makes a read-only mount view of its own, so mode, owner, ACL, extended attribute and time changes outside its fence fail as under the unit), inherited by the instances and the applications. rw_paths="..." in the conf.d file is the twin of a ReadWritePaths= drop-in (an application’s uploads directory)full (kernels with Landlock; Alpine 3.24’s has it)
ProtectKernelModules, LockPersonality, RestrictSUIDSGID, SystemCallArchitectures=nativeThe same --service-sandbox: a seccomp deny-list of module loading, persona changes, set-user/group-ID modes and foreign-architecture callsfull
PrivateTmp, PrivateDevices, ProtectKernelTunables, RestrictNamespaces, the console’s SystemCallFilter and MemoryDenyWriteExecuteNo OpenRC equivalent (they need mount namespaces or a per-service filter that the applications would inherit). scalwsctl status says service sandbox: OpenRC: write fence and system call deny-list in force; no private /tmp, devices or namespaces (without Landlock: system call deny-list only, no write fence (no Landlock))degraded
Panel unitsNot installed: no control panel supports Alpine; scalwsctl panel detect says nonenot supported

Firewall on Alpine

A fresh Alpine install runs no firewall, and the install says so (no firewall is running: ports 80 and 443 and the console's port 8090 are reachable). On the first install the package opens ports 80 and 443 in:

  • ufw (community repository), when it is active: the profile ScalWS, as on Ubuntu;
  • nftables, when the nftables service runs a ruleset with the stock table inet filter input chain that includes /etc/nftables.d/*.nft (Alpine’s /etc/nftables.nft does): it writes /etc/nftables.d/50-scalws.nft (tcp dport { 80, 443 } accept) and loads it at once. Any other ruleset is left alone and the output prints the rule.

With awall in use (policies enabled, its rules loaded by the iptables service) the package changes nothing and prints the commands for its policy scalws (/usr/share/awall/optional/scalws.json):

doas awall diff                                   # see what else is pending first
doas awall enable scalws && doas awall activate

It does not run them itself because awall activate replaces the whole live iptables ruleset: rules that other services added at run time (fail2ban’s bans, Docker’s chains) are gone until those services add them again, and changes to the awall configuration that were not activated yet go live with it.

With plain iptables rules nothing is changed and the output prints the command. The console port 8090 is never opened, a stopped firewall is never started. Removal leaves the nftables file (and prints rm /etc/nftables.d/50-scalws.nft && rc-service nftables reload); if you enabled the awall policy scalws, removal disables it (its file goes with the package) and the ports stay open until your next awall activate.

Logs, cgroups and diskless systems

  • Logs: /var/log/scalws/. controller.log and scalwsd.log are readable by root and group scalws; console.log by root only (0600, like the console’s other files). Each instance of the controller also writes /var/lib/scalws-controller/logs/web-<n>.log, as elsewhere.
  • Tenant limits need cgroup v2, Alpine’s default (rc_cgroup_mode="unified" in /etc/rc.conf). With legacy or hybrid ScalWS serves without limits and the first install says how to switch.
  • Diskless and “data” installs (system in RAM, lbu commit) are not supported: ScalWS keeps its state in /var/lib and /etc, which such a system loses at reboot unless you add them to lbu.
  • Upgrade: apk upgrade (or apk add -u scalws). Running services are restarted; the controller’s instances are adopted, as on systemd 254 and later. A configuration file you changed stays, and the new default is written next to it as <file>.apk-new.
  • Uninstall: apk del scalws stops the services (the instances drain), takes them out of every runlevel and keeps /etc/scalws, /var/lib/scalws* and your document roots. Remove the repository line from /etc/apk/repositories and the key from /etc/apk/keys to remove the repository.

From a package file

apt install ./scalws_<version>-<release>_amd64.deb   # Debian, Ubuntu
dnf install ./scalws-<version>-<release>.x86_64.rpm  # RHEL family, Fedora
zypper install ./scalws-<version>-<release>.x86_64.rpm  # SUSE (a signed package)
apk add ./scalws-<version>-r<release>.apk            # Alpine (with the key in /etc/apk/keys)

Alpine versions have no ~ or -: 1.0.0-rc.1 is 1.0.0_rc1 there (scalws-1.0.0_rc1-r12.apk), and 1.0.0 is 1.0.0.

Only the repositories bring updates. DEB and RPM packages are built from the source tree with scripts/package.sh (see operations).

Control panel servers (cPanel, DirectAdmin, Plesk)

On a server that runs cPanel, DirectAdmin or Plesk, the first install of the package lets ScalWS take over the panel’s web server and serve the panel’s domains: the package runs scalwsctl panel enable <panel> once and prints its result (undo: sudo scalwsctl panel disable). Upgrades never do this. If switching fails, everything is undone, the package stays installed and the panel’s web server keeps serving; retry with sudo scalwsctl panel enable <panel>.

To leave the panel’s web server alone, install with --panel=none (the installer passes SCALWS_PANEL=none to the package):

curl -fsSL https://raw.githubusercontent.com/xsmtx/scalws/main/install.sh | sudo sh -s -- --panel=none

Installing the package directly, set it after sudo (sudo drops variables set before it):

sudo SCALWS_PANEL=none apt-get install scalws

ScalWS is then installed but not started on ports 80 and 443 next to the panel’s web server; the message names the command that switches over.

See control panels.

What the package installs

PathWhat
/usr/bin/scalwsdThe web server (data plane).
/usr/bin/scalwsctlOperator CLI (CLI reference).
/usr/bin/scalws-limitcpu, -limitram, -limitio, -highcpu, -highram, -highioSymlinks to scalwsctl for tenant resource commands.
/usr/bin/scalws-controllerAdaptive Runtime controller: runs and supervises scalwsd instances.
/usr/bin/scalws-consoleWeb administration console (port 8090).
/usr/lib/systemd/system/scalwsd.service, scalws-controller.service, scalws-console.servicesystemd units.
/etc/scalws/scalws.yamlServer configuration (by default ports 80 and 443 with ACME and the welcome page, see first start; never replaced on upgrade).
/etc/scalws/controller.yamlController configuration (never replaced on upgrade).
/etc/scalws/sites.d/One file per site (site files).
/etc/scalws/console/Console accounts (credentials).
/var/lib/scalws, /var/lib/scalws-consoleState: ACME account and certificates; console history and audit log.
/usr/lib/sysctl.d/90-scalws-adaptive-runtime.confnet.ipv4.tcp_migrate_req = 1.
/usr/share/doc/scalws/examples/scalws.yamlFull reference configuration.
/etc/ufw/applications.d/scalwsufw application profiles ScalWS (80, 443/tcp), ScalWS HTTP3 (443/udp) and ScalWS Console (8090).
/usr/share/selinux/packages/scalws/SELinux policy module scalws.pp and scalws_extra.cil (.rpm only, see SELinux).
Shell completionsbash, zsh and fish completions for scalwsctl.

The install scripts also:

  • create the system group and user scalws (home /var/lib/scalws, no login shell);
  • set /etc/scalws to 0750 root:scalws and the two configuration files to 0640 root:scalws;
  • apply the sysctl setting immediately (it is also loaded at every boot);
  • where SELinux is enabled (RHEL family, Fedora, openSUSE Leap 16, SLES 16), load the policy module and label the package’s files;
  • with systemd before 254, write a drop-in for scalws-controller;
  • on the first install, open ports 80 and 443 in an active ufw or a running firewalld (firewall);
  • create the console administrator account once and enable and start scalws-console.service (see ScalWS Console);
  • print a short message with the next steps.

On a first install without a control panel they also enable and start scalws-controller (first start). On an upgrade, units that were running are restarted.

The three services

UnitRunsUse it when
scalws-controllerscalws-controller run --config /etc/scalws/controller.yaml, which starts and supervises one or more scalwsd instances sharing the listening socketsDefault. Static sites, reverse proxies, PHP through an existing PHP-FPM pool. Required for the console’s management actions.
scalwsdOne scalwsd --config /etc/scalws/scalws.yaml processYour configuration uses managed runtimes: php: true (ScalWS starts PHP-FPM), node, python or container applications.
scalws-consoleThe web console on 0.0.0.0:8090Always (enabled by the package). It is a separate process; stopping it never affects the web server.

scalws-controller and scalwsd exclude each other (Conflicts= in the controller unit): enable exactly one of them.

Why the split: the controller can run several scalwsd instances, and each instance would otherwise start its own copy of every application pool (ADR-0036). The controller therefore refuses a configuration with managed PHP-FPM, Node.js, Python or container runtimes:

<tenant>/<app>: managed php runtimes are not supported with several instances (ADR-0036); use an external endpoint (php: fastcgi, others: proxy)

Under the controller, run PHP through an existing PHP-FPM pool (php: {fastcgi: "unix:/run/php/php8.3-fpm.sock"}) and start Node.js or Python applications yourself (for example as their own systemd units) behind proxy. If you want ScalWS to start and supervise those processes, use scalwsd.service. The console then shows “controller unavailable” and its management actions are disabled; scalwsctl works as usual.

HTTP/3 is part of the packaged binaries and off by default. It works under the controller too (through its QUIC demux): set http3: true on a TLS listener and open UDP 443, see HTTP/3 (UDP 443).

First start

The package starts the server by itself on a fresh installation: scalws-controller is enabled and started with the default configuration, which

  • listens on ports 80 and 443 (IPv4, and IPv6 where the host has it),
  • obtains a Let’s Encrypt certificate for every site you add (server.acme, which accepts the Let’s Encrypt Subscriber Agreement), and
  • answers every host name that no site serves (the server’s IP address, for example) with a short welcome page.

The end of the installation output says what happened:

ScalWS is running on ports 80 and 443 (http://203.0.113.10/ shows its welcome page).
Publish a site: point the domain's DNS at this server, then
    sudo scalwsctl site add example.com
Files go to /srv/www/example.com; HTTPS comes from Let's Encrypt automatically.

The server is not started when another program already listens on port 80 or 443 (nginx, Apache): the output names it. Stop that program, or move its sites over with scalwsctl migrate, then start ScalWS yourself:

sudo systemctl enable --now scalws-controller

For managed PHP-FPM, Node.js or Python runtimes, which scalws-controller does not run (it uses existing pools and upstreams), run the single-process unit instead:

sudo systemctl disable --now scalws-controller
sudo systemctl enable --now scalwsd

Let operators use scalwsctl without root: members of the group scalws may use the admin socket (sudo usermod -aG scalws alice).

Publishing a site

  1. Point the domain’s DNS records at the server: A to its IPv4 address and, if it has one, AAAA to its IPv6 address. For example.com, also www.example.com (it redirects to example.com).

  2. Add the site:

    sudo scalwsctl site add example.com
    added example.com → /etc/scalws/sites.d/example.com.yaml
    created /srv/www/example.com with a placeholder index.html
    reloaded: {"generation":2}
  3. Put the site’s files into /srv/www/example.com. The certificate is requested right after the reload and is used as soon as it arrives (usually within seconds); scalwsctl certs shows its state.

PHP through an existing PHP-FPM pool, a reverse proxy and the other options are in site files.

Checking that it works

curl -I http://127.0.0.1/                      # the welcome page (200)
scalwsctl status                               # version, generation, listeners
scalwsctl certs                                # certificates and their state
sudo scalws-controller status                  # instances of the Adaptive Runtime
journalctl -u scalws-controller -e

Under scalws-controller the read commands (status, apps, certs, top, …) use the first instance’s admin socket on their own.

Health and metrics stay on loopback: under scalwsd.service http://127.0.0.1:9900/healthz (also /metrics and a read-only /dashboard); under scalws-controller each instance has its own port, metrics_base_port + i (default 9910 for the first instance).

Firewall

Ports 80 and 443 must be reachable from the internet, also for the Let’s Encrypt validation. On the first install of the package, on a server without a control panel, the package opens them in the host firewall if one is running:

  • ufw, when ufw status says active: it adds the application profile ScalWS (ufw allow ScalWS: 80/tcp and 443/tcp). The output says:

    opened ports 80 and 443 in ufw (rule: ScalWS)
    the console (port 8090) was not opened: the default policy of ufw blocks it and no rule for port 8090 was found; to open it: sudo ufw allow 8090/tcp

    The package says so only when ufw’s default incoming policy is deny or reject and no rule naming port 8090 exists. It does not evaluate port ranges, source rules or other tables, so otherwise it says that the console may be reachable from the network and asks you to check your firewall.

  • firewalld, when firewall-cmd --state says running: it adds the services http and https, permanently and at runtime, to the zone of the interface that carries the default route, or else to the default zone. The output names the zone:

    opened services http and https in firewalld (zone public)
    the console (port 8090) was not opened: the default policy of firewalld (zone public) blocks it and no rule for port 8090 was found; to open it: sudo firewall-cmd --permanent --zone=public --add-port=8090/tcp && sudo firewall-cmd --reload

    That needs a zone target other than ACCEPT (the zone trusted accepts everything) and no port rule for 8090; services, rich rules and zone sources are not evaluated.

    If the runtime change fails, the permanent rules stay and the output says they take effect after sudo firewall-cmd --reload.

Whenever the package starts or enables the console without such a line (no firewall, a panel server, a reinstall), the output says that it listens on port 8090 of every address and whether a firewall’s default policy blocks it, for example ScalWS Console listens on port 8090 of every address and may be reachable from the network (no firewall is running); check your firewall, .... A masked console (systemctl mask --now scalws-console) is left alone and not mentioned.

The package never enables or starts a stopped firewall, never opens the console port 8090, and does nothing on upgrades or on a panel server (the panel manages the firewall there). If a command fails, the output prints the command to run yourself; the installation succeeds either way. Removing the package leaves the rules in place and prints how to remove them:

The ufw rule for ports 80 and 443 stays; to remove it: sudo ufw delete allow ScalWS
The firewalld services http and https stay open (zone public); to remove them: sudo firewall-cmd --permanent --zone=public --remove-service=http --remove-service=https && sudo firewall-cmd --reload

To open the ports yourself (a firewall enabled after the install, for example):

sudo ufw allow 'ScalWS'                                                    # ufw
sudo firewall-cmd --permanent --add-service=http --add-service=https && sudo firewall-cmd --reload   # firewalld

On a cloud server, also allow the ports in the provider’s firewall or security group. The metrics ports and the admin socket stay local. Port 8090 is the console; see below before opening it.

HTTP/3 (UDP 443)

HTTP/3 is off until you set http3: true on a TLS listener (tls.md), and the package never opens UDP 443, not even when it opens 80 and 443 over TCP. Packages before 1.0.0 had 443/udp in the ufw profile ScalWS: upgrading narrows such a rule to TCP (narrowed the ufw rule ScalWS to TCP ports 80 and 443). After enabling HTTP/3, open UDP 443 yourself:

# ufw: the profile ScalWS HTTP3 is 443/udp
sudo ufw allow 'ScalWS HTTP3'

# firewalld (the zone the install named, here public)
sudo firewall-cmd --permanent --zone=public --add-port=443/udp && sudo firewall-cmd --reload

# nftables with Alpine's stock ruleset (table inet filter, /etc/nftables.d included)
doas tee /etc/nftables.d/51-scalws-http3.nft <<'EOF'
table inet filter {
	chain input {
		udp dport 443 accept comment "ScalWS HTTP/3"
	}
}
EOF
doas nft -f /etc/nftables.d/51-scalws-http3.nft

# nftables with another ruleset: add the rule to your input chain, for example (this
# changes only the live ruleset: also add it to your ruleset file, e.g. /etc/nftables.conf,
# or it is gone at the next reload or reboot)
sudo nft add rule inet filter input udp dport 443 accept

With awall, write a policy of your own and activate it (the same caution as for the policy scalws below: awall activate replaces the whole live ruleset):

doas tee /etc/awall/optional/scalws-http3.json <<'EOF'
{
  "description": "ScalWS HTTP/3: UDP 443 from every zone",
  "service": {"scalws-http3": {"proto": "udp", "port": 443}},
  "filter": [{"out": "_fw", "service": "scalws-http3", "action": "accept"}]
}
EOF
doas awall enable scalws-http3 && doas awall activate

Also allow UDP 443 in the provider’s firewall or security group. While UDP 443 is closed, browsers stay on HTTP/2 over TCP: HTTP/3 only adds a way in. To check from another host, curl --http3-only -sI https://example.com/ (a curl built with HTTP/3) answers HTTP/3 200.

SELinux

On the RHEL family, Fedora, openSUSE Leap 16 and SLES 16, ScalWS runs with SELinux enforcing, under its own policy module. There is no need to change the SELinux mode. The .rpm ships the module /usr/share/selinux/packages/scalws/scalws.pp and its companion scalws_extra.cil, which labels port 8090 as scalws_console_port_t and UDP port 443 as http_port_t for HTTP/3 (EL 10 and Fedora label it so already; EL 8 and 9 do not). The package loads both when it is installed and labels its files before any service starts. On SUSE 16 this is the same module, loaded the same way (semodule from policycoreutils, which a new installation has); SUSE’s targeted policy has every type it uses. The .deb carries no module.

The UDP 443 label (HTTP/3) on EL 8 and 9, where the module adds it:

  • It applies to the whole system, as on EL 10 and Fedora: other confined services may bind UDP 443 too. Removing the package removes the label again (UDP 443 is reserved_port_t then), also for those services.
  • semanage port -l | grep http_port_t shows udp 443. The port is already defined, so semanage port -a ... -p udp 443 answers Port udp/443 already defined, modifying instead and adds a local rule; use semanage port -m for that on purpose. semanage port -d -p udp 443 deletes only such a local rule, never the module’s label.
  • A local rule of your own wins over the module: after semanage port -m -t <type> -p udp 443 with another type, a listener with http3: true cannot bind UDP 443. The controller (or scalwsd) then fails to start the listener with Permission denied, and sudo ausearch -m avc -ts recent shows a denied name_bind on udp_socket. semanage port -l -C lists your local rules; sudo semanage port -m -t http_port_t -p udp 443 restores it.
  • Another policy module that labels UDP 443 with a different type conflicts with scalws_extra: loading the second of the two fails. The package then says it could not load the module.
DomainRuns
scalws_tscalwsd and scalws-controller
scalws_console_tscalws-console
scalws_app_tApplications the server starts (managed PHP-FPM pools, Node.js, Python), through setpriv --selinux-label

Document roots need a web content type. /srv/www and /var/www have httpd_sys_content_t already, and ~/public_html has httpd_user_content_t. The policy lets the server read those types and lets applications write to directories labelled httpd_sys_rw_content_t (uploads, caches). The server can also reach the distribution’s PHP-FPM sockets (/run/php-fpm/), and on SUSE php8-fpm’s default 127.0.0.1:9000 (--php-fastcgi tcp:127.0.0.1:9000). For a document root somewhere else, label it once:

sudo semanage fcontext -a -t httpd_sys_content_t '/data/www(/.*)?'
sudo restorecon -R /data/www

The boolean scalws_can_network_connect (on by default) lets the server and its applications connect to any TCP port: reverse proxy upstreams, databases, APIs. Turned off, they may only connect to HTTP ports (http_port_t, which ACME needs):

getsebool scalws_can_network_connect
sudo setsebool -P scalws_can_network_connect off

To see whether SELinux denied anything:

sudo ausearch -m avc -ts recent

If SELinux is enforcing and semodule is missing, the package installs but cannot load the module, and says so:

ERROR: SELinux is enforcing but semodule is missing: ScalWS runs unconfined. Install it and load the module: sudo dnf install policycoreutils && sudo semodule -i ...

(On SUSE the message says zypper install policycoreutils.)

The message ends with the full commands that load the module, relabel the files and restart the services. Removing the package unloads the module and relabels the files that stay.

Known limits in this release:

  • runtime: {type: container} (podman) is not supported under SELinux enforcing.
  • Applications in scalws_app_t do not get the parent-death signal. If scalwsd dies, the unit stops them (KillMode=control-group), so nothing is left running (operations).

On cPanel and DirectAdmin servers SELinux is set as the panel vendor requires; see Control panels.

cgroup v1 (EL8)

AlmaLinux, Rocky Linux and Oracle Linux 8 boot with cgroup v1 by default. ScalWS serves normally there, but tenant limits (CPU, memory, processes) are off; the limit commands answer tenant limits need cgroup v2, which is not active on this server .... The first install says so:

This host uses cgroup v1: tenant limits (CPU, memory, processes) need cgroup v2.
Switch with: sudo grubby --update-kernel=ALL --args=systemd.unified_cgroup_hierarchy=1 (then reboot); without grubby add systemd.unified_cgroup_hierarchy=1 to the kernel command line.

To switch to cgroup v2:

sudo grubby --update-kernel=ALL --args=systemd.unified_cgroup_hierarchy=1
sudo reboot
stat -fc %T /sys/fs/cgroup           # cgroup2fs after the reboot

EL9, EL10, Fedora, Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04 and the SUSE systems use cgroup v2 by default.

PHP on EL8

A plain dnf install php-fpm on AlmaLinux, Rocky Linux or Oracle Linux 8 installs PHP 7.2, the default php module stream. Managed PHP pools run with PHP-FPM 7.2 and later: ScalWS asks the binary for its version (php-fpm -v) and writes only directives it knows. On 7.2 that leaves out decorate_workers_output (added in PHP 7.3), so lines a PHP worker writes to stderr reach the log with PHP-FPM’s child <pid> said into stderr prefix. PHP 7.2 is out of upstream support since 2020; for applications use a newer stream from AppStream:

dnf module list php                  # the streams this release offers (8.0 from EL 8.6, 8.2 from EL 8.8)
sudo dnf module reset -y php
sudo dnf module enable -y php:8.2
sudo dnf install -y php-fpm php-cli php-mysqlnd
php-fpm -v                           # PHP 8.2.x (fpm-fcgi)

or PHP 8.3 and later from the Remi repository:

sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-8.rpm
sudo dnf module reset -y php
sudo dnf module enable -y php:remi-8.3
sudo dnf install -y php-fpm php-cli php-mysqlnd

Both install /usr/sbin/php-fpm, which ScalWS finds by itself. A version switched later is used when a pool next starts; sudo systemctl restart scalwsd starts every pool anew.

systemd before 254

EL8, EL9, Debian 12 and Ubuntu 22.04 have a systemd older than 254, which lacks DelegateSubgroup=. There the package writes the drop-in /usr/lib/systemd/system/scalws-controller.service.d/10-systemd-before-254.conf with KillMode=control-group. A restart of scalws-controller, and so a package upgrade, then stops the instances and starts new ones: a short interruption instead of the seamless handover. scalwsd.service is not affected. Details: files and services.

Fedora 44 and systemd 259.5

With systemd 259.5, the version Fedora 44 was released with, a restart of scalws-controller with tenant limits failed with “Failed to spawn executor: Device or resource busy” while cgroup controllers were still enabled in the unit’s group. ScalWS now releases them when scalws-controller stops, so restarts and upgrades work. A controller that crashed instead of stopping cannot do that, and its restart can still fail this way. Update systemd to fix it:

sudo dnf upgrade systemd

IPv6 listeners

The default configuration listens on ports 80 and 443 of both 0.0.0.0 and [::]. How [::] then takes IPv6 only, and when it stays dual-stack, is in the address rules of configuration.

Changing the listeners

The listeners are in /etc/scalws/scalws.yaml. Both units have CAP_NET_BIND_SERVICE, so the server binds ports below 1024 without running as root. After a change:

sudo scalwsctl check
sudo scalwsctl reload               # under the controller: binds the new address, rolls instances
sudo systemctl restart scalwsd      # under scalwsd.service: listener changes need a restart

Under the controller use reload, not a restart: a restarted controller adopts the running instances with the configuration they have. The controller binds new addresses before it activates the new generation; a bind failure (port in use, permission) rejects the reload and leaves the current sockets as they were.

ScalWS Console

On the first install the package creates /etc/scalws/console/credentials (mode 0600, root) with the user admin and a random 24-character password, and prints:

ScalWS Console administrator account created
  address:  https://<server-address>:8090
  user:     admin
  password: sudo grep '^admin:' /etc/scalws/console/credentials | cut -d: -f3
  file:     /etc/scalws/console/credentials (0600)

The password itself is never printed, because package managers log the installer’s terminal. Read it with:

sudo grep '^admin:' /etc/scalws/console/credentials | cut -d: -f3

Open https://<server-address>:8090 and sign in as admin. The console uses a generated self-signed certificate (in /var/lib/scalws-console/tls/), so the browser warns once. For production, give it a real certificate (--tls-cert, --tls-key) and restrict the client addresses (--allow), either with a firewall rule for port 8090 or by overriding ExecStart in a drop-in. Options, accounts and roles are described in console operations.

The console manages the server through scalws-controller. Under scalwsd.service it shows “controller unavailable”.

Upgrades keep the credentials file. If the file is missing (tarball installs, containers), create it with:

scalws-console init-credentials --credentials /etc/scalws/console/credentials \
    --url "https://$(hostname -I | awk '{print $1}'):8090"
systemctl enable --now scalws-console

Upgrade

sudo apt update && sudo apt upgrade      # Debian, Ubuntu
sudo dnf upgrade scalws                  # RHEL family, Fedora
sudo zypper up scalws                    # SUSE

What the upgrade changes, how instances move to the new binary and how to roll back: upgrading.

  • /etc/scalws/scalws.yaml and /etc/scalws/controller.yaml are never replaced once you changed them; the console credentials and site files are kept.
  • The package restarts scalwsd, scalws-controller and scalws-console if they were running. Restarting the controller does not stop its instances: the new controller adopts them and then rolls them onto the new scalwsd binary itself, one at a time. It uses a copy of the current generation and does not read the configuration file again. sudo scalws-controller reload is needed only with instances.roll_on_upgrade: false, or when every slot up to instances.max is taken (then the controller logs a warning). With systemd before 254 the instances restart with the controller instead (systemd before 254).
  • Console sessions end on upgrade; drafts are lost, history and audit log stay.
  • Check the version with scalwsctl status (or scalwsd --version).

New defaults that only exist in the packaged configuration (for example include: sites.d/*.yaml on installations that predate site files) are not merged into your file; see site files for the one line to add.

Uninstall

sudo apt remove scalws        # stops and disables scalwsd, scalws-controller, scalws-console
sudo apt purge scalws         # additionally removes the package's configuration files
sudo dnf remove scalws        # RHEL family, Fedora
sudo zypper remove scalws     # SUSE

Removal also stops the scalwsd instances the controller started: they get SIGTERM to drain and SIGKILL after 30 seconds. With dnf or zypper, a configuration file you changed is saved as <file>.rpmsave. Where SELinux is on (RHEL family, Fedora, SUSE 16) the module is unloaded. Firewall rules the first install added stay; the output says how to remove them (firewall).

Stopping the controller unit stops the controller and its QUIC demux (ExecStopPost=), not the instances (KillMode=process, except with systemd before 254). With HTTP/3 on, the instances keep the UDP 443 sockets they were handed: the port stays bound but QUIC is not answered until the controller starts again (TCP keeps serving). To free UDP 443, or to switch from the controller to scalwsd.service, stop the instances too:

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

A controller started while a demux of an earlier one still runs ends it before binding the UDP sockets, so a later systemctl start scalws-controller does not fail on them.

Left in place on purpose: the scalws user and group, /var/lib/scalws (ACME account and certificates), /var/lib/scalws-controller, /var/lib/scalws-console, the console credentials, your site files and your document roots. Delete them by hand if you want a clean system. Remove the repository with rm /etc/apt/sources.list.d/scalws.list /usr/share/keyrings/scalws-archive-keyring.gpg (apt), rm /etc/yum.repos.d/scalws.repo (dnf) or zypper removerepo scalws (zypper).

Building from source

Requires Rust 1.88 or later (edition 2024).

cargo build --release
./target/release/scalwsctl check examples/scalws.yaml        # needs the referenced files
./target/release/scalwsd --config examples/scalws.yaml
kill -HUP $(pidof scalwsd)                                   # reload
curl -s 127.0.0.1:9901/metrics | grep scalws_http_requests_total

The release build produces scalwsd, scalwsctl, scalws-controller and scalws-console in target/release/. Optional features: --features scalwsd/http3 (HTTP/3, which the packages are built with; scalwsd --features lists what a binary has) and --features scalwsd/ebpf (eBPF network accounting). Release packages are profile-guided (tools/pgo-build.sh); a plain cargo build --release works too.

Running from source does not create the scalws user, the systemd units or /etc/scalws; install the package for that, or copy the units from packaging/systemd/.

Next steps