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/*)

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":"cncf-subproject-sboms","region":"us-east-1"}]'

Multiple buckets:

s3:
  buckets: '[{"name":"cncf-subproject-sboms","region":"us-east-1"},{"name":"cncf-project-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
ingestionWatcher:
  env:
    CLUSTER_NAME: "prod-eu"

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

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

# values.yaml — single watcher, multiple clusters
ingestionWatcher:
  env:
    CLUSTER_NAME: "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 CLUSTER_NAME = default

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

Option B: Seed Job

s3:
  buckets: ""

gitSync:
  enabled: false

seedJob:
  sbomRepo: "https://github.com/cncf/sbom.git"
  sbomBranch: main

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 the seed job 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":"cncf-subproject-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

2. License Exceptions

License exceptions suppress specific license violations. They are stored in a ConfigMap that is mounted read-only into the API Gateway and Workers.

kubectl edit configmap bomhort-license-exceptions
kubectl rollout restart deployment bomhort-api-gateway

3. License Policy

The license policy defines which SPDX IDs are classified as permissive, copyleft, or unknown.

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

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

5. Site Configuration

ui:
  siteConfig:
    enabled: true
    content:
      brandName: "My Platform"
      pageTitle: "My Platform"
      dashboard:
        title: "Overview"
        subtitle: "Software Supply Chain Governance"

6. 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 every /api/ proxy call 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.

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)

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

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.


8. Full Deployment Example

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

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

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

11. 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.6.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

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


12. 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 seed job or git-syncPush to Git, seed job clones
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 ExceptionsConfigMapkubectl edit configmap → restart API
License PolicyConfigMapkubectl edit 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