Users' guide
Kubernetes
ScalWS runs in Kubernetes as the web server of an application: it serves static files, proxies to other Services and runs PHP through a PHP-FPM sidecar in the same pod. This guide covers the container image, the Helm chart, the plain manifests and the settings that matter inside a pod.
ScalWS is also an ingress controller for networking.k8s.io/v1 Ingress resources and the
Gateway API (Gateway, HTTPRoute), as a mode of the chart; see
ingress-controller.md and gateway-api.md.
Operators and multi-node
coordination are not part of this release. The multi-node design is in
ADR-0021.
Note: the container image
ghcr.io/xsmtx/scalwsand the Helm chartoci://ghcr.io/xsmtx/charts/scalwsare not yet published for public download. Until they are, the steps on this page describe how the Kubernetes deployment works but cannot be followed as written; the Linux packages are the supported way to install 1.0.0.
Image
The image is ghcr.io/xsmtx/scalws:<version>, built for amd64 and arm64. It is distroless
(no shell), runs as user 65532 and contains /usr/bin/scalwsd and /usr/bin/scalwsctl.
Because there is no shell, kubectl exec <pod> -- scalwsctl status works but
kubectl exec <pod> -- sh does not.
SCALWS_CONFIG points at /etc/scalws/scalws.yaml, which is this file:
# Configuration baked into the ScalWS container image: a static site from /srv/www on
# port 8080, probes on 8081. Mount your own file and point SCALWS_CONFIG at it (the Helm
# chart does). Domains other than localhost need their own configuration.
server:
listeners:
- name: http
address: "0.0.0.0:8080"
health:
address: "0.0.0.0:8081"
reload:
watch: true
limits:
shutdown_delay: 5s
cgroups:
mode: "off"
runtime_dir: /run/scalws
logging:
format: json
tenants:
- name: site
applications:
- name: web
root: /srv/www
domains: [localhost]
runtime: {type: static}To try it:
docker run -p 8080:8080 -p 8081:8081 --read-only --tmpfs /run/scalws:uid=65532 \
ghcr.io/xsmtx/scalws:1.0.0-rc.1Helm chart
Install the chart from the OCI registry:
helm install web oci://ghcr.io/xsmtx/charts/scalws --version 1.0.0-rc.1 \
--set domains={www.example.com} --set content.image=registry.example.com/my-site:1.0content.image is an image holding your files; an init container copies them into
/srv/www. Without it the pod serves whatever is in /srv/www of the ScalWS image. The
chart creates a Deployment, a Service and a ConfigMap with the generated configuration,
plus a PodDisruptionBudget when replicaCount is above 1 or autoscaling is enabled, and
the optional objects below.
The main values (all of them are in deploy/helm/scalws/values.yaml):
| Value | Default | Meaning |
|---|---|---|
replicaCount | 2 | Number of pods. |
domains | [scalws.local] | Domains of the default site, used when tenants and sites are empty. Together with the domains of the tenants applications it is also the TLS server_names and the Certificate’s dnsNames. Domains of sites files are not read: list them here for TLS. |
tenants | [] | Applications in the tenants: form of scalws.yaml. Empty: one site serving /srv/www for domains. |
sites | {} | Site files, name to content (the include mechanism of site files). |
server | {} | Extra server: settings merged over the chart’s (limits, trusted_proxies, …). |
existingConfigMap | "" | Use a ConfigMap made elsewhere (key scalws.yaml, site files site-*.yaml). The chart renders no configuration then, so it refuses sites and serviceMonitor.enabled together with it. |
rollOnConfigChange | false | false: configuration changes are reloaded in place; settings that need a restart apply only after kubectl rollout restart (see Configuration changes). true: a change rolls the Deployment instead; with tls.enabled the watcher stays on for certificates. |
shutdownDelaySeconds | 5 | After SIGTERM, keep serving while reporting not ready. |
shutdownTimeoutSeconds | 30 | Time to drain connections afterwards. |
tls.enabled | false | Serve HTTPS in the pod. |
tls.secretName | "" | Secret with tls.crt and tls.key. Default: <fullname>-tls. |
tls.certManager.issuerRef | {} | For example {name: letsencrypt, kind: ClusterIssuer}. When set, the chart creates the Certificate. |
php.enabled | false | Add a PHP-FPM sidecar. |
php.image | php:8.3-fpm | Image of the sidecar. It must contain the PHP CLI (php), which the probes run. |
php.port | 9000 | FPM port (on 127.0.0.1). |
php.readOnlyRootFilesystem | false | Read-only root filesystem for the sidecar. |
php.resources | requests cpu: 100m, memory: 64Mi | Resources of the sidecar. |
content.image | "" | Image whose content.path is copied to /srv/www (needs sh and cp). |
content.path | /app | Directory inside content.image. |
content.existingClaim | "" | An existing PersistentVolumeClaim mounted at /srv/www. With more than one replica the volume must be ReadOnlyMany or ReadWriteMany, because the pods run on different nodes. |
resources | requests cpu: 100m, memory: 128Mi; no limit | Resources of the ScalWS container. Memory grows with the requests in flight and the size of proxied responses (about 330 MiB for 256 concurrent 100 KiB responses); a memory limit, if you set one, needs that headroom or scalwsd is OOM-killed under load. |
autoscaling.enabled | false | Create a HorizontalPodAutoscaler. |
autoscaling.minReplicas, autoscaling.maxReplicas | 2, 10 | Its bounds. |
autoscaling.targetCPUUtilizationPercentage | 70 | Its CPU target. |
serviceMonitor.enabled | false | Prometheus Operator ServiceMonitor scraping /metrics on the probe port (sets server.health.metrics) through a separate ClusterIP Service <name>-metrics, so the main Service never exposes it. |
serviceMonitor.interval | 30s | Scrape interval. |
A PHP application with its code in your own image:
domains: [app.example.com]
php:
enabled: true
image: php:8.3-fpm
content:
image: registry.example.com/my-php-app:1.0
path: /appPlain manifests
If you do not use Helm, deploy/kubernetes/static/scalws.yaml and
deploy/kubernetes/php/scalws.yaml are rendered from the chart with the values.yaml next
to each of them. Apply them as they are or edit them. deploy/check.sh verifies that they
match the chart; deploy/check.sh --write re-renders them.
Pod settings
These server: settings are what the image and the chart use inside a pod. The health
listener is described after the list.
server:
reload:
watch: true
health:
address: "0.0.0.0:8081"
metrics: true
limits:
shutdown_delay: 5s
cgroups:
mode: "off"
runtime_dir: /run/scalwsreload.watchreloads the configuration when the mounted files change. ConfigMaps and Secrets are updated by swapping a symlink, which the watcher follows. An invalid change keeps the running configuration and is logged once.limits.shutdown_delayis the time after SIGTERM during which/readyzanswers 503 while requests are still served, so Services and load balancers stop sending traffic first. SetterminationGracePeriodSecondsto the delay plus the shutdown timeout plus 5 seconds; the chart does this fromshutdownDelaySecondsandshutdownTimeoutSeconds.cgroups.mode: "off"because the container runtime already enforces limits. With the defaultauto, scalwsd logs a warning in a pod.runtime_dirneeds a writable directory (anemptyDirwhen the root filesystem is read-only).
Both scalwsd and scalwsctl read SCALWS_CONFIG. The order is --config, then
SCALWS_CONFIG, then /etc/scalws/scalws.yaml.
Health listener
health.address serves /healthz (liveness) and /readyz (readiness) for the kubelet.
With metrics: true it also serves /metrics. Everything else answers 404. It is a plain
HTTP listener separate from the metrics listener (see
the metrics listener), which keeps its loopback
default.
Configuration changes
With rollOnConfigChange: false (the default) a helm upgrade updates the ConfigMap and
each pod reloads it in place. A reload cannot apply settings that need a restart
(listeners, limits.max_connections, health.address, event_loops, cache sizes and the
others listed under Reload or restart?): it is
refused with “configuration change requires a restart” and the pods keep the old
configuration. Apply such a change with
kubectl rollout restart deployment/web-scalwsor set rollOnConfigChange: true, which rolls the Deployment on every configuration change.
TLS
By default the pod speaks plain HTTP and TLS ends at an ingress or load balancer. Set
server.trusted_proxies (through server: in the values) to the addresses of that
ingress, so that client addresses and per-client limits are right.
To serve HTTPS in the pod, set tls.enabled: true and provide a Secret with tls.crt and
tls.key (named by tls.secretName). With cert-manager, set tls.certManager.issuerRef
and the chart creates the Certificate for domains. Renewed certificates are picked up
without a restart, also when rollOnConfigChange is true.
PHP
With php.enabled the pod gets a PHP-FPM sidecar. Both containers mount the same
/srv/www, filled from content.image (an image with sh and cp) or taken from
content.existingClaim. PHP-FPM listens on 127.0.0.1:9000 only, so it is reachable from
the pod and from nowhere else. On shutdown the sidecar keeps running through the drain, so
requests in flight still finish. The sidecar has exec readiness and liveness probes that
connect to that port from inside the container (with a 1 second connect timeout), so the
pod is not ready before PHP-FPM accepts connections, and a PHP-FPM whose listener is closed
is restarted. The liveness probe cannot detect hung workers behind an open listener.
The image of php.image needs the PHP CLI (php) for these probes.
The PHP extensions are those of the sidecar image. For other extensions build your own image
FROM php:8.3-fpm and set php.image.
Ingress controller
With --set ingressController.enabled=true the chart installs scalws-ingress next to
scalwsd in every pod, an IngressClass (scalws), the RBAC it needs and a LoadBalancer
Service. Ingress resources and the common nginx.ingress.kubernetes.io/* annotations
become ScalWS configuration; endpoint changes reach the pods without a reload. Install,
annotations, differences from ingress-nginx, troubleshooting and the migration checklist
are in ingress-controller.md. A comparison with ingress-nginx and
Traefik on one node is in
the Kubernetes ingress benchmark.
Gateway API
--set ingressController.gatewayAPI.enabled=true makes the same controller serve
GatewayClass, Gateway, HTTPRoute and ReferenceGrant resources too, and creates the
GatewayClass scalws; ingressController.ingress.enabled=false (--ingress=false) turns
the Ingress input off for a Gateway-only install. The chart never installs the Gateway API
CRDs; install them first. Both inputs share the pods, the Service address and one
ownership rule for hosts: the oldest claim wins, and an Ingress and an HTTPRoute never
serve the same host. Install, listeners and ports, attachment, ownership, TLS, status and
the conformance result are in gateway-api.md.
Limits per pod
Per-client limits, tenant limits and the response cache are kept per pod. With N replicas the effective limit is up to N times the configured value, and each pod has its own cache. Size the limits accordingly.
Not supported in pods
- ACME: scalwsd warns about it in a pod. Use cert-manager.
- Managed runtimes and containers: run Node.js, Python and other applications as their own Deployments and proxy to their Services.
- Cgroups: use
cgroups.mode: "off"and the pod’s resources. scalws-controller: scale with replicas and a HorizontalPodAutoscaler. Under the controller scalwsd ignoresserver.health.address,server.reload.watchandlimits.shutdown_delay.
Operations
Run the admin client inside a pod:
kubectl exec deploy/web-scalws -c scalws -- scalwsctl statusThe Deployment is named <release>-scalws unless the release name already contains
scalws or you set fullnameOverride. Logs are JSON on stdout (kubectl logs).
Evictions such as kubectl drain respect the PodDisruptionBudget (created when
replicaCount is above 1 or autoscaling is enabled); rolling updates follow the
Deployment’s maxUnavailable and maxSurge instead. Each pod drains as described under
Pod settings.