Kubernetes
The Helm chart, and what it refuses to generate
Edit this pageOn this page
A Helm chart lives in infra/helm/loomscope. It installs the control plane
as a Deployment, the scheduled worker as a second Deployment, and the scanner
as a DaemonSet — because what a scanner can see depends on which nodes it
runs on.
Install
The chart references secrets and never generates them. Create them first:
kubectl create namespace loomscope
kubectl -n loomscope create secret generic loomscope \
--from-literal=LOOMSCOPE_KMS_KEY="$(openssl rand -base64 32)" \
--from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
kubectl -n loomscope create secret generic loomscope-db \
--from-literal=DATABASE_URL="postgres://user:pass@host:5432/loomscope"Then install:
helm install loomscope infra/helm/loomscope \
--namespace loomscope \
--set secrets.existingSecret=loomscope \
--set externalDatabase.existingSecret=loomscope-db \
--set server.publicUrl=https://loomscope.example.comThe daemon needs a key minted in the UI, so it comes second — sign in and create one under Settings → Daemons.
Name it after the node it will run on. The chart runs the scanner as a DaemonSet and each pod registers under its own node's name, so a daemon enrolled under any other name is refused with a 404 and the pod restarts. The name cannot be set from the chart: one name shared by every pod would make an estate look like a single scanner that keeps moving. Enrol one per node, under exactly these names:
kubectl get nodes -o name | cut -d/ -f2Then:
kubectl -n loomscope create secret generic loomscope-daemon --from-literal=LOOMSCOPE_DAEMON_API_KEY=lsd_...
helm upgrade loomscope infra/helm/loomscope --namespace loomscope --reuse-values --set daemon.existingSecret=loomscope-daemonTo give the scanner nmap, point daemon.image at an image you built with
one — see Giving a daemon nmap.
Installing nmap on the node does nothing: the daemon's runtime is
distroless and cannot see the host's filesystem.
Why the chart will not generate your secrets
A chart that generates LOOMSCOPE_KMS_KEY on install is a chart that
generates a new one on upgrade, and every credential in the database
becomes permanently unreadable. Helm's lookup-and-keep patterns do not
survive --dry-run, a templating error, or an install into a fresh namespace
from the same values.
So the chart fails to render without secrets.existingSecret. That is a
worse first five minutes and a much better second year.
Migrations run as a hook
This is the one place Kubernetes does better than the Compose path. A Helm hook Job runs once, before install and before upgrade, and Helm waits for it to succeed before touching the Deployments — so a failed migration stops the rollout instead of leaving new code running against an old schema.
migrations:
enabled: true
backoffLimit: 1
activeDeadlineSeconds: 900The Compose path tells you to run migrations by hand precisely because several server replicas would otherwise race each other to apply them.
The database
External by default:
externalDatabase:
existingSecret: loomscope-db
key: DATABASE_URLA bundled PostgreSQL is available for evaluation and is off:
postgresql:
enabled: falseTurning it on means accepting that the database's availability, backups, failover and PITR are now this chart's problem — and it has none of them while looking like it does. A cluster operator running Loomscope for real already has a database strategy.
Connection budget
server.dbPoolMax (default 25) is the PostgreSQL connections each
process may hold across all its internal pools. It is not a per-replica
free-for-all: every server replica, every worker and the migration Job draws
from the same database.
(server.replicaCount + worker.replicaCount + 1) × dbPoolMax < max_connectionsLeave headroom for a rolling update, which runs old and new pods at once.
The scanner DaemonSet
daemon:
enabled: true
hostNetwork: true
runAsRoot: true
capabilities: [NET_RAW]
nodeSelector: {}
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedulehostNetwork: true because ARP needs layer-2 adjacency and the NetFlow
listeners need traffic addressed to the node.
runAsRoot: true with only NET_RAW for the same reason as the Compose
path: the image runs as uid 65532, and a capability granted to a non-root
process lands in the permitted set but never the effective one — so the ICMP
sweep cannot open its socket and a scan finds zero hosts.
The unprivileged alternative is a net.ipv4.ping_group_range that includes
the daemon's group. Kubernetes treats that sysctl as safe and would allow it
per-pod, but not alongside hostNetwork, which the kubelet refuses. Set
it on the node instead, and then:
daemon:
runAsRoot: false
capabilities: []The default toleration puts a scanner on control-plane nodes too. Remove it if those segments are not networks you care about — but removing it means they are not scanned, and nothing will say so.
Ingress
server:
publicUrl: https://loomscope.example.com
ingress:
enabled: true
className: nginx
hosts:
- host: loomscope.example.com
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: loomscope-tls
hosts: [loomscope.example.com]server.publicUrl must match what the browser shows. Better-Auth signs
callbacks against it, so a mismatch produces sign-ins that appear to work and
then fail on redirect.
Server-Sent Events need proxy buffering off. On ingress-nginx:
annotations:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"Worker replicas
worker:
replicaCount: 1Safe above one: every tick takes a PostgreSQL advisory lock, so a second replica skips rather than duplicates. More replicas buy redundancy, not throughput, and each one costs connection budget.
The AI assistant
Off unless a key is supplied, and keys come from a Secret rather than from values:
server:
ai:
enabled: true
existingSecret: loomscope-ai
anthropicKey: LOOMSCOPE_ANTHROPIC_API_KEYVerifying an install
helm test loomscope -n loomscope 2>/dev/null || true
kubectl -n loomscope get pods
kubectl -n loomscope logs job/loomscope-migrate
kubectl -n loomscope port-forward svc/loomscope 3000:3000
curl -fsS http://localhost:3000/api/healthThe chart is linted with kubeconform in CI against a real API schema, and
installed into a kind cluster on every change to it.