Deployment

Kubernetes deployment guide — S3 ingestion, Helm configuration, license governance, theming, and operations.

This guide covers deploying BOMHort to a Kubernetes cluster using Helm.

Prerequisites

  • Kubernetes cluster (1.27+)
  • ClickHouse Operator installed
  • Helm 3.x
  • Container images pushed to a registry (e.g. ghcr.io/seebom-labs/bomhort/*)

Supported ClickHouse versions

The supported floor is the ClickHouse 24.8 LTS line (#392). The chart installs clickhouse/clickhouse-server:24.8.14.39, pinned by digest so a re-pushed tag cannot change what gets installed. LTS because production should run a release that keeps receiving fixes.

CI runs the migrations and every API query against that exact image and against 24.12, the version docker-compose.yml uses for local development. A query that only works on the newer version fails CI instead of production. deploy/helm/tests/test_clickhouse_version.py fails if the chart, the CI matrix and docker-compose drift apart.

Running a newer ClickHouse via clickhouse.installation.podTemplate.containerImage works, but only 24.8 and 24.12 are tested. Older than 24.8 is unsupported.

Chart source: published release or working tree

The examples on this page install from the working tree (deploy/helm/bomhort/). For a published release, use the OCI chart instead — it deploys the images of exactly that release:

# Final release (recommended)
helm install bomhort oci://ghcr.io/seebom-labs/bomhort/charts/bomhort --version 0.8.0 \
  -n bomhort -f my-values.yaml

# Release candidate — for testing an upcoming release; Helm only installs it when asked for the exact version
helm install bomhort oci://ghcr.io/seebom-labs/bomhort/charts/bomhort --version 0.8.0-rc.1 \
  -n bomhort -f my-values.yaml

Leave image.tag unset: the chart then uses its own appVersion, so chart and images always match. RCs never move the latest image tag. See Release → Installing a Release Candidate.


1. SBOMs – Getting Data Into the Cluster

BOMHort supports multiple SBOM ingestion methods. S3 bucket ingestion is the default and recommended approach — it requires no PVCs, no volume scheduling, and scales to any number of SBOMs.

Ingest SBOMs directly from S3-compatible buckets (AWS S3, MinIO, GCS). The Ingestion Watcher streams object listings with pagination and the Parsing Workers fetch objects on-demand.

Single public bucket:

s3:
  buckets: '[{"name":"my-org-sboms","region":"us-east-1"}]'

Multiple buckets:

s3:
  buckets: '[{"name":"my-org-sboms","region":"us-east-1"},{"name":"platform-sboms","region":"us-east-1"}]'

Private buckets with credentials:

s3:
  buckets: '[{"name":"my-private-bucket","region":"eu-west-1"}]'
  accessKey: ""   # pass via --set or K8s Secret
  secretKey: ""   # pass via --set or K8s Secret
helm install bomhort deploy/helm/bomhort/ -n bomhort -f my-values.yaml \
  --set s3.accessKey="AKIA..." \
  --set s3.secretKey="..."

Private buckets with an existing Kubernetes Secret (recommended for production):

Instead of passing credentials as plain Helm values, you can reference a pre-existing Kubernetes Secret — the same pattern used for ClickHouse and GitHub credentials:

s3:
  buckets: '[{"name":"my-private-bucket","region":"eu-west-1"}]'
  credentialsSecret:
    enabled: true
    secretName: "my-s3-credentials"
    accessKeyKey: "S3_ACCESS_KEY"   # key inside the Secret
    secretKeyKey: "S3_SECRET_KEY"   # key inside the Secret

Create the Secret first:

kubectl create secret generic my-s3-credentials \
  --from-literal=S3_ACCESS_KEY="AKIA..." \
  --from-literal=S3_SECRET_KEY="..." \
  -n bomhort

This avoids storing credentials in Helm values files or command history.

MinIO (local S3-compatible):

s3:
  buckets: '[{"name":"sboms","endpoint":"minio.minio.svc:9000","usePathStyle":true,"useSSL":false}]'
  accessKey: "minioadmin"
  secretKey: "minioadmin"

Advantages:

  • No PVC, no volume scheduling, no pod affinity constraints
  • Streams object listings — handles 100k+ SBOMs without memory issues
  • Works with any S3-compatible storage (AWS, GCS, MinIO, Ceph, DigitalOcean Spaces)

Multi-Cluster Ingestion

BOMHort supports tagging data by cluster for multi-cluster visibility from a single instance. This is fully optional — omit all cluster config for single-instance mode.

Option 1: Global cluster name (one instance per cluster)

# values-prod-eu.yaml
ownership:
  cluster: "prod-eu"

Deploy one BOMHort instance per cluster, each with its own cluster value.

Option 2: Per-bucket cluster assignment (one instance, multiple clusters)

# values.yaml — single watcher, multiple clusters
ownership:
  cluster: "default"   # fallback for buckets without explicit cluster

s3:
  buckets: |
    [
      {"name": "prod-eu-sboms", "region": "eu-west-1", "cluster": "prod-eu"},
      {"name": "prod-us-sboms", "region": "us-east-1", "cluster": "prod-us"},
      {"name": "staging-sboms", "cluster": "staging"},
      {"name": "shared-sboms"}
    ]

In this example:

  • prod-eu-sboms → all SBOMs tagged as prod-eu
  • prod-us-sboms → tagged as prod-us
  • staging-sboms → tagged as staging
  • shared-sboms → inherits ownership.cluster = default

Priority: per-bucket cluster > global ownership.cluster > empty (untagged)

Cluster is only one of the ownership dimensions — see Ownership for namespace, project, tags and deriving the triple from the ingestion path.

Option B: Seed Job (removed in 0.8.0)

Charts up to 0.7.x rendered a seed job whenever git-sync was disabled. Its git clone had been commented out since March 2026, so it cloned nothing and waited forever for a file nothing created — blocking helm install --wait for S3 users too. It was removed in 0.8.0 (#391).

Values that still contain a seedJob block now fail the render with a message pointing here. Remove the block and pick one of the other options: S3 (Option A) for any size, git-sync (Option C) for small repos, or a PVC you populate yourself (Option D) for large Git repos without S3.

Option C: git-sync (small repos < 1 GB)

s3:
  buckets: ""

gitSync:
  enabled: true
  repo: "https://github.com/your-org/sbom-repo.git"
  branch: main

⚠️ git-sync struggles with large repos (multi-GB). Use S3 or a pre-populated PVC instead.

Option D: Pre-populated PVC

s3:
  buckets: ""
gitSync:
  enabled: false

sbomSource:
  pvcName: my-preloaded-sbom-pvc

Option E: Push-Model Uploads (CI/CD)

In addition to the pull-based methods above, CI/CD pipelines can push SBOM/VEX content directly via POST /api/v1/sboms/upload (see the API Reference). This endpoint always requires apiGateway.auth.enabled: true — it self-enforces this independent of the global auth default, since a write endpoint open by default is a materially different risk than the read-only default.

Recommended: dedicated S3 bucket. Mark one bucket "skipScan": true — it becomes the upload target and is excluded from the ingestion watcher’s periodic scan, so pushed objects are never rediscovered and double-enqueued:

apiGateway:
  auth:
    enabled: true

s3:
  buckets: '[{"name":"my-org-sboms","region":"us-east-1"},{"name":"bomhort-pushed","region":"us-east-1","skipScan":true}]'

Fallback: local filesystem. If no skipScan bucket is configured, uploads fall back to SBOM_DIR/pushed/ — this needs the API Gateway’s sbom-data volume mounted read-write:

apiGateway:
  auth:
    enabled: true

gitSync:
  enabled: false   # PVC mode required for a writable mount

sbomSource:
  writable: true
  # Only if apiGateway.replicas > 1 — ReadWriteOnce lets just one pod mount
  # read-write at a time, so every replica but one would fail to persist
  # uploads. Requires a storage class that supports ReadWriteMany (EFS,
  # Azure Files, most NFS-backed provisioners).
  accessMode: ReadWriteMany

If neither a skipScan bucket nor a writable SBOM_DIR is configured, the API Gateway logs a startup warning and POST /api/v1/sboms/upload returns 503 Service Unavailable for every request rather than failing with a confusing storage error.

Sizing the write budget. The gateway’s rate limit is request-based (100 requests / 10s per IP), not byte-based, so the effective per-IP write budget is 100 × MAX_UPLOAD_SIZE_MB per 10 seconds — 5 GB at the 50 MB default. Set apiGateway.maxUploadSizeMB to the smallest value that fits your largest SBOM, especially if the endpoint is reachable from untrusted networks:

apiGateway:
  maxUploadSizeMB: 25

Original Document Store (Tier-2 Fidelity)

From v0.7 the parsing worker keeps the original bytes of every ingested SBOM so it can be reproduced byte-for-byte later (download, enriched export, re-signing — see Architecture). ClickHouse only stores a reference and sha256; the bytes go to a blob store selected by originalStore.backend:

backendWhere originals goWhen to use
auto (default)s3 if s3.buckets is set, else fs if originalStore.fs.enabled, else noneAlmost always — follows your ingestion setup.
s3originalStore.s3.bucket (default: first non-skipScan bucket) under originalStore.s3.prefix (default _bomhort/originals/)Any deployment with object storage. Anything under _bomhort/ is ignored by the ingestion watcher, so originals are never re-ingested.
fsA dedicated PVC (originalStore.fs.pvcName) mounted read-write into the workers and read-only into the API gatewayAir-gapped / filesystem-only deployments.
noneNowhereOnly deliberately — SBOMs ingested while disabled can never be recovered byte-for-byte.

S3 (default with buckets configured) — nothing to do. Optionally pin a dedicated archive bucket:

s3:
  buckets: '[{"name":"my-org-sboms","region":"us-east-1"},{"name":"bomhort-archive","region":"us-east-1","skipScan":true}]'

originalStore:
  s3:
    bucket: bomhort-archive
    prefix: originals/

Filesystem (no S3):

originalStore:
  backend: fs
  fs:
    enabled: true
    storageSize: 20Gi
    # Required if parsingWorker.replicas > 1 (they all write) — needs a
    # ReadWriteMany-capable storage class (EFS, Azure Files, NFS, …).
    accessMode: ReadWriteMany
    storageClassName: nfs

Sizing: originals are gzip-compressed and stored once per sbom_id (a re-ingest replaces the previous blob instead of adding a second one). SBOM JSON typically shrinks 6–10×, so budget roughly 10–15 % of the raw size of your SBOM corpus plus growth — a 50 GB corpus needs about 5–8 GB. document_store.stored_size_bytes tells you the exact footprint per SBOM (SELECT sum(stored_size_bytes), sum(size_bytes) FROM document_store FINAL). A worker that cannot reach the blob store fails the job (it is retried later) rather than ingesting without the original.

Performance: compression runs at gzip.BestSpeed and costs a few milliseconds per document on the worker; the gateway never decompresses for clients that accept gzip (all browsers, curl --compressed) — it passes the stored bytes through with Content-Encoding: gzip, so downloads of stored originals are usually faster than reading the source file.

Volume ownership. The images run as nobody (uid/gid 65534). A freshly provisioned PVC is normally root-owned, so the chart sets podSecurityContext.fsGroup: 65534 (with fsGroupChangePolicy: OnRootMismatch) on the worker and gateway pods by default. The worker also probes the directory at startup and refuses to start with a not writable by uid 65534 … set fsGroup / chown error instead of failing every job. If your cluster forbids fsGroup (e.g. a restrictive admission policy) set podSecurityContext: null and make the volume writable for uid 65534 yourself. The same applies to sbomSource.writable for push uploads.

The download endpoint advertises a served original with X-BOMHort-Original: true and its digest in ETag / X-BOMHort-SHA256 (always the sha256 of the decoded document, regardless of transfer encoding); SBOMs ingested before this feature fall back to the source file transparently.


2. Ownership – Labelling SBOMs by Cluster, Namespace, Project and Tags

Every ingested row carries three orthogonal ownership labels, plus a list of free-form tags. All default to "" / [] (unassigned), so an existing deployment that sets none of them keeps behaving exactly as before.

LabelQuestion it answersExampleTypical cardinalityOwned by
clusterWhere is it deployed?prod-eu1–50Platform
namespaceWhich tenant/team boundary inside the cluster?payments10–500Platform / team
projectWhat is it / who owns it?payment-service50–5000Dev teams
tagsWhich grouping does it belong to?sandbox-applications5–100Curator

Stored as LowCardinality(String) columns on every core table (sboms, sbom_packages, vulnerabilities, license_compliance, ingestion_queue, vex_statements, document_store) by migrations 012 (cluster) and 015 (namespace, project). tags is an Array(String) on sboms and ingestion_queue (migration 022).

Static values

The simplest setup — one BOMHort instance per cluster, everything labelled the same:

ownership:
  cluster: prod-eu
  namespace: ""
  project: ""

Per bucket, for the bucket-per-team case:

s3:
  buckets:
    - name: team-payments-sboms
      region: eu-central-1
      cluster: prod-eu
      namespace: payments
    - name: team-search-sboms
      region: eu-central-1
      cluster: prod-eu
      namespace: search

Deriving labels from the ingestion path

If your buckets are already organised by cluster/team/service, declare that layout instead of repeating it per bucket:

ownership:
  pathLayout: "cluster/namespace/project"
prod-eu/payments/payment-service/app.spdx.json
  → cluster=prod-eu  namespace=payments  project=payment-service

Segments map positionally onto the leading path segments, relative to the ingestion root — SBOM_DIR for local files, or a bucket’s configured prefix for S3 (the prefix is stripped first, so a bucket with prefix: k3s-io/ does not end up with cluster=k3s-io). The filename itself is never consumed.

SegmentMeaning
cluster / namespace / projectAssign this path level to that dimension
parentAssign this path level as the project’s parent (product); see Parent projects
_Skip this level (it carries no meaning)
pathLayout: "cluster/namespace/project"   # prod-eu/payments/svc/f.json
pathLayout: "namespace/project"           # payments/svc/f.json
pathLayout: "cluster/_/project"           # prod-eu/ignored/svc/f.json
pathLayout: "project/cluster"             # reordering is fine, it is positional

Per-bucket override, for fleets where one bucket is nested and another flat:

s3:
  buckets:
    - name: nested-sboms
      pathLayout: "cluster/namespace/project"
    - name: flat-sboms
      namespace: legacy          # no layout: label it statically instead

Tolerant on data, strict on config. A path shallower than the layout fills what it can and leaves the rest empty; a deeper one is matched from the left. One oddly-placed file therefore cannot fail an ingestion run. A malformed layout is the opposite — it is rejected at startup, because accepting it would ingest the whole fleet unlabelled, and DEFAULT '' makes that mistake indistinguishable from “genuinely unassigned”. Only a full re-ingest would fix it.

Push-model uploads

The server cannot infer ownership from an uploaded body, so a pushing CI job states it per request:

curl -X POST "https://bomhort.example.com/api/v1/sboms/upload?cluster=prod-eu&namespace=payments&project=payment-service" \
  -H "X-API-Key: $BOMHORT_API_KEY" \
  -H "X-Filename: payment-service.spdx.json" \
  --data-binary @payment-service.spdx.json

Any parameter you omit falls back to the instance default. A blank parameter (?namespace=) is treated as omitted, so a client cannot accidentally blank out a configured value.

Tags – grouping projects

The three dimensions above describe where a workload runs. On a catalogue-style instance — a foundation collecting SBOMs of its member projects, a vendor publishing SBOMs for its product portfolio — nothing runs in a cluster at all, so cluster and namespace stay structurally empty. That instance still needs to say “these 40 projects are sandbox applications”. Tags (#357) are that fourth, orthogonal dimension.

Tags group projects, they do not replace them. A project with three SBOMs stays one project named after itself and merely carries the label — so a catalogue keeps its per-project view and gains the grouping.

# values.yaml
ownership:
  project: ""                                  # left to pathLayout / per-bucket
  tags: ["sandbox-applications", "platform"]

A list rather than a single value, because the groupings are genuinely many-to-many: one project can be a sandbox application and an observability tool. Per bucket and per upload:

s3:
  buckets:
    - name: sandbox-sboms
      tags: ["sandbox-applications"]
    - name: graduated-sboms
      tags: ["graduated"]
curl -X POST "https://bomhort.example.com/api/v1/sboms/upload?tags=sandbox-applications,observability" \
  -H "X-API-Key: $BOMHORT_API_KEY" \
  -H "X-Filename: k2s.spdx.json" \
  --data-binary @k2s.spdx.json

Read the groupings back data-driven — never hard-code a tag vocabulary, the API reports exactly the tags that exist in the data:

curl -s https://bomhort.example.com/api/v1/tags
# [{"tag":"graduated","sbom_count":89,"project_count":12},
#  {"tag":"sandbox-applications","sbom_count":312,"project_count":41}]

curl -s "https://bomhort.example.com/api/v1/projects?tag=sandbox-applications"

Tags need migration 022. See Fleet Views for the full model and make demo-catalogue for a runnable three-tier catalogue.

Applying the change

These are ingestion-time labels, not query-time ones: they are written when an SBOM is parsed. Changing them affects new ingests only.

Run migration 015 before deploying the new images — the columns must exist before any writer references them. The migrate Job does this automatically on helm upgrade or an Argo CD sync (schema migrations).

helm upgrade bomhort ./deploy/helm/bomhort -f my-values.yaml

# Re-label existing data by re-ingesting it:
kubectl create job --from=cronjob/bomhort-ingestion-watcher reingest-$(date +%s)

3. License Exceptions

License exceptions suppress specific license violations. They come from exactly one source, which the API Gateway and the workers re-read every licenseExceptions.refreshInterval (default 60s):

SourceSetUse when
Chart-rendered ConfigMaplicenseExceptions.custom (default)the rules live in your Helm values
Your own ConfigMaplicenseExceptions.existingConfigMapa second GitOps source / kustomize manages the reviewed file
URL or S3 objectlicenseExceptions.sourcethe rules live outside the cluster, e.g. in a governance repository

No exceptions are enabled or downloaded by default. Adapt the inactive examples/license-exceptions/license-exceptions.example.json structure to your organization and approve only reviewed rules:

helm upgrade --install bomhort deploy/helm/bomhort -n bomhort \
  -f my-values.yaml --set-file licenseExceptions.custom=./my-exceptions.json

A change needs neither a restart nor a re-scan: the pods reload the rules and granting or revoking an exception applies to every SBOM already ingested. The rule format, the source / existingConfigMap options, the Argo CD workflow and how the retroactive re-check works are on the dedicated License Exceptions page.


4. License Policy

The license policy defines which SPDX IDs are classified as permissive or copyleft. Every license is then in one of four categories:

CategoryMeaningViolation
permissiveListed under permissive in the policyno
copyleftListed under copyleft in the policyyes
unapprovedA declared license the policy does not list (e.g. CC0-1.0, Unlicense, LicenseRef-…) — not necessarily copyleft, but nobody approved ityes
unknownNo license information in the SBOM (NOASSERTION, NONE, empty) after resolution, or an unparseable expressionyes (needs review)

Approve an unapproved license by adding it to the policy, or per package via license-exceptions.json.

kubectl edit configmap bomhort-license-policy
kubectl rollout restart deployment bomhort-api-gateway bomhort-parsing-worker

To manage the policy outside the chart, set licensePolicy.existingConfigMap (key licensePolicy.existingConfigMapKey, default license-policy.json). It works exactly like licenseExceptions.existingConfigMap, including the restart after a content change.

Compound SPDX expressions

Declared licenses are often expressions rather than single IDs — Apache-2.0 AND BSD-3-Clause AND MIT, MIT OR GPL-2.0-only, GPL-2.0-only WITH Classpath-exception-2.0. BOMHort parses them (precedence WITH > AND > OR, parentheses, case-insensitive operators, deprecated +, and deprecated bare GNU IDs such as GPL-2.0 → GPL-2.0-only or GPL-2.0-with-classpath-exception → GPL-2.0-only) and folds the operands into one category according to licensePolicy.expressionMode.

Before that, common free-text spellings are rewritten to SPDX IDs at ingest (MPL 2.0 → MPL-2.0, CC BY-SA 4.0 → CC-BY-SA-4.0, The Apache Software License, Version 2.0 → Apache-2.0, Eclipse Public License - v 1.0 → EPL-1.0, Lesser General Public License, version 3 or greater → LGPL-3.0-or-later, also inside expressions), so the stored package license already carries the SPDX form. Only spellings that name an explicit version are mapped. Ambiguous ones — BSD (which clause count?), Apache Software License (1.1 or 2.0?), Public Domain (no SPDX ID) — are deliberately not guessed and stay unapproved until the SBOM is fixed or an exception is added.

The project’s own Yarn workspace packages (pkg:npm/…@0.0.0-use.local) and Maven modules with unresolved ${project.*} coordinates are, like the SBOM’s root package, first-party code rather than dependencies. They remain in the package list but are excluded from the compliance check, so they do not show up as NOASSERTION. Every normalization and resolution rule, and why it exists, is listed on the License Resolution page.

The folding modes:

ModeApache-2.0 AND MITMIT AND GPL-3.0-onlyMIT OR GPL-3.0-only
strict (default)permissivecopyleftpermissive
permissive-winspermissivepermissivepermissive
offunapprovedunapprovedunapproved
licensePolicy:
  expressionMode: permissive-wins   # rendered as LICENSE_EXPRESSION_MODE

strict follows SPDX semantics and never hides a copyleft operand. permissive-wins is for catalogues whose SBOM generators join a package’s licenses with AND regardless of dual-licensing (the CNCF example values use it). off restores the pre-evaluation behaviour. The Helm value overrides the expressionMode field of the policy file; an invalid value fails helm template. A verbatim policy entry for a whole expression always wins, and a malformed expression stays unknown.

Folding uses the order permissive < unknown < unapproved < copyleft: AND (under strict) takes the worst operand, OR the best — so MIT AND CC0-1.0 is unapproved and CDDL-1.0 OR GPL-2.0-only is unapproved rather than copyleft.


5. Custom Theme

ui:
  customTheme:
    enabled: true
kubectl create configmap bomhort-custom-theme \
  --from-file=custom-theme.css=./my-theme.css \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment bomhort-ui

Write overrides as :root:root { … } and :root:root[data-theme="dark"] { … }: Angular appends its own stylesheet after the theme link, so a plain :root block ties on specificity and the default palette wins. A complete example (CNCF colour palette) lives in examples/themes/cncf/; locally: CUSTOM_THEME=./examples/themes/cncf/custom-theme.css make dev.


6. Site Configuration

ui:
  siteConfig:
    enabled: true
    content:
      brandName: "My Platform"
      brandLogo: "/brand/my-logo.svg"          # optional, default: BOMHort mascot
      brandLogoDark: "/brand/my-logo-white.svg" # optional, used in dark mode
      brandLogoAlt: "My Platform"
      pageTitle: "My Platform"
      dashboard:
        title: "Overview"
        subtitle: "Software Supply Chain Governance"

brandLogo is rendered at 28 px height with free width, so a landscape wordmark works as well as a square mark. A dark wordmark vanishes on the dark navbar, so brandLogoDark can name a light variant for dark mode. It must be same-origin (the UI’s CSP is img-src 'self' data:), i.e. either a path served by the UI container or a data: URI. Locally, BRAND_ASSETS=./my-brand make dev mounts a directory at /brand/; in Kubernetes, mount a ConfigMap/volume with the file into the UI container at /usr/share/nginx/html/brand/.

Locally with Docker Compose, the same keys live in ui/public/ui-config.json; point UI_CONFIG=./my-ui-config.json in .env at your own file and run docker compose up -d --force-recreate ui. All fields are optional and fall back to the built-in BOMHort default.


7. API Authentication (Optional)

API authentication is fully optional and disabled by default. When you expose the API Gateway externally (e.g. via Ingress), enable it to prevent unauthenticated access.

When you need it

  • ✅ API Gateway exposed via Ingress / public endpoint
  • ✅ CI/CD pipelines pushing data (when upload endpoint lands in #135)
  • ✅ Multi-tenant or shared deployments

When you don’t need it

  • ❌ Internal cluster-only deployments (network policy is enough)
  • ❌ Local development (make dev)
  • ❌ Air-gapped environments behind a corporate VPN

Two authentication modes (combinable)

Mode 1: Service Token — a single shared secret, ideal for upstream proxy/gateway integrations (Kong, oauth2-proxy, custom auth) and for letting the bundled UI authenticate against the API Gateway:

apiGateway:
  auth:
    enabled: true
    serviceToken: "your-strong-random-secret-here"

UI ⇄ API Gateway: When you deploy the bundled Angular UI alongside the API Gateway, the chart automatically injects the same SERVICE_TOKEN into the UI’s nginx container. Nginx adds the Authorization: Bearer … header on /api/ read requests before forwarding to the API Gateway, so the browser never sees the token. No extra configuration is required — flip auth.enabled to true and both sides are wired up.

The token is injected on GET and HEAD only. The UI is read-only, and the nginx proxy is reachable by anyone who can open the UI — injecting on writes would let every anonymous visitor call POST /api/v1/sboms/upload and PATCH /api/v1/sboms/{id} as the service. Writers (CI pipelines pushing SBOMs) must send their own credential, e.g. an X-API-Key, either directly to the API Gateway or through the UI host; a client-supplied Authorization header is always passed through unchanged.

Clients send the token via either header:

curl -H "Authorization: Bearer your-strong-random-secret-here" \
  https://bomhort.example.com/api/v1/stats/dashboard

# Or:
curl -H "X-Service-Token: your-strong-random-secret-here" \
  https://bomhort.example.com/api/v1/stats/dashboard

Mode 2: API Keys — multiple pre-shared keys for direct consumers (CI/CD pipelines, scripts):

apiGateway:
  auth:
    enabled: true
    apiKeys: "ci-cd-pipeline-key,monitoring-key,backup-script-key"

Clients send the key via:

curl -H "X-API-Key: ci-cd-pipeline-key" \
  https://bomhort.example.com/api/v1/stats/dashboard

Both modes can be enabled at the same time — useful when a proxy uses the service token while direct CI/CD jobs use API keys.

Reference a pre-existing Secret instead of inlining the token in Helm values — same pattern as s3.credentialsSecret and clickhouse.userPasswordSecret:

kubectl create secret generic bomhort-api-auth \
  --from-literal=SERVICE_TOKEN="$(openssl rand -hex 32)" \
  --from-literal=API_KEYS="key1,key2,key3" \
  -n bomhort
apiGateway:
  auth:
    enabled: true
    existingSecret:
      enabled: true
      secretName: "bomhort-api-auth"
      serviceTokenKey: "SERVICE_TOKEN"   # key inside the Secret
      apiKeysKey: "API_KEYS"

Both the API Gateway and the UI nginx container automatically read SERVICE_TOKEN from this Secret. To rotate the token, update the Secret and restart both Deployments:

kubectl rollout restart deployment bomhort-api-gateway bomhort-ui

Public endpoints (always accessible)

Even when authentication is enabled, the following endpoints are always reachable without credentials:

EndpointPurpose
/healthzKubernetes health check (legacy, always 200)
/livezLiveness probe (always 200 if process running)
/readyzReadiness probe (pings ClickHouse, 503 if DB unavailable)
OPTIONS *CORS preflight

Security notes

  • Use at least 32 random bytes for the service token: openssl rand -hex 32
  • Rotate tokens by restarting the API Gateway pod after updating the secret
  • All comparisons are constant-time to prevent timing attacks
  • Failed auth attempts are logged with sanitized client IPs
  • The frontend UI bundle is publicly served by Nginx — auth applies to the API Gateway only

Failure scenarios

ScenarioResponse
No credentials sent (auth enabled)401 Unauthorized with WWW-Authenticate: Bearer realm="bomhort"
Invalid token/key401 Unauthorized
AUTH_ENABLED=true but no SERVICE_TOKEN and no API_KEYS configuredAll requests rejected (misconfiguration warning logged at startup)

Query limits

The API gateway sends three ClickHouse session settings with every query it runs, so one expensive request — a search over a large fleet, a project page over thousands of SBOMs — fails with an error instead of starving every other page and the ingestion pipeline (#344):

apiGateway:
  queryLimits:
    maxExecutionTime: 30        # seconds per query (max_execution_time)
    maxMemoryUsage: 536870912   # bytes per query (max_memory_usage), 512 MiB
    maxThreads: 0               # threads per query (max_threads); 0 = server default

0 leaves a setting unset, so the ClickHouse server or user-profile default applies. maxMemoryUsage is a plain byte count — 512Mi fails the render — and the default is half of the chart’s 1Gi ClickHouse memory limit: raise it together with clickhouse.installation.podTemplate.resources.limits.memory. A query that hits a limit returns 500 to that one caller; everything else keeps working.

Only the gateway is limited. The parsing workers, the CVE refresher and the migration job read the same ClickHouse but get none of these variables: their long inserts and full-table refreshes are the job, not an accident, and a limit sized for interactive pages would make them fail on large instances. If legitimate pages start hitting the limit, scale ClickHouse and raise the limit rather than removing it — and apiGateway.replicas adds request capacity, not per-query headroom.

Outside Helm, set CLICKHOUSE_MAX_EXECUTION_TIME, CLICKHOUSE_MAX_MEMORY_USAGE and CLICKHOUSE_MAX_THREADS on the gateway only. An invalid or negative value stops the gateway at startup.


8. GitHub Token (License Resolution)

BOMHort resolves unknown package licenses (NOASSERTION) by querying the GitHub API. Without a token, you are limited to 60 requests per hour. With a token, the limit increases to 5,000 req/h.

We strongly recommend setting a GitHub token for any production deployment.

When the limit is hit, the parsing worker does not guess and does not wait: the SBOM’s job is parked in ingestion_queue (status = pending, retry_after = the reset time GitHub reports, error_message = github rate limit …) and claimed again automatically once that time has passed. Other jobs keep flowing. Without a token this means an SBOM-heavy backlog advances about 60 lookups per hour — see License resolution, D15.

Create a Personal Access Token (classic) with no scopes required.

github:
  token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Or pass it securely via --set:

helm install bomhort deploy/helm/bomhort/ -n bomhort -f values.yaml \
  --set github.token="ghp_..."

See FAQ: Should I use a GitHub token? for more details and how to re-ingest after adding a token.

Licenses that are still unknown after the GitHub pass are looked up in public package registries for npm (registry.npmjs.org), NuGet (api.nuget.org) and deps.dev (api.deps.dev, covering Maven, PyPI, Cargo, Go, NuGet and npm; a package without a usable version is resolved via its latest release). These lookups need no credentials and are rate-limited client-side. They can be disabled individually, e.g. in air-gapped environments:

parsingWorker:
  skipGitHubResolve: false
  skipNPMResolve: false
  skipNuGetResolve: false
  skipDepsDevResolve: false
  skipPackagistResolve: false
  skipPyPIResolve: false

See Architecture: License Resolution for the exact resolution strategy.


9. Full Deployment Example

helm install bomhort ./deploy/helm/bomhort \
  -f values-production.yaml \
  --set 's3.buckets=[{"name":"my-org-sboms","region":"us-east-1"}]' \
  --set s3.accessKey="AKIA..." \
  --set s3.secretKey="..." \
  --set parsingWorker.replicas=10

Schema migrations

Every install and upgrade runs a migrate Job that applies all files in the chart’s migrations/ directory. The migrations are idempotent (IF NOT EXISTS), so running them again changes nothing.

The Job is named <release>-migrate-<hash>. The hash covers the migration files, the migration script and the Job spec. A release that adds or edits a migration therefore gets a new Job, which runs. A release with the same migrations renders the same Job, so nothing runs again and nothing shows up as a diff.

To run the migrations again by hand (for example after restoring ClickHouse from an older backup), delete the current migrate Job. helm upgrade or the next Argo sync recreates it under the same name, and it runs again.

A failed migrate Job is not retried by an upgrade. The same name means Helm and Argo leave the existing Job in place, failed or not. The migration script exits with an error instead of waiting forever; Kubernetes retries the pod up to backoffLimit (10) times and then marks the Job Failed:

  • rejected credentials (wrong password or user) end each attempt immediately, with ClickHouse’s error in the log;
  • an unreachable ClickHouse ends an attempt after 100 tries, three seconds apart, again with the last error;
  • the whole Job, retries included, is stopped after migrations.activeDeadlineSeconds (default 3600).

Fix the cause (for example the password in clickhouse.password or in the clickhouse.userPasswordSecret secret), then delete the failed Job so the next upgrade or sync runs it again:

kubectl -n bomhort get jobs -l app.kubernetes.io/component=migrate
kubectl -n bomhort logs -l app.kubernetes.io/component=migrate --tail=40
kubectl -n bomhort delete job -l app.kubernetes.io/component=migrate

Argo CD (GitOps)

Argo CD renders the chart with helm template and applies the result. Nothing in the chart relies on Helm’s release history. Two settings matter for the batch Jobs:

  • cleanBatchJobs: false. With the default true, a finished Job deletes itself after five minutes. Argo then reports the Job as missing, and with selfHeal it recreates it, so the migrations run every few minutes. Keep the Jobs; they are small.
  • ignoreDifferences for Jobs. Kubernetes adds controller-uid labels to a Job’s selector and pod template. Argo shows these as a permanent diff unless they are ignored. Ignoring them is safe because the chart never changes a Job’s spec under the same name: a different spec renders as a different Job.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: bomhort
  namespace: argocd
spec:
  project: default
  sources:
    - repoURL: ghcr.io/seebom-labs/bomhort/charts
      chart: bomhort
      targetRevision: 0.8.0
      helm:
        valuesObject:
          cleanBatchJobs: false
          clickhouse:
            userPasswordSecret:
              enabled: true
              secretName: clickhouse-password
              key: password
          s3:
            buckets:
              - name: my-org-sboms
                endpoint: https://s3.example.com
                region: us-east-1
          licenseExceptions:
            enabled: true
            existingConfigMap: my-license-exceptions
            existingConfigMapKey: license-exceptions.json
    # Optional second source for the ConfigMaps you manage yourself
    # (license exceptions, ...), see /docs/license-exceptions/#argo-cd-gitops.
    - repoURL: https://github.com/my-org/platform.git
      targetRevision: HEAD
      path: manifests/bomhort
  destination:
    server: https://kubernetes.default.svc
    namespace: bomhort
  syncPolicy:
    automated:
      prune: true      # removes Jobs the chart no longer renders, e.g. bomhort-migrate-1
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - RespectIgnoreDifferences=true
  ignoreDifferences:
    - group: batch
      kind: Job
      jqPathExpressions:
        - .status
        - .spec.selector
        - .spec.template.metadata.labels

Do not use ignoreDifferences to hide changes to a Job’s containers or volumes. If a Job’s spec shows as changed under the same name, the chart is outdated: upgrade it rather than ignoring the diff.


10. Headless Mode (API-Only)

For CI/CD integrations, custom dashboards, or environments where the Angular UI is not needed, BOMHort can be deployed in headless mode. This skips all UI-related resources (Deployment, Service, nginx ConfigMap) and reduces the cluster’s resource footprint.

# values-headless.yaml
ui:
  enabled: false

apiGateway:
  auth:
    enabled: true
    serviceToken: "my-ci-token"

With ui.enabled: false:

  • No UI Deployment, Service, or ConfigMaps are rendered
  • All 25 API endpoints remain fully functional
  • The API Gateway is the only externally exposed component
  • Pair with apiGateway.auth.enabled: true to secure access

This is ideal for:

  • CI/CD pipelines that push SBOMs and query results programmatically
  • Grafana/custom dashboards that consume the REST API directly
  • Resource-constrained clusters where every pod counts
  • Air-gapped deployments where the UI is served separately

10a. MCP Server (Optional)

The MCP server (#399) exposes BOMHort’s read-only data as Model Context Protocol tools, so an AI agent can ask get_project instead of someone writing a REST client first. It ships disabled.

Most people do not deploy it. An MCP client on a laptop starts the binary itself and speaks stdio — no port, no token, no origin list, because nothing is listening. See the MCP Server page for that setup.

Deploy it in-cluster only when agents outside your machine need to reach the instance:

mcp:
  enabled: true
  transport: http
  httpToken: "change-me"          # or existingSecret
  allowedOrigins:
    - https://agent.example.com

helm template fails if httpToken is missing, allowedOrigins is empty, or it contains *. That is intentional and not configurable: the HTTP transport is a remote tool-execution endpoint, and * re-opens the cross-site hole described by CVE-2026-33252. A CLI client sends no Origin at all and is unaffected by the list — it exists to stop a browser page from driving the server.

When apiGateway.auth.enabled: true, the MCP server reads the release’s SERVICE_TOKEN from the same Secret; there is no second token to keep in sync.

The pod mounts neither the release ConfigMap nor its Secret: it talks to the API Gateway like any external consumer and holds no database credentials. A chart test asserts this, so it stays true.


11. Ingress – Exposing the API Externally

BOMHort includes an optional Ingress resource to expose the API Gateway (and optionally the UI) outside the cluster. The template is controller-agnostic — it works with any Ingress controller that implements the Kubernetes Ingress spec (Envoy Gateway, Contour, AWS ALB, etc.).

Basic (Envoy Gateway)

ingress:
  enabled: true
  className: eg
  hosts:
    - host: bomhort.example.com
      paths:
        - path: /api
          pathType: Prefix
        - path: /
          pathType: Prefix
          serviceSuffix: ui

With TLS (cert-manager)

ingress:
  enabled: true
  className: eg
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: bomhort.example.com
      paths:
        - path: /api
          pathType: Prefix
        - path: /
          pathType: Prefix
          serviceSuffix: ui
  tls:
    - secretName: bomhort-tls
      hosts:
        - bomhort.example.com

Contour

ingress:
  enabled: true
  className: contour
  hosts:
    - host: bomhort.example.com
      paths:
        - path: /api
          pathType: Prefix
        - path: /
          pathType: Prefix
          serviceSuffix: ui

AWS ALB

ingress:
  enabled: true
  className: alb
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:...
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
  hosts:
    - host: bomhort.example.com
      paths:
        - path: /api
          pathType: Prefix
        - path: /
          pathType: Prefix
          serviceSuffix: ui

API-only (headless)

ingress:
  enabled: true
  className: eg
  hosts:
    - host: api.bomhort.example.com
      paths:
        - path: /
          pathType: Prefix

12. Upgrading from v0.5.0 or Earlier (SeeBOM → BOMHort)

Starting with v0.6.0, the project was renamed from SeeBOM to BOMHort. This affects the Helm chart name, namespace, ClickHouse database name, and container image paths. Existing deployments running v0.5.0 or earlier need a one-time data migration.

What changed

Resourcev0.5.0 (old)v0.6.0+ (new)
Helm chartseebombomhort
Namespaceseebombomhort
ClickHouse databaseseebombomhort
ClickHouse hostchi-seebom-clickhouse-seebom-cluster-0-0chi-bomhort-clickhouse-bomhort-cluster-0-0
Image repositoryghcr.io/seebom-labs/seebom/*ghcr.io/seebom-labs/bomhort/*
PVC nameseebom-sbom-databomhort-sbom-data
OCI chart URLoci://ghcr.io/seebom-labs/seebom/charts/seebomoci://ghcr.io/seebom-labs/bomhort/charts/bomhort

Migration steps

The chart includes a built-in data migration hook that copies all ClickHouse tables from the old seebom instance to the new bomhort instance using ClickHouse’s remote() function. It runs as a Helm post-install/post-upgrade Job.

1. Keep the old deployment running

Do not delete the seebom namespace yet. The migration Job connects to the old ClickHouse cross-namespace.

2. Create the password Secret in the new namespace

The migration Job needs access to the old ClickHouse password:

kubectl create namespace bomhort

# Copy the old ClickHouse password into the new namespace
OLD_PW=$(kubectl get secret clickhouse-password -n seebom -o jsonpath='{.data.password}' | base64 -d)
kubectl create secret generic clickhouse-migration-source \
  --from-literal=password="$OLD_PW" \
  -n bomhort

3. Add the cluster column to the old database

v0.6.0 introduces a cluster column (migration 012). The data migration Job uses SELECT * FROM remote(...), which requires matching schemas. Add the column to the source tables first:

kubectl exec -n seebom chi-seebom-clickhouse-seebom-cluster-0-0-0 -c clickhouse -- \
  clickhouse-client --database=seebom --password="$OLD_PW" --multiquery <<'EOF'
ALTER TABLE sboms ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
ALTER TABLE sbom_packages ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
ALTER TABLE license_compliance ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
ALTER TABLE ingestion_queue ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
ALTER TABLE vex_statements ADD COLUMN IF NOT EXISTS cluster LowCardinality(String) DEFAULT '';
EOF

This is safe and non-destructive — existing rows get an empty default value.

4. Deploy with migration enabled

helm install bomhort oci://ghcr.io/seebom-labs/bomhort/charts/bomhort \
  --version 0.7.0 \
  -n bomhort \
  -f your-values.yaml \
  --set dataMigration.enabled=true \
  --set dataMigration.source.host=chi-seebom-clickhouse-seebom-cluster-0-0.seebom.svc.cluster.local \
  --set dataMigration.source.port=9000 \
  --set dataMigration.source.database=seebom \
  --set dataMigration.source.user=default \
  --set dataMigration.source.passwordSecret.secretName=clickhouse-migration-source \
  --set dataMigration.source.passwordSecret.key=password

Or add this to your values file:

dataMigration:
  enabled: true
  source:
    host: chi-seebom-clickhouse-seebom-cluster-0-0.seebom.svc.cluster.local
    port: 9000
    database: seebom
    user: default
    passwordSecret:
      secretName: clickhouse-migration-source
      key: password

5. Monitor the migration

kubectl logs -n bomhort job/bomhort-data-migration-1 -f

The Job migrates these tables (skipping any that are empty or already populated in the target):

  • sboms, sbom_packages, vulnerabilities, license_compliance
  • ingestion_queue, vex_statements, cve_refresh_log
  • github_license_cache, github_repo_metadata, registry_license_cache, document_store

Stored originals themselves (the blobs referenced by document_store) are not moved by the Job — they stay in their S3 prefix or PVC; make sure the new release points at the same bucket / volume.

The dashboard_stats_mv materialized view repopulates automatically.

6. Verify and clean up

# Verify row counts match
kubectl exec -n bomhort $(kubectl get pod -n bomhort -l app.kubernetes.io/component=api-gateway -o name | head -1) \
  -- wget -qO- http://localhost:8080/api/v1/stats/dashboard

# Once satisfied, disable migration for future upgrades
helm upgrade bomhort oci://ghcr.io/seebom-labs/bomhort/charts/bomhort \
  -n bomhort -f your-values.yaml \
  --set dataMigration.enabled=false

# Delete the old deployment when ready
kubectl delete namespace seebom

ArgoCD users

If you manage deployments via ArgoCD, create a new Application resource pointing to the new chart:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: bomhort
  namespace: argocd
spec:
  source:
    repoURL: ghcr.io/seebom-labs/bomhort/charts
    chart: bomhort
    targetRevision: "0.7.0"
    helm:
      values: |
        dataMigration:
          enabled: true
          source:
            host: chi-seebom-clickhouse-seebom-cluster-0-0.seebom.svc.cluster.local
            port: 9000
            database: seebom
            user: default
            passwordSecret:
              secretName: clickhouse-migration-source
              key: password
  destination:
    namespace: bomhort

After confirming data integrity, set dataMigration.enabled: false and remove the old seebom Application. See Argo CD (GitOps) for the Job settings every Argo-managed install needs.


13. Verifying the Deployment

kubectl get pods -l app.kubernetes.io/name=bomhort

kubectl exec -it $(kubectl get pod -l app.kubernetes.io/component=api-gateway -o name | head -1) \
  -- wget -qO- http://localhost:8080/api/v1/stats/dashboard

Summary

WhatWhereHow to Change
SBOMs (S3)S3-compatible bucketsConfigure s3.buckets in Helm values
SBOMs (volume)PVC via git-sync or populated by youPush to Git (git-sync re-syncs) or copy into the PVC
SBOMs (push/CI-CD)skipScan S3 bucket, or PVCPOST /api/v1/sboms/upload — see Option E
VEX filesSame S3 bucket or directoryPlace *.openvex.json alongside SBOMs
License ExceptionsConfigMap (chart-rendered, or your own via existingConfigMap), or a URL / S3 object via sourceUpdate the ConfigMap or the remote file → pods reload within refreshInterval → stored results re-checked automatically
License PolicyConfigMap (chart-rendered, or your own via existingConfigMap)kubectl edit configmap (or update your own ConfigMap) → restart API + Workers
Custom ThemeConfigMapkubectl create configmap → restart UI
Site ConfigConfigMapHelm values ui.siteConfig.content.* → restart UI
S3 credentialsSecret--set s3.accessKey=... or s3.credentialsSecret (existing K8s Secret)
API AuthenticationEnv vars (Secret recommended)AUTH_ENABLED=true + SERVICE_TOKEN and/or API_KEYS; off by default
Headless ModeHelm valueui.enabled: false — skips UI Deployment/Service/ConfigMaps
IngressIngress resourceingress.enabled: true + configure hosts/tls in Helm values