Deployment
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.
Option A: S3 Buckets (default, recommended)
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.
Earlier versions of this page showed ingestionWatcher.env.CLUSTER_NAME. That
key was never read by the chart — the watcher template has no env
passthrough, so the setting silently did nothing and all data stayed untagged.
Use the ownership block below, which is wired into the ConfigMap that every
workload consumes. If you were relying on the old snippet, re-ingest after
switching so existing rows pick up the label.
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 asprod-euprod-us-sboms→ tagged asprod-usstaging-sboms→ tagged asstagingshared-sboms→ inheritsownership.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:
backend | Where originals go | When to use |
|---|---|---|
auto (default) | s3 if s3.buckets is set, else fs if originalStore.fs.enabled, else none | Almost always — follows your ingestion setup. |
s3 | originalStore.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. |
fs | A dedicated PVC (originalStore.fs.pvcName) mounted read-write into the workers and read-only into the API gateway | Air-gapped / filesystem-only deployments. |
none | Nowhere | Only 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
Fleet Views shows what the cluster and
namespace views look like, and ships a runnable demo fleet: make demo-fleet
wipes the database and ingests examples/fleet/ (three clusters, five
namespaces, six projects) with INGEST_PATH_LAYOUT=cluster/namespace/project.
For the tag dimension there is make demo-catalogue, which ingests
examples/catalogue/ (six projects across three tiers) with TAGS instead.
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.
| Label | Question it answers | Example | Typical cardinality | Owned by |
|---|---|---|---|---|
cluster | Where is it deployed? | prod-eu | 1–50 | Platform |
namespace | Which tenant/team boundary inside the cluster? | payments | 10–500 | Platform / team |
project | What is it / who owns it? | payment-service | 50–5000 | Dev teams |
tags | Which grouping does it belong to? | sandbox-applications | 5–100 | Curator |
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.
| Segment | Meaning |
|---|---|
cluster / namespace / project | Assign this path level to that dimension |
parent | Assign 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
Derivation only fills dimensions that are still empty after upload params,
per-bucket config and the instance defaults have been applied. If you set
cluster: prod-eu and the path also yields a cluster segment, the configured
value is kept — an operator who names a dimension means it, and silently
overriding that from directory structure would be impossible to debug.
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
This is the one place where tags deliberately break the ownership precedence
rules. cluster/namespace/project are answers — a more specific level
overrides a less specific one, because a document has exactly one owner. Tags
are memberships: a bucket adding sandbox-applications does not contradict
an instance-wide platform, so the document ends up with both. Values are
normalised on ingestion (trimmed, lowercased, deduplicated, sorted), so casing
in your values file is not load-bearing.
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)
ingestion_queue is append-only — a status change is a new row, not an update
— so during a rolling upgrade an old parsing worker can claim a job that a
new ingestion watcher enqueued, and write the status row back without the
namespace/project columns it doesn’t know about. Those rows land with '',
exactly as if the labels had never been set.
This is transient and harmless (the data is merely unlabelled, never wrong),
but if you care about complete labelling from the first ingest, let the worker
rollout finish before the next watcher run — or simply re-ingest afterwards.
The same applied to cluster when #131 shipped.
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):
| Source | Set | Use when |
|---|---|---|
| Chart-rendered ConfigMap | licenseExceptions.custom (default) | the rules live in your Helm values |
| Your own ConfigMap | licenseExceptions.existingConfigMap | a second GitOps source / kustomize manages the reviewed file |
| URL or S3 object | licenseExceptions.source | the 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:
| Category | Meaning | Violation |
|---|---|---|
permissive | Listed under permissive in the policy | no |
copyleft | Listed under copyleft in the policy | yes |
unapproved | A declared license the policy does not list (e.g. CC0-1.0, Unlicense, LicenseRef-…) — not necessarily copyleft, but nobody approved it | yes |
unknown | No license information in the SBOM (NOASSERTION, NONE, empty) after resolution, or an unparseable expression | yes (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:
| Mode | Apache-2.0 AND MIT | MIT AND GPL-3.0-only | MIT OR GPL-3.0-only |
|---|---|---|---|
strict (default) | permissive | copyleft | permissive |
permissive-wins | permissive | permissive | permissive |
off | unapproved | unapproved | unapproved |
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.
The mode is applied when an SBOM is parsed. Existing rows keep their stored category until the SBOMs are re-processed; a watcher run alone skips unchanged files (SHA256 dedup).
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_TOKENinto the UI’s nginx container. Nginx adds theAuthorization: Bearer …header on/api/read requests before forwarding to the API Gateway, so the browser never sees the token. No extra configuration is required — flipauth.enabledtotrueand both sides are wired up.The token is injected on
GETandHEADonly. 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 callPOST /api/v1/sboms/uploadandPATCH /api/v1/sboms/{id}as the service. Writers (CI pipelines pushing SBOMs) must send their own credential, e.g. anX-API-Key, either directly to the API Gateway or through the UI host; a client-suppliedAuthorizationheader 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.
Using Kubernetes Secrets (recommended for production)
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:
| Endpoint | Purpose |
|---|---|
/healthz | Kubernetes health check (legacy, always 200) |
/livez | Liveness probe (always 200 if process running) |
/readyz | Readiness 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
| Scenario | Response |
|---|---|
| No credentials sent (auth enabled) | 401 Unauthorized with WWW-Authenticate: Bearer realm="bomhort" |
| Invalid token/key | 401 Unauthorized |
AUTH_ENABLED=true but no SERVICE_TOKEN and no API_KEYS configured | All 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
Before v0.8.0 the gateway ran every query unbounded. An upgrade turns the limits
on with the defaults above, so a page that used to take 45 seconds now fails
with 500 after 30. If you know of slow pages on a large instance, raise the
limits in your values before upgrading rather than after the first complaint.
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
S3-based (recommended)
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.
These charts named the Job <release>-migrate-<revision>. Argo CD renders
charts with helm template, where the revision is always 1. The Job stayed
bomhort-migrate-1 forever: it was created once and never ran again, so the
migrations added by later releases were never applied. Once its pod template
changed, every Argo sync also failed with spec.template: … field is immutable.
When you upgrade, Argo creates the new bomhort-migrate-<hash> Job. With
prune: true it also removes bomhort-migrate-1. Without pruning, delete the
old Job yourself (kubectl -n bomhort delete job bomhort-migrate-1); the new
Job runs either way. After that sync, check that the missing columns exist
before anything else:
kubectl -n bomhort get jobs -l app.kubernetes.io/component=migrate
kubectl -n bomhort logs -l app.kubernetes.io/component=migrate --tail=40
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(default3600).
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 defaulttrue, a finished Job deletes itself after five minutes. Argo then reports the Job as missing, and withselfHealit recreates it, so the migrations run every few minutes. Keep the Jobs; they are small.ignoreDifferencesfor Jobs. Kubernetes addscontroller-uidlabels 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: trueto 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.).
For the newer Gateway API, configure Gateway/HTTPRoute resources separately. The Helm chart provides the classic Ingress resource.
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
When exposing the API externally, ensure apiGateway.auth.enabled: true is set. Without authentication, all SBOM data is publicly accessible.
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
| Resource | v0.5.0 (old) | v0.6.0+ (new) |
|---|---|---|
| Helm chart | seebom | bomhort |
| Namespace | seebom | bomhort |
| ClickHouse database | seebom | bomhort |
| ClickHouse host | chi-seebom-clickhouse-seebom-cluster-0-0 | chi-bomhort-clickhouse-bomhort-cluster-0-0 |
| Image repository | ghcr.io/seebom-labs/seebom/* | ghcr.io/seebom-labs/bomhort/* |
| PVC name | seebom-sbom-data | bomhort-sbom-data |
| OCI chart URL | oci://ghcr.io/seebom-labs/seebom/charts/seebom | oci://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_complianceingestion_queue,vex_statements,cve_refresh_loggithub_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
The migration Job is idempotent — it skips tables that already contain data in the target database. It is safe to re-run, but it will not merge partial data. If you need to re-migrate a specific table, truncate it in the new database first.
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
| What | Where | How to Change |
|---|---|---|
| SBOMs (S3) | S3-compatible buckets | Configure s3.buckets in Helm values |
| SBOMs (volume) | PVC via git-sync or populated by you | Push to Git (git-sync re-syncs) or copy into the PVC |
| SBOMs (push/CI-CD) | skipScan S3 bucket, or PVC | POST /api/v1/sboms/upload — see Option E |
| VEX files | Same S3 bucket or directory | Place *.openvex.json alongside SBOMs |
| License Exceptions | ConfigMap (chart-rendered, or your own via existingConfigMap), or a URL / S3 object via source | Update the ConfigMap or the remote file → pods reload within refreshInterval → stored results re-checked automatically |
| License Policy | ConfigMap (chart-rendered, or your own via existingConfigMap) | kubectl edit configmap (or update your own ConfigMap) → restart API + Workers |
| Custom Theme | ConfigMap | kubectl create configmap → restart UI |
| Site Config | ConfigMap | Helm values ui.siteConfig.content.* → restart UI |
| S3 credentials | Secret | --set s3.accessKey=... or s3.credentialsSecret (existing K8s Secret) |
| API Authentication | Env vars (Secret recommended) | AUTH_ENABLED=true + SERVICE_TOKEN and/or API_KEYS; off by default |
| Headless Mode | Helm value | ui.enabled: false — skips UI Deployment/Service/ConfigMaps |
| Ingress | Ingress resource | ingress.enabled: true + configure hosts/tls in Helm values |