Skip to content

Kubernetes

The Helm chart, and what it refuses to generate

Edit this page
On 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:

bash
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:

bash
helm install loomscope infra/helm/loomscope \
  --namespace loomscope \
  --set secrets.existingSecret=loomscope \
  --set externalDatabase.existingSecret=loomscope-db \
  --set server.publicUrl=https://loomscope.example.com

The 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:

bash
kubectl get nodes -o name | cut -d/ -f2

Then:

bash
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-daemon

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

yaml
migrations:
  enabled: true
  backoffLimit: 1
  activeDeadlineSeconds: 900

The 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:

yaml
externalDatabase:
  existingSecret: loomscope-db
  key: DATABASE_URL

A bundled PostgreSQL is available for evaluation and is off:

yaml
postgresql:
  enabled: false

Turning 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_connections

Leave headroom for a rolling update, which runs old and new pods at once.

The scanner DaemonSet

yaml
daemon:
  enabled: true
  hostNetwork: true
  runAsRoot: true
  capabilities: [NET_RAW]
  nodeSelector: {}
  tolerations:
    - key: node-role.kubernetes.io/control-plane
      operator: Exists
      effect: NoSchedule

hostNetwork: 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:

yaml
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

yaml
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:

yaml
annotations:
  nginx.ingress.kubernetes.io/proxy-buffering: "off"
  nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"

Worker replicas

yaml
worker:
  replicaCount: 1

Safe 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:

yaml
server:
  ai:
    enabled: true
    existingSecret: loomscope-ai
    anthropicKey: LOOMSCOPE_ANTHROPIC_API_KEY

Verifying an install

bash
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/health

The chart is linted with kubeconform in CI against a real API schema, and installed into a kind cluster on every change to it.