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.headers and cannot present the ingress token. RKE2 works the same way; its manifests directory is /var/lib/rancher/rke2/server/manifests/ and its chart is named rke2-traefik.
  • An external S3-compatible store, reachable from every pod, honouring If-Match / If-None-Match conditional writes.
  • cert-manager, installed separately (K3s does not bundle it), with a ClusterIssuer that 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

  1. Set the non-secret scafo-node ConfigMap values: S3 endpoint, bucket, region and prefix. Replace apps.example.com consistently in the ConfigMap, the Certificate and the Ingress.
  2. Set the image's VERSION tag to a published Scafo release. Never use a floating tag.
  3. Set Certificate.spec.issuerRef.name to 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/health

The 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; echo

SCAFO_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 shop

Nothing 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

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-tls

Two 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.

traefik-config.yaml
# 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: 65532

Important

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.com come from cert-manager. The rules-less scafo-platform-tls Ingress exists only to put that Secret into Traefik's certificate store, which is matched by SNI for the @http routers.
  • A custom domain an application claimed is issued by Traefik itself, HTTP-01 on the web entryPoint, 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:7400 is what lets the scafo-api Service reach the pod; it is a bind on the pod network and nothing more. SCAFO_TRANSPORT=none states 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 1Gi claim through K3s's default StorageClass, never an emptyDir. 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 stays Pending until 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: false and shareProcessNamespace: false.