helm

streaming-hub Helm chart

Deploys streaming-hub — the Lerian service that consumes lib-streaming CloudEvents from Kafka/Redpanda and fans them out to per-tenant SaaS/BYOC subscribers.

This is a multi-component chart built on one image and one binary, selected at runtime into a role via STREAMING_HUB_ROLE. There are no database, broker, or OTEL subcharts — Kafka/Redpanda and PostgreSQL are shared external infra, and OTEL is env-wired (see External dependencies).


Chart Contract


Requirements of the 2.x line

appVersion 2.1.0 asks for four things the 1.x line did not. They do not all behave the same way when missing, and the difference matters when you are reading a pod that will not start — or one that started and is enforcing nothing. All four are environment wiring the chart exposes but does not invent.

Refuse the boot

Requirement Where Without it
STREAMING_HUB_DASHBOARD_CURSOR_HMAC_KEY, 32 bytes, base64 Secret (always emitted; pinned via secretKeyRef) Mandatory in EVERY mode. Absent or short, the app refuses to start. The value is never echoed — supply it through the gitops/Vault path (useExistingSecret), never in a values file in git.
A DSN that verifies the server certificate: sslmode=verify-full plus sslrootcert=/etc/streaming-hub/postgres-ca/bundle.pem Secret (the DSN) + postgres.caBundle (the file) In staging and production the hardened boot rejects sslmode=require with no CA. Turn on postgres.caBundle.enabled so the bundle exists at that path — see Postgres TLS and the CA bundle.
RATE_LIMIT_ENABLED=true together with a Redis address that works ConfigMap (streamingHub.rateLimit / configmap.RATE_LIMIT_ENABLED) With enforcement asked for and an address set, the connection is validated at boot: an address the hub cannot use stops it rather than serving unlimited.

Degrade instead

Situation What actually happens
STREAMING_HUB_REDIS_ADDRESS left empty No crash. lib-commons builds a pass-through limiter and every request is served unlimited, silently — see Rate limiting. This is the failure mode to watch for, because the pod looks healthy.
The address points at the tenant-manager’s Redis Also no crash: nothing at boot detects this. Giving the hub its own instance is a Lerian decision about blast radius and quota, not something the app enforces — the control-plane store is a different tenant of the same technology, not a shared cache. Getting it wrong is silent, so it is the operator’s job to point STREAMING_HUB_REDIS_ADDRESS at an instance dedicated to the hub.

License (lib-license-go, GLOBAL mode)

The license-gated streaming-hub release (streaming-hub#153 and later) refuses to boot without a valid Lerian license, in every environment. Three unprefixed variables carry the contract. They follow the platform’s lib-license-go convention, so a STREAMING_HUB_-prefixed spelling reads empty.

Variable Where Default Without it / wrong
LICENSE_KEY Secret: secrets.LICENSE_KEY (always emitted, even empty), or the Secret named by existingSecretName, where the Deployments pin it via secretKeyRef "" The app refuses to start (ErrMissingLicenseKey). On the useExistingSecret path a Secret without the key fails container creation (CreateContainerConfigError, “couldn’t find key LICENSE_KEY”). Supply it through Vault/ExternalSecret, never in a values file in git.
ORGANIZATION_IDS ConfigMap: configmap.ORGANIZATION_IDS global Must be exactly global: the hub supports global license mode only. Any other value (GLOBAL, an organization id, a comma list) fails the render, and the app would refuse it at boot (ErrLicenseOrganizationIDsInvalid).
IS_DEVELOPMENT ConfigMap: configmap.IS_DEVELOPMENT (emit-when-set) unset Read by the library. Exactly "true" validates against the development license server (license.dev.lerian.io), which a development license key needs; anything else uses the production server. "true" together with STREAMING_HUB_ENV=production fails the render, and the app would refuse it at boot (ErrLicenseDevServerInProduction).

streamingHub.extraEnvVars wins over the ConfigMap, so the render guard judges the effective value there too. It also refuses extraEnvVars.LICENSE_KEY, because that would put the license in plaintext on the Deployment spec.

Rollout order on the useExistingSecret path. From this chart version on, every Deployment pins LICENSE_KEY by name, whatever the image tag. Add the key to the Vault/ExternalSecret source before you roll the chart out. That includes environments still pinned to a pre-license image, which ignores the value but cannot start without the key being present.


Pod Spreading (global.scheduling.spread)

Every component Deployment (all, ingest, delivery) renders topologySpreadConstraints from the lerian-common spread preset. The default is soft (ScheduleAnyway) across nodes, so replicas spread across nodes (e.g. spot nodes) whenever possible, but a pod is never left Pending because of it. Zone spreading is off by default (zone: ""): on clusters without the topology.kubernetes.io/zone label a zone constraint silently cancels the node spread.

Precedence: streamingHub.<component>.topologySpreadConstraints (non-empty) > streamingHub.topologySpreadConstraints > the preset, whose fields resolve streamingHub.<component>.spread.<field> > streamingHub.spread.<field> > global.scheduling.spread.<field>. Each constraint counts only the component’s own pods of the same ReplicaSet (matchLabelKeys: [pod-template-hash]).

On EKS + Karpenter use a hard node spread:

global:
  scheduling:
    spread: { hostname: DoNotSchedule, zone: ScheduleAnyway, maxSkew: 1, minDomains: 2, nodeTaintsPolicy: Honor }

minDomains: 2 is required with DoNotSchedule: skew is measured only against existing nodes, so a single eligible node would otherwise take both replicas.

Installing the Chart

helm install streaming-hub oci://registry-1.docker.io/lerianstudio/streaming-hub-helm --version <version> -n streaming-hub --create-namespace

With a custom values file:

helm install streaming-hub oci://registry-1.docker.io/lerianstudio/streaming-hub-helm --version <version> -n streaming-hub -f my-values.yaml

The chart is mirrored to oci://ghcr.io/lerianstudio/streaming-hub-helm as well.

Uninstalling the Chart

helm uninstall streaming-hub -n streaming-hub

The mode switch (read this first)

streamingHub.mode decides the topology. It is an either/or:

mode Renders STREAMING_HUB_ROLE When
all (default) ONE Deployment + Service (+ HPA/PDB) all Single co-resident deployment, for single-instance setups. Byte-equivalent to the historical single binary.
split TWO Deployments + Services (ingest + delivery), each with its own HPA/PDB ingest / delivery Ingest and delivery scaled independently as N + M replicas.

> DOUBLE-CONSUME HAZARD — the load-bearing rule

NEVER run a mode: all release AND a mode: split release against the same Kafka/Redpanda cluster. Both consuming roles (all and ingest) join the one ingest consumer group. An all pod and an ingest pod consuming together means every event is double-consumed and double-delivered. The mode switch enforces either/or within a single release — do not defeat it by deploying two releases that overlap.

Worked example — mode: all

streamingHub:
  mode: all
  all:
    replicaCount: 1
    poolMaxOpenConns: 25   # role=all holds both planes' connections
    poolMaxIdleConns: 12

Renders: streaming-hub-all Deployment + streaming-hub-all Service, plus the shared streaming-hub ConfigMap + Secret + ServiceAccount. One consumer-group member set — no double-consume possible.

Worked example — mode: split (independent scaling)

streamingHub:
  mode: split
  ingest:
    replicaCount: 3
    poolMaxOpenConns: 8    # consume-poll + inbox tx + partition-cron
    poolMaxIdleConns: 4
    autoscaling: { enabled: true, minReplicas: 3, maxReplicas: 4 }
  delivery:
    replicaCount: 2
    poolMaxOpenConns: 16   # 8 dispatch workers + reclaim + dlq + delivery /readyz
    poolMaxIdleConns: 10
    autoscaling: { enabled: true, minReplicas: 2, maxReplicas: 4 }

Renders streaming-hub-ingest and streaming-hub-delivery Deployments + Services + HPAs, sharing the one ConfigMap + Secret. Each Deployment gets its own STREAMING_HUB_ROLE and pool sizing as explicit per-Deployment env (which wins over the shared envFrom).


Connection-budget invariant

streaming-hub owns ONE shared PostgreSQL database. Every open connection on every pod of every role draws from that single max_connections budget.

Σ over all running pods of (replicas × poolMaxOpenConns) + headroom ≤ PostgreSQL max_connections. Under HPA, use maxReplicas (not replicaCount) in the sum.

Worked example (max_connections = 100, mode: split):

Role replicas poolMaxOpenConns connections
ingest 3 8 24
delivery 4 16 64
total     88 (leaves 12 for admin/migrations) ✅

Adding an all pod (25) → 88 + 25 = 113 > 100 ❌. The mode either/or already forbids that combination; the budget math is why it also matters operationally. See streaming-hub/.env.reference (STREAMING_HUB_POSTGRES_MAX_OPEN_CONNS) for the per-role rationale (all 25/12, ingest 8/4, delivery 16/10).


Health, drain, and termination


Secrets sourcing

Two mutually-exclusive paths:

useExistingSecret Behavior
false (default) The chart renders templates/secrets.yaml from streamingHub.secrets (base64-encoded). STREAMING_HUB_POSTGRES_DSN is ALWAYS emitted (even empty) so a bare helm template succeeds; an empty DSN fails the app fast at boot. Empty optional keys are skipped.
true No Secret is rendered. Deployments reference existingSecretName. This is the gitops/Vault path (an external secret is projected into the named Secret) and is the production default. Key contract: the named Secret MUST carry STREAMING_HUB_POSTGRES_DSN, STREAMING_HUB_DASHBOARD_CURSOR_HMAC_KEY and LICENSE_KEY — each Deployment pins all three via explicit secretKeyRefs, so a Secret missing any of them fails container creation (CreateContainerConfigError, “couldn’t find key”) instead of CrashLooping the app.

Sensitive keys (all in streamingHub.secrets, all default ""): STREAMING_HUB_POSTGRES_DSN, STREAMING_HUB_KAFKA_CA_CERT, STREAMING_HUB_DEV_KEK (dev only) and STREAMING_HUB_DASHBOARD_CURSOR_HMAC_KEY (32 bytes base64, mandatory from 2.x) and LICENSE_KEY (mandatory in every environment on a license-gated image — see License) — these five are ALWAYS emitted (even empty) so a bare render succeeds and their non-credential-shaped names are still counted by the coverage check; plus STREAMING_HUB_KAFKA_SCRAM_PASSWORD, STREAMING_HUB_REDIS_PASSWORD (rate-limiter AUTH; may legitimately stay empty) and POSTGRES_PASSWORD (migration-toolchain parity; no in-cluster consumer), all emit-when-set. Not secrets (live in the ConfigMap): ORGANIZATION_IDS / IS_DEVELOPMENT (the license mode and server selector), STREAMING_HUB_KAFKA_SCRAM_USERNAME (username, not a credential — and emit-when-set there too: the Deployment reads the Secret first and the ConfigMap second, so an unconditional empty value here would overwrite a username projected from Vault into the same key; set it in one place, never both), STREAMING_HUB_KEK_REF (a POINTER — the NAME of the env var holding the KEK material) and STREAMING_HUB_REDIS_ADDRESS / STREAMING_HUB_REDIS_TLS / STREAMING_HUB_REDIS_CA_CERT (an endpoint, a flag, and a public CA cert — the credential travels in its own key).


External dependencies

This chart provisions none of the following — they live outside it:


Postgres TLS and the CA bundle

The DSN decides whether the server certificate is verified; the chart only makes the certificate authority available to verify it against. Set postgres.caBundle.enabled=true and the bundle is projected read-only at

/etc/streaming-hub/postgres-ca/bundle.pem

in all three roles (all / ingest / delivery) and in the migrations Job — migrate/migrate dials the same DSN, so it needs the same file. Point the DSN’s sslrootcert at that path (sslmode=verify-full&sslrootcert=/etc/streaming-hub/postgres-ca/bundle.pem).

Two mutually-exclusive sources, and enabling with neither fails the render on purpose (a volume naming a ConfigMap that does not exist hangs every pod in ContainerCreating with no useful message):

Value Behavior
postgres.caBundle.pem The chart renders <fullname>-postgres-ca holding the PEM under the key bundle.pem. For AWS RDS this is the PUBLIC global-bundle.pem, which is why a ConfigMap — not a Secret — is the right home.
postgres.caBundle.existingConfigMap An operator-managed ConfigMap is mounted instead. Key contract: it MUST carry bundle.pem; the volume pins that single key, so a ConfigMap keyed otherwise blocks pod creation with a clear kubelet event instead of booting the app with no certificate at the path.

Setting both fails the render: the chart would build a ConfigMap from pem that nothing ever mounts while every workload silently verified against the other one.

With existingConfigMap, that ConfigMap has to exist BEFORE the sync starts. The migrations Job is a PreSync hook, so a ConfigMap the same Application creates at wave 0 does not exist yet when the Job’s pod is scheduled and the Job sits in ContainerCreating until it times out. Create it outside the chart (the estate does), or use pem and let the chart render the PreSync copy for you.

On the chart-managed (pem) path with migrations.enabled=true, a PreSync copy of the ConfigMap (<fullname>-migrations-postgres-ca, sync-wave -5) is rendered alongside it. The migrations Job is a PreSync hook at wave -1 and Helm applies normal resources only after every pre-install hook has run, so the main-Sync ConfigMap does not exist yet when the Job’s pod is created — the same ordering gap migration-secret closes for the DSN, with the same before-hook-creation-only delete policy (a hook-succeeded policy would delete it at the end of wave -5, before the Job at wave -1 mounts it).


Rate limiting

Live from the 2.x line. appVersion 2.1.0 reads STREAMING_HUB_REDIS_ADDRESS and expects RATE_LIMIT_ENABLED=true. The store has to be an instance dedicated to the hub, and nothing at boot checks that — point it at the tenant-manager’s Redis and it just works, wrongly. An empty address does not crash either; it serves every request unlimited. See Requirements of the 2.x line. (On 1.7.0 and earlier these keys were inert.)

The control plane rate-limits its HTTP API through the lib-commons three-tier contract, backed by Redis/Valkey, which this chart does not provision. The whole contract is rendered by lerian-common.rateLimit.env, so streaming-hub exposes the same keys, defaults and productized knobs as every other Lerian product. The ConfigMap is shared by all roles, so the keys reach every pod.

The connection (streamingHub.datastores.redis, or global.datastores.redis for the shared tier; configmap.<KEY> overrides either):

Key Mask field Default What it does
STREAMING_HUB_REDIS_ADDRESS host "" Full host:port. Required for enforcement.
STREAMING_HUB_REDIS_TLS tls "true" TLS to Redis.
STREAMING_HUB_REDIS_CA_CERT caCert "" Base64 PEM CA bundle. Empty trusts the container’s system roots.
STREAMING_HUB_REDIS_PASSWORD — (secrets) "" AUTH password. Emit-when-set — leave empty for an instance that takes none.

The limits (streamingHub.rateLimit.<field>; configmap.<KEY> overrides):

Key Field Default
RATE_LIMIT_ENABLED enabled "true"
ALLOW_RATELIMIT_DISABLED allowDisabled ""
ALLOW_RATELIMIT_FAIL_OPEN allowFailOpen "true" (chart sets it — see below)
RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_SEC max / windowSec 500 / 60
AGGRESSIVE_RATE_LIMIT_MAX / ..._WINDOW_SEC aggressiveMax / aggressiveWindowSec 100 / 60
RELAXED_RATE_LIMIT_MAX / ..._WINDOW_SEC relaxedMax / relaxedWindowSec 1000 / 60
RATE_LIMIT_REDIS_TIMEOUT_MS redisTimeoutMs 500

ALLOW_RATELIMIT_FAIL_OPEN is a security bypass, and lib-commons says so. The library forbids fail-open by default and, when permitted, logs security bypass active for the life of the process. This chart sets streamingHub.rateLimit.allowFailOpen: "true" because the hub’s intended posture is that a Redis outage degrades enforcement rather than taking the control plane down. Unset it for fail-closed, where an unreachable limiter refuses the request.

Three reasonable defaults compose into silent allow-all. An empty STREAMING_HUB_REDIS_ADDRESS makes lib-commons build a pass-through limiter; so does TLS="true" against a private-CA Redis with no CA_CERT, because the handshake fails and fail-open then allows everything. Neither fails the render nor crashes the pod. If an environment must actually enforce a limit, set the address, and set the CA when the chain is private.

This Redis is not the multi-tenant registry, and the two must be separate instances. MULTI_TENANT_REDIS_* points at the tenant-manager’s Valkey, which the hub subscribes to for tenant-lifecycle events; STREAMING_HUB_REDIS_* is the hub’s own rate-limiter store. Nothing at boot checks that they differ, so pointing one at the other just works, wrongly. Enabling rate limiting does not turn on the tenant-lifecycle bus, and vice versa.

STREAMING_HUB_PULL_RATE is unrelated and unchanged: it is the app’s own inbound pull gate on GET /v1/events, not part of this contract. STREAMING_HUB_PULL_BURST was its burst half and is retired on 2.x — the fixed window has no separate burst allowance, so the chart ships no default for it and a stale value earns one WARN per boot.


Top-level values

Key Default Description
streamingHub.mode all Topology switch: all | split. Schema-enforced enum.
streamingHub.image.repository ghcr.io/lerianstudio/streaming-hub Image repo.
streamingHub.image.tag "" Empty falls back to Chart.appVersion.
streamingHub.image.pullPolicy IfNotPresent  
streamingHub.imagePullSecrets [] Private registry pull secrets.
streamingHub.service.type ClusterIP Lerian convention (Ingress fronts external).
streamingHub.service.port 8080 Control-plane HTTP port.
streamingHub.ingress.enabled false Control-plane API ingress (opt-in per env).
streamingHub.serviceAccount.create true  
streamingHub.terminationGracePeriodSeconds 80 Defaults to the ~80s drain ceiling; keep at or above it.
streamingHub.securityContext nonroot 65532, drop ALL, RO rootfs, RuntimeDefault Distroless:nonroot.
streamingHub.useExistingSecret false true = Vault/gitops path.
streamingHub.existingSecretName "" Required when useExistingSecret.
streamingHub.configmap {} Non-sensitive env override hatch (defaults live in templates/configmap.yaml). configmap.<KEY> overrides an enumerated (allowlisted) key — the schema propertyNames.enum rejects a key outside the allowlist; use extraEnvVars to inject one that is not enumerated. See README.params.md.
streamingHub.datastores {} Dedicated datastore masks: postgres (host/port/user/name/ssl) and redis (host carries host:port, tls, caCert). global.datastores.<type> is the shared tier.
streamingHub.rateLimit {allowFailOpen: "true"} lib-commons rate-limit contract (10 keys) via lerian-common.rateLimit.env. See Rate limiting.
streamingHub.extraEnvVars {} Per-Deployment env hatch (key → value map). Never credentials.
streamingHub.secrets (all "") Shared sensitive env. DSN always emitted; other empty values skipped.
streamingHub.secrets.STREAMING_HUB_DASHBOARD_CURSOR_HMAC_KEY "" Dashboard cursor signing key, 32 bytes base64. Mandatory from appVersion 2.x; always emitted and pinned via secretKeyRef.
streamingHub.secrets.LICENSE_KEY "" Lerian license key (lib-license-go, GLOBAL mode). Mandatory in every environment on a license-gated image; always emitted and pinned via secretKeyRef. See License.
streamingHub.configmap.ORGANIZATION_IDS global (template default) License mode. Must be exactly global; any other value fails the render.
streamingHub.configmap.IS_DEVELOPMENT unset Emit-when-set. "true" selects the development license server; refused with STREAMING_HUB_ENV=production.
streamingHub.secrets.STREAMING_HUB_REDIS_PASSWORD "" Rate-limiter Redis AUTH password. Emit-when-set; may stay empty.
streamingHub.<role>.replicaCount 1 Per role: all / ingest / delivery.
streamingHub.<role>.poolMaxOpenConns all 25 / ingest 8 / delivery 16 Postgres pool (connection-budget invariant).
streamingHub.<role>.poolMaxIdleConns all 12 / ingest 4 / delivery 10  
streamingHub.<role>.autoscaling.enabled false HPA per role (autoscaling/v2, CPU+memory).
streamingHub.<role>.pdb.enabled false PodDisruptionBudget per role (policy/v1).
global.externalPostgresDefinitions.enabled false Bootstrap Job for the hub’s one DB/role (PreSync, sync-wave -10).
streamingHub.migrations.enabled false Out-of-band schema migration Job (PreSync, sync-wave -1).
streamingHub.migrations.image.repository ghcr.io/lerianstudio/streaming-hub-migrations Migrations image (FROM migrate/migrate + the hub’s migrations/).
streamingHub.migrations.image.tag "" Empty falls back to streamingHub.image.tag, then Chart.appVersion.
streamingHub.migrations.image.digest "" Pin by digest; wins over tag.
streamingHub.migrations.useExistingSecret false true = Job reads STREAMING_HUB_POSTGRES_DSN from existingSecretName.
streamingHub.migrations.existingSecretName "" Required when migrations.useExistingSecret. Must already exist before the PreSync phase — not the main-Sync app Secret.
streamingHub.migrations.backoffLimit 3 Job retry cap.
streamingHub.migrations.activeDeadlineSeconds 600 Job wall-clock cap.
streamingHub.migrations.ttlSecondsAfterFinished 600 Finished-Job GC TTL.
postgres.caBundle.enabled false Project the Postgres server CA at /etc/streaming-hub/postgres-ca/bundle.pem in all three roles and the migrations Job. See Postgres TLS and the CA bundle.
postgres.caBundle.pem "" The PEM itself; the chart renders the ConfigMap holding it.
postgres.caBundle.existingConfigMap "" Mount an operator-managed ConfigMap instead. Must carry the key bundle.pem.

For the full env contract (defaults, required-in-SaaS markers, the F4 tenant caution, KEK source vars), see streaming-hub/.env.reference.

See docs/TOPOLOGY.md for the role model in depth.