K3s and RKE2
The cluster's bundled Traefik becomes the fleet's ingress: it polls /v1/ingress/routes/traefik through a scafo-api Service and routes each host to whichever pod runs it.
The Scafo pods carry no ingress of their own and publish nothing outside the cluster. Scafo's routers arrive as <name>@http; operator-owned Ingress and IngressRoute objects keep working beside them in their own providers.
#Prerequisites
- K3s v1.32 or newer with its bundled Traefik enabled. Older releases ship Traefik 2.11, which has no
providers.http.headersand cannot present the ingress token. RKE2 works the same way; its manifests directory is/var/lib/rancher/rke2/server/manifests/and its chart is namedrke2-traefik. - An external S3-compatible store, reachable from every pod, honouring
If-Match/If-None-Matchconditional writes. - cert-manager, installed separately (K3s does not bundle it), with a
ClusterIssuerthat can issue the platform apex and its wildcard. That needs DNS-01, so configure the issuer, its DNS-provider credentials, and DNS first. - Apex and wildcard DNS pointing at the K3s Traefik address.
#Create the namespace and Secrets
They live outside the manifest. scafo-bucket holds the bucket credentials, which are fleet-root authority. scafo-ingress holds SCAFO_INGRESS_TOKEN, the one credential an ingress ever holds; Traefik runs in kube-system, so the same token goes there too.
kubectl create namespace scafo
kubectl -n scafo create secret generic scafo-bucket \
--from-literal=SCAFO_S3_ACCESS_KEY="$SCAFO_S3_ACCESS_KEY" \
--from-literal=SCAFO_S3_SECRET_KEY="$SCAFO_S3_SECRET_KEY"
INGRESS_TOKEN="$(openssl rand -hex 32)"
kubectl -n scafo create secret generic scafo-ingress \
--from-literal=SCAFO_INGRESS_TOKEN="$INGRESS_TOKEN"
kubectl -n kube-system create secret generic scafo-ingress \
--from-literal=SCAFO_INGRESS_TOKEN="$INGRESS_TOKEN"#Edit the manifest
- Set the non-secret
scafo-nodeConfigMap values: S3 endpoint, bucket, region and prefix. Replaceapps.example.comconsistently in the ConfigMap, the Certificate and the Ingress. - Set the image's
VERSIONtag to a published Scafo release. Never use a floating tag. - Set
Certificate.spec.issuerRef.nameto the existing ClusterIssuer.
#Apply it
kubectl apply -f scafo.yaml
kubectl -n scafo wait --for=condition=Ready certificate/scafo-platform --timeout=5m
kubectl -n scafo rollout status statefulset/scafo-node#Teach the bundled Traefik
Set the ACME contact address in traefik-config.yaml, then place it on each server node. The helm-controller picks it up and upgrades the bundled release.
sudo cp traefik-config.yaml /var/lib/rancher/k3s/server/manifests/traefik-config.yaml
kubectl -n kube-system rollout status deploy/traefik#Verify
kubectl -n kube-system logs deploy/traefik | grep -i "provider.*http"
curl -sk https://<cluster-edge>/.well-known/scafo/health -H "Host: apps.example.com"
curl -sS https://apps.example.com/.well-known/scafo/healthThe second line works before DNS points at the edge; the third, with the wildcard certificate cert-manager issued, once it does. A deployed application answers at its own host the same way. Until then only the platform apex is routed: Scafo publishes no wildcard fallback, and an unclaimed host is a 404 at the edge.
#Deploy
Follow first deploy from your own machine, wherever kubectl already runs. The endpoint is https:// plus the apex in SCAFO_PLATFORM_DOMAINS, which Traefik routes to the node API. The bucket values are split between the scafo-node ConfigMap (endpoint, bucket, region, prefix) and the scafo-bucket Secret:
kubectl -n scafo get configmap scafo-node -o yaml
kubectl -n scafo get secret scafo-bucket -o jsonpath='{.data.SCAFO_S3_ACCESS_KEY}' | base64 -d; echo
kubectl -n scafo get secret scafo-bucket -o jsonpath='{.data.SCAFO_S3_SECRET_KEY}' | base64 -d; echoSCAFO_S3_PREFIX must be the ConfigMap's: production in this manifest, not the scafo the Compose recipes default to. A different prefix is a different, empty installation.
scafo remote add prod --endpoint https://apps.example.com --register
scafo deploy ./my-app --name shopNothing is forwarded out of the cluster for this, and kubectl exec into a node pod is never part of it. If the apex is off, see without the apex.
#scafo.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: scafo-node
namespace: scafo
data:
# These non-secret defaults identify one fleet; set them for the external store.
SCAFO_S3_ENDPOINT: "https://s3.example.com"
SCAFO_S3_BUCKET: "scafo"
SCAFO_S3_REGION: "us-east-1"
SCAFO_S3_PREFIX: "production"
SCAFO_PLATFORM_DOMAINS: "apps.example.com"
SCAFO_LISTEN: "0.0.0.0:7400"
# `none` declares the pod network private: runtime processes bind the pod
# address, so the cluster's Traefik dials whichever pod holds a host directly.
SCAFO_TRANSPORT: "none"
# The entryPoint the routers are attached to is the TLS one of the bundled
# Traefik; see traefik-config.yaml, which is what polls this routing.
SCAFO_TRAEFIK_ENTRYPOINTS: "websecure"
SCAFO_OBSERVABILITY: "1"
---
apiVersion: v1
kind: Service
metadata:
name: scafo-nodes
namespace: scafo
spec:
clusterIP: None
selector:
app.kubernetes.io/name: scafo
ports:
- name: api
port: 7400
targetPort: api
---
# Where the bundled Traefik polls the fleet's routing. Every node renders the
# same document, so this Service may answer from any of them; it asks for no
# preferred node, which spreads each host over every pod serving it.
apiVersion: v1
kind: Service
metadata:
name: scafo-api
namespace: scafo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: scafo
ports:
- name: api
port: 7400
targetPort: api
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: scafo-node
namespace: scafo
labels:
app.kubernetes.io/name: scafo
spec:
# One node to start with. Scaling out is supported: the cluster edge reads the
# whole fleet's routing, so it reaches a host on whichever pod holds it.
replicas: 1
serviceName: scafo-nodes
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app.kubernetes.io/name: scafo
template:
metadata:
labels:
app.kubernetes.io/name: scafo
spec:
# Keep node and descendants inside the container lifecycle.
hostPID: false
shareProcessNamespace: false
# Celld shutdown may take 40 seconds; permit Scafo to drain first.
terminationGracePeriodSeconds: 50
securityContext:
fsGroup: 10001
containers:
- name: node
# Replace VERSION with a published Scafo release before applying.
image: ghcr.io/sandros94/scafo:VERSION
imagePullPolicy: IfNotPresent
args: ["sail"]
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
allowPrivilegeEscalation: false
envFrom:
# Create these Secrets outside the manifest: `scafo-bucket` holds the
# bucket-root credentials, `scafo-ingress` the SCAFO_INGRESS_TOKEN the
# cluster's Traefik presents to read the fleet's routing. That token
# needs a second copy in `kube-system`, where Traefik runs.
- secretRef:
name: scafo-bucket
- secretRef:
name: scafo-ingress
- configMapRef:
name: scafo-node
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: SCAFO_ADVERTISE
value: "$(POD_IP):7400"
- name: SCAFO_NODE_LABELS
value: "platform=k3s"
ports:
- name: api
containerPort: 7400
volumeMounts:
- name: state
mountPath: /var/lib/scafo
startupProbe:
httpGet:
path: /.well-known/scafo/health
port: api
periodSeconds: 5
failureThreshold: 24
readinessProbe:
httpGet:
path: /.well-known/scafo/health
port: api
periodSeconds: 5
livenessProbe:
httpGet:
path: /.well-known/scafo/health
port: api
periodSeconds: 20
failureThreshold: 3
resources:
# Cgroup bounds do not influence Scafo placement or deploy admission.
requests:
cpu: "500m"
memory: "512Mi"
limits:
memory: "2Gi"
# A claim per pod, kept when the StatefulSet is scaled down or deleted, and
# never an emptyDir: each runtime process keeps its work directory here, a
# write is acknowledged once a peer holds it there before the bucket has it,
# and the pod recovers it at boot from what its predecessor of the same name
# left. K3s' default StorageClass is node-local, so a pod whose node is gone
# stays Pending until that node is back rather than restarting empty.
volumeClaimTemplates:
- metadata:
name: state
spec:
# Omit storageClassName to use the K3s default StorageClass.
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: "1Gi"
---
# cert-manager and this ClusterIssuer are operator-managed prerequisites.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: scafo-platform
namespace: scafo
spec:
secretName: scafo-platform-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
dnsNames:
- apps.example.com
- "*.apps.example.com"
---
# The routing comes from Scafo's http provider, so nothing here routes anything.
# This Ingress exists to hand the wildcard Secret to Traefik: its Kubernetes
# provider reads `spec.tls` into the certificate store before it looks at rules,
# so an Ingress with no rules adds the certificate and creates no router. The
# store is matched by SNI, which is how the platform hosts of the `@http`
# routers are served this certificate rather than an ACME-issued one.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: scafo-platform-tls
namespace: scafo
spec:
ingressClassName: traefik
tls:
- hosts:
- apps.example.com
- "*.apps.example.com"
secretName: scafo-platform-tlsTwo Services, for two different jobs: scafo-nodes is the headless Service the StatefulSet needs for stable pod identity, and scafo-api is the ClusterIP the bundled Traefik polls. Both are ClusterIP, reachable from inside the cluster alone. Every node renders the same routing document, so scafo-api may answer from any pod; it asks for no preferred node, which spreads each host over every pod serving it.
#traefik-config.yaml
A HelmChartConfig in kube-system, dropped into the server manifests directory. The helm-controller merges it into the bundled chart and upgrades the release.
# Customises K3s's bundled Traefik so it reads the fleet's routing itself.
#
# Copy this file to /var/lib/rancher/k3s/server/manifests/traefik-config.yaml on
# every server node; the filename must be RFC-1123. K3s's helm-controller merges
# it into the bundled chart and upgrades the release. Never edit the neighbouring
# traefik.yaml: K3s rewrites it on every start. On RKE2 the directory is
# /var/lib/rancher/rke2/server/manifests/ and the chart is named `rke2-traefik`.
#
# Requires K3s or RKE2 v1.32 or newer, whose chart ships Traefik v3: the bearer
# header below is `providers.http.headers`, which Traefik 2.11 does not have.
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
# A Traefik provider is static configuration, settled at startup, and the
# chart has no value for the http one — its schema rejects unknown keys — so
# arguments are the only way to declare it.
#
# Kubernetes expands $(VAR) in a container's arguments from that container's
# environment, so the token stays in the Secret rather than in this file.
additionalArguments:
- "--providers.http.endpoint=http://scafo-api.scafo.svc:7400/v1/ingress/routes/traefik"
- "--providers.http.pollInterval=5s"
- "--providers.http.headers.Authorization=Bearer $(SCAFO_INGRESS_TOKEN)"
# Traefik runs in kube-system, so the ingress token needs a copy there. It is
# the only credential Traefik holds; it never sees bucket credentials.
env:
- name: SCAFO_INGRESS_TOKEN
valueFrom:
secretKeyRef:
name: scafo-ingress
key: SCAFO_INGRESS_TOKEN
# Scafo's routers are attached to `websecure` (SCAFO_TRAEFIK_ENTRYPOINTS);
# `asDefault` keeps operator-owned Kubernetes routes on the same entryPoint.
ports:
websecure:
asDefault: true
http:
tls:
enabled: true
certResolver: letsencrypt
# For domains an application claimed: their DNS points at this cluster, so
# HTTP-01 on the `web` entryPoint resolves without a DNS-provider plugin.
# The platform apex and its wildcard are not issued here — cert-manager holds
# them, and ACME skips a host the certificate store already covers.
certificatesResolvers:
letsencrypt:
acme:
email: "operator@example.com"
storage: /data/acme.json
httpChallenge:
entryPoint: web
# Issued certificates survive a restart only on a volume. The chart mounts
# the claim at /data as uid 65532; acme.json must exist with mode 600 before
# Traefik reads it, which is what the init container is for.
persistence:
enabled: true
size: 128Mi
deployment:
initContainers:
- name: volume-permissions
image: busybox:latest
command: ["sh", "-c", "touch /data/acme.json; chmod -v 600 /data/acme.json"]
volumeMounts:
- name: data
mountPath: /data
podSecurityContext:
fsGroup: 65532Important
The filename must be RFC-1123. Never edit the neighbouring traefik.yaml: K3s rewrites it on every start.
#Certificates
- The platform apex and
*.apps.example.comcome from cert-manager. The rules-lessscafo-platform-tlsIngress exists only to put that Secret into Traefik's certificate store, which is matched by SNI for the@httprouters. - A custom domain an application claimed is issued by Traefik itself, HTTP-01 on the
webentryPoint, once its DNS points at the cluster edge. Traefik skips any host the store already covers, so the wildcard is never re-issued.
#Operating constraints
SCAFO_LISTEN=0.0.0.0:7400is what lets thescafo-apiService reach the pod; it is a bind on the pod network and nothing more.SCAFO_TRANSPORT=nonestates that the pod network is private and trusted, and with it runtime processes bind the pod address, which is how Traefik reaches a host's holder on any node. A pod network that is not private needs a different transport decision.- The StatefulSet gives each node a retained
1Giclaim through K3s's default StorageClass, never anemptyDir. It preserves the node's identity across replacement and holds the working directory of every runtime process the pod supervises; a pod that comes back on an empty claim comes back having lost writes this fleet acknowledged. K3s's default StorageClass is node-local, so a pod whose node is gone staysPendinguntil that node is back rather than restarting empty. Size the claim and the retention policy for your recovery plan; see durability and the state directory. - Container classes are unsupported on Kubernetes until Celld supports them there.
- The StatefulSet starts with one replica and scales out: the routing Traefik polls names every pod serving a host, so a new node joins the edge as soon as it runs something.
- Shutdown grace, PID namespace and the bucket as control plane are the common prerequisites; the manifest already sets
terminationGracePeriodSeconds: 50,hostPID: falseandshareProcessNamespace: false.