Install on Kubernetes with Helm
Operators running Kubernetes who want to deploy TRUSCA with the
production-grade Helm chart. Assume kubectl, Helm 3, and basic cluster
administration (Ingress, StorageClasses, cert-manager) proficiency. If you run a
single host, the Docker Compose install is simpler.
The Helm chart (charts/trustedoss, chart version 0.23.1) deploys the full
portal: the FastAPI backend, the Celery worker and beat scheduler, the React
frontend, an Ingress with TLS, and a database migration Job. PostgreSQL and
Redis can either be bundled in-cluster (for evaluation) or pointed at external
managed datastores (recommended for production).
The chart is not published to a registry yet, so there is no oci:// install.
Clone the repository and install the chart from the working tree, as the
commands below do.
The chart's appVersion now tracks the portal release, so a default install
gets the current images without overriding image.tag. Publishing to the
registry needs a chart-vX.Y.Z tag and a one-time package-visibility change,
which is the remaining half of
issue #81.
The worker pod ships with the Trivy DB and downloads / refreshes it from ghcr.io/aquasecurity/trivy-db (or a mirror via env.trivy.dbRepository). No external vulnerability engine is required. See Vulnerability data (Trivy DB).
What the chart deploys
| Workload | Kind | Notes |
|---|---|---|
| backend | Deployment | FastAPI API. AUTO_MIGRATE=false — migrations run in the Job. |
| worker | Deployment (+ optional HPA) | Celery worker (cdxgen / scancode / Trivy). |
| beat | Deployment (replicas: 1) | Celery scheduler — singleton. |
| frontend | Deployment | React SPA on nginx (:8080). |
| postgres | StatefulSet | Optional bundle (postgres.bundled). |
| redis | Deployment | Optional bundle (redis.bundled). |
| migrate | Job (pre-install / pre-upgrade hook) | alembic upgrade head as the owner role. |
| ingress | Ingress | cert-manager TLS; API + SPA routing. |
| backups | PersistentVolumeClaim | Backup store (BACKUPS_ROOT) shared by the backend and the workers. See Backups on a Helm install. |
| worker-scan | NetworkPolicy (optional, off) | Egress limits for the scan worker. See Network policy. |
| backend | ServiceMonitor (optional, off) | Prometheus Operator scrape target. See Metrics. |
Prerequisites
- A Kubernetes cluster and a
kubectlcontext with permission to create the namespace and workloads. - Helm 3.
- An ingress controller (the chart defaults to class
nginx). - cert-manager with a
ClusterIssuernamedletsencrypt-prodfor the default TLS configuration (override viaingress.annotations). - On multi-node clusters, a
ReadWriteManyStorageClass for the shared scan workspace (workspace.persistence.storageClassName). A single-node cluster can use the per-podemptyDirfallback.
Validate the chart before installing
Before deploying, render the in-repo chart locally to catch values / template errors without touching a cluster (Helm 3+, from the repository root):
SECRET=$(openssl rand -hex 32)
HMAC_SECRET=$(openssl rand -hex 32)
GITHUB_APP_KEY=$(openssl rand -base64 32 | tr '+/' '-_')
helm lint charts/trustedoss \
--set env.secret.secretKey="$SECRET" \
--set env.secret.apiKeyHmacSecret="$HMAC_SECRET" \
--set env.secret.githubAppEncryptionKey="$GITHUB_APP_KEY" \
--set postgres.auth.password=throwaway \
--set ingress.host=trustedoss.example.com
helm template trustedoss charts/trustedoss --namespace trustedoss \
--set env.secret.secretKey="$SECRET" \
--set env.secret.apiKeyHmacSecret="$HMAC_SECRET" \
--set env.secret.githubAppEncryptionKey="$GITHUB_APP_KEY" \
--set postgres.auth.password=throwaway \
--set ingress.host=trustedoss.example.com \
>/dev/null
helm lint reports chart-structure problems; helm template fully renders
every manifest with the minimum required values, so a non-zero exit means the
chart would not install. The --set values here are throwaway — the real
install below uses your own secrets.
Quick start (bundled datastores, evaluation)
This runs PostgreSQL and Redis in-cluster — fast to stand up, but not recommended for production data.
git clone https://github.com/trustedoss/trusca.git && cd trusca
helm install trustedoss ./charts/trustedoss \
--namespace trustedoss --create-namespace \
--set fullnameOverride=trusca \
--set env.secret.secretKey="$(openssl rand -hex 32)" \
--set env.secret.apiKeyHmacSecret="$(openssl rand -hex 32)" \
--set env.secret.githubAppEncryptionKey="$(openssl rand -base64 32 | tr '+/' '-_')" \
--set postgres.auth.password="$(openssl rand -hex 24)" \
--set ingress.host=trustedoss.example.com \
--set env.corsAllowedOrigins=https://trustedoss.example.com
Replace trustedoss.example.com with your own hostname, and make sure DNS for
that host points at your ingress controller.
The in-cluster PostgreSQL and Redis have modest defaults and a single replica. For anything beyond a trial, use external managed datastores (below).
The chart is still named trustedoss, so without fullnameOverride every object is named <release>-trustedoss-<component> (for example trusca-trustedoss-backend). Setting fullnameOverride=trusca gives trusca-backend, trusca-worker-scan and so on. Choose it at first install and keep it on every upgrade: changing it later renames the objects, and Deployment selectors are immutable, so an upgrade would fail.
Production (external managed datastores — recommended)
Prefer Cloud SQL / RDS for PostgreSQL and Memorystore / ElastiCache for Redis over the in-cluster bundles. Provide a values file:
# values.prod.yaml
fullnameOverride: trusca
postgres:
bundled: false
redis:
bundled: false
env:
database:
url: postgresql+asyncpg://app:***@cloudsql-proxy:5432/trustedoss
# if you separate the DDL/owner role from the runtime role:
ownerUrl: postgresql+asyncpg://owner:***@cloudsql-proxy:5432/trustedoss
redis:
url: redis://memorystore:6379/0
secret:
# pre-created Secret carrying all six keys (see below)
existingSecret: trustedoss-prod-secrets
corsAllowedOrigins: https://trustedoss.example.com
ingress:
host: trustedoss.example.com
Checking the database role at startup
At startup the backend asks PostgreSQL what the role it connected as is able to do, and logs the answer:
db.role.separation.active: the runtime can read and write rows and nothing more. This is whatenv.database.ownerUrlbuys you: a compromise of the backend cannot drop the audit trigger or alter tables.db.role.separation.missing: the runtime holds DDL rights. Expected whenenv.database.urlpoints at the owning role, which is a supported single-role deployment. The message names what to change if you want the split.
The check asks about privileges, not about role names, so a database whose
DML-only role is called something other than trustedoss_app is recognised
correctly.
To make the second case refuse to start rather than warn, set
REQUIRE_DB_ROLE_SEPARATION=true. It is off by default because single-role is
supported; turn it on where the split is mandatory. If the privilege cannot be
determined, it fails closed.
Earlier versions refused to start whenever DATABASE_URL_APP was set and the
connected role was not literally trustedoss_app. Because the chart always
writes that key, a single-role external database, the configuration
values.yaml recommends, could not start, and the error told operators to
check docker-compose wiring that does not exist on Kubernetes.
Then install:
helm install trustedoss ./charts/trustedoss \
--namespace trustedoss --create-namespace \
-f values.prod.yaml
When env.secret.existingSecret is set, the chart renders no Secret of its
own. The referenced Secret must carry all six keys, or the pods will not
start:
DATABASE_URL_APPDATABASE_URL_OWNERREDIS_URLSECRET_KEY(at least 32 characters)API_KEY_HMAC_SECRET(at least 32 characters, independent ofSECRET_KEY; never reuse one value for both)GITHUB_APP_ENCRYPTION_KEY(URL-safe base64 of 32 random bytes, independent ofSECRET_KEY; it encrypts every stored credential, see Rotating the encryption key)
When existingSecret is unset, env.secret.secretKey,
env.secret.apiKeyHmacSecret and env.secret.githubAppEncryptionKey are all
required inputs to the chart itself: the release fails to render rather than
deriving either of the last two from secretKey. Generate secretKey and
apiKeyHmacSecret independently with openssl rand -hex 32, and
githubAppEncryptionKey with openssl rand -base64 32 | tr '+/' '-_'.
env.corsAllowedOrigins must enumerate the exact origins that serve the SPA
— no wildcard in production. List every scheme + host that browsers will use.
High availability without a managed cloud datastore
The example above assumes a managed Postgres/Redis (Cloud SQL, RDS,
Memorystore, ElastiCache). If you self-host on your own cluster instead, this
chart's bundled Postgres (a single Pod, no replication) and bundled Redis (a
single Pod, no Sentinel/Cluster) are not an HA topology by themselves; they
are meant for evaluation and small installs, matching the "Quick start"
section above. This chart does not implement Postgres/Redis clustering
itself. Leader election, WAL streaming, failover, and backup coordination
are an entire operator's worth of scope, and re-implementing an
orchestrator inside a Helm chart is out of bounds for this project.
Instead, run each datastore under an existing, dedicated K8s operator and
point this chart at the resulting Service, the exact same way the
managed-cloud example does with bundled: false:
- Postgres: CloudNativePG or the
Zalando postgres-operator
both give you a primary + replica set with automatic failover behind one
Service name. Point
env.database.url(andenv.database.ownerUrl, for role separation) at that Service instead of Cloud SQL/RDS; everything else in the values file above is unchanged. - Redis: a Sentinel or Cluster-mode deployment (e.g. the
Bitnami
redischart withsentinel.enabled: true, or Redis Cluster) gives you failover behind one client-visible address. Pointenv.redis.urlat it the same way.
Either way, set postgres.bundled: false / redis.bundled: false so this
chart renders no datastore objects of its own and defers entirely to the
operator-managed one.
Is the shared Redis instance itself a single point of failure?
Redis here plays four roles at once: Celery broker, Celery result backend,
the request-rate limiter, and the WebSocket connection registry
(core/ws_registry.py), all through the one env.redis.url. Whether that
is worth splitting into more than one Redis instance/index depends on what
actually happens to each role during an outage, so here is the current
answer rather than a guess:
- The rate limiter and the login throttle already fail open on a Redis
error (
core/ratelimit.py,core/login_throttle.py,core/redis_degradation.py): a request that cannot reach Redis is let through rather than rejected, and the degradation is logged (deduped) and surfaced on/health/ready. An outage here costs you rate-limiting, not availability. - Celery (scan/notification/backup tasks) genuinely needs its broker; an outage stops task dispatch until Redis recovers. This is not something a second Redis instance changes, since the broker's job IS Redis (or another broker entirely, which this chart does not support).
- The WebSocket connection registry (
core/ws_registry.py) also fails open (issue #458): a new connection is admitted rather than refused, so live scan-progress streaming keeps working through the outage the same as everything else reading Redis here. The trade-off is narrower than the other two: the per-user and global connection-count caps go unenforced for the outage's duration, so a client could open unbounded sockets while Redis stays unreachable. That is accepted deliberately (an operator-visible, self healing outage, over a hard-closed real-time feature for every user for the same duration) rather than copied mechanically from the rate-limiter case; see the module's own docstring for the full reasoning.
Given that, splitting Redis into a second instance for these three roles is not something this chart takes on now: it is new infrastructure surface (a second datastore to provision, monitor, and fail over) for an outage whose actual impact today is a temporarily unenforced abuse guard, not data loss, a stuck pipeline, or a broken feature.
How migrations run
A Helm pre-install + pre-upgrade hook Job runs alembic upgrade head once
as the owner DB role (DATABASE_URL_OWNER). The application pods run with
AUTO_MIGRATE=false, so the Job is the sole migrator.
Backend pods stay NotReady (/health/ready returns 503) until the schema is
at HEAD, so traffic only ever reaches a migrated schema. Migrations are
forward-only — the Job never downgrades. Hook ordering for the bundled case is:
Secrets → Postgres Service / StatefulSet → migration Job, and the Job's init
container waits for Postgres to accept connections before alembic runs.
Upgrade
git -C trusca pull && git -C trusca checkout <new-tag>
helm upgrade trustedoss ./trusca/charts/trustedoss \
--namespace trustedoss \
-f values.prod.yaml
The pre-upgrade migration Job applies any new schema before the new pods roll out. Because migrations are forward-only, take a database backup before upgrading — see Backup & restore.
Key values
The full table lives in the chart README. The values you most often set:
| Key | Default | Purpose |
|---|---|---|
image.tag | 0.23.1 | Image tag for backend / worker / frontend (never :latest). |
ingress.host | "" | Required. Public hostname. |
env.corsAllowedOrigins | "" | Required in prod. Allowed browser origins (no wildcard). |
env.secret.secretKey | "" | SECRET_KEY (≥32 chars). Required unless existingSecret. |
env.secret.apiKeyHmacSecret | "" | API_KEY_HMAC_SECRET (≥32 chars), a dedicated key for hashing stored API-key secrets. Required unless existingSecret; never the same value as secretKey. |
env.secret.githubAppEncryptionKey | "" | GITHUB_APP_ENCRYPTION_KEY, URL-safe base64 of 32 bytes, the key that encrypts stored credentials. Required unless existingSecret; never the same value as secretKey. |
env.secret.existingSecret | "" | Pre-created Secret with all six keys; disables the chart Secret. |
postgres.bundled | true | false → use env.database.* (external). |
redis.bundled | true | false → use env.redis.url (external). |
env.trivy.dbRepository | ghcr.io/aquasecurity/trivy-db | Override for an air-gapped internal mirror — see Air-gapped operation. |
env.trivy.dbRefreshHours | 168 | Weekly Trivy DB refresh; lower for fresher feeds. |
worker.trivyDbPersistence.enabled | true | Mount a PVC at /var/lib/trivy so the worker doesn't re-download on every restart. |
workspace.persistence.storageClassName | "" | RWX class for the shared scan volume on multi-node clusters. |
worker.replicaCount | 2 | Prefer scaling worker pods over per-pod concurrency. |
env.extraEnv | {} | Any runtime variable the chart does not name. See below. |
env.extraEnvFrom | [] | envFrom entries, for Secrets you created yourself. See below. |
env.extraVolumes | [] | volumes entries for backend, worker and beat. A certificate for a private authority goes here. |
env.extraVolumeMounts | [] | volumeMounts entries for the same three. See Private certificate authorities. |
backups.persistence.storageClassName | "" | RWX class for the backup store. See Backups on a Helm install. |
backups.persistence.size | 50Gi | Backup store size; roughly (database dump + workspace) x 8. |
image.backendDigest / workerDigest / frontendDigest | "" | Pin an image by digest. See Image digests. |
metrics.enabled | false | Turn on /metrics. See Metrics. |
metrics.serviceMonitor.enabled | false | Render a ServiceMonitor. |
networkPolicy.enabled | false | Limit the scan worker's network reach. See Network policy. |
Image digests
A tag can be moved to different image contents; a digest cannot. Set a digest
per image and the chart renders repository@sha256:... for it instead of
repository:tag. Images without a digest keep their tag. workerDigest also
covers beat and the migration Job, which run the worker image; the bundled
datastores take postgres.image.digest and redis.image.digest.
image:
backendDigest: sha256:<64 hex characters>
workerDigest: sha256:<64 hex characters>
frontendDigest: sha256:<64 hex characters>
Read a published image's digest with
crane digest ghcr.io/trustedoss/trusca-backend:0.23.1 (or
docker buildx imagetools inspect). A value that is not sha256: followed by
64 lowercase hex characters fails the render.
Metrics
metrics.enabled: true turns on the backend's /metrics endpoint
(METRICS_ENABLED), without going through env.extraEnv. To require a token,
create a Secret and name it in metrics.token.existingSecret; the backend
reads it as METRICS_TOKEN. With the Prometheus Operator installed,
metrics.serviceMonitor.enabled: true adds a ServiceMonitor for the backend
Service that presents the same token:
kubectl -n trustedoss create secret generic trustedoss-metrics \
--from-literal=METRICS_TOKEN="$(openssl rand -hex 32)"
metrics:
enabled: true
token:
existingSecret: trustedoss-metrics
serviceMonitor:
enabled: true
labels:
release: prometheus # whatever your Prometheus selects on
metrics.serviceMonitor.enabled without metrics.enabled fails the render,
since the endpoint answers 404 while it is off. The Ingress routes /metrics
to the backend, so the endpoint is reachable wherever the Ingress is: set a
token unless that is what you want. The series and their meaning are in
Disk & system health, and
Alerting has rules for them.
Network policy for the scan worker
worker-scan runs cdxgen, which runs the scanned project's own build tooling,
so it executes code the portal did not write. networkPolicy.enabled: true
renders a NetworkPolicy for that pod: no ingress, and egress only to
- cluster DNS (kube-dns in
kube-systemby default,networkPolicy.workerScan.dns), - the bundled Postgres and Redis pods,
- the public internet on TCP 443, 80, 22 and 9418, excluding private (RFC 1918), carrier-grade NAT, link-local and loopback ranges,
- the rules you add in
networkPolicy.workerScan.extraEgress.
The excluded ranges cover addresses in private space, which is where pod, Service and node addresses and the cloud metadata endpoint (169.254.169.254) usually are. Anything on a public address stays reachable on those ports, including a managed cluster's public API endpoint on 443; the chart's ServiceAccounts mount no token, so the worker has no credential to present there.
Other workloads are not covered. The policy only takes effect on a CNI that enforces NetworkPolicy; on one that does not, it is accepted and does nothing.
On a cluster running NodeLocal DNSCache, pods resolve through 169.254.20.10
on a hostNetwork pod, which the DNS selectors do not match and the internet
rule excludes. Add an ipBlock rule for 169.254.20.10/32 on port 53 (UDP
and TCP) to extraEgress.
Default-queue tasks run under this policy too. By default worker-scan also
subscribes to the default queue (worker.subscriptions.scan), so it runs
default-queue tasks such as email notifications (SMTP on 587, 465 or 25, often
to a relay on a private address) and ticket and webhook posts (often to an
internal host). Under the policy these fail only when worker-scan happens to
take the message. Before enabling the policy, either narrow worker-scan to the
scan queue once the default queue has drained (chart README, "Queue split
transition"), or add the SMTP, ticket and webhook hosts to extraEgress. The
install notes warn while both the policy and the default-queue subscription
are on.
worker:
subscriptions:
scan:
- trustedoss.scan
With an external Postgres or Redis, the chart cannot tell where they live, so
you list them in externalDatastoreEgress; the render fails without it rather
than cutting the worker off from its database. An internal package mirror goes
in extraEgress, and on an air-gapped cluster internet.enabled: false drops
the internet rule:
networkPolicy:
enabled: true
workerScan:
externalDatastoreEgress:
- to:
- ipBlock:
cidr: 10.20.0.5/32 # Postgres
ports:
- port: 5432
protocol: TCP
- to:
- ipBlock:
cidr: 10.20.0.6/32 # Redis
ports:
- port: 6379
protocol: TCP
extraEgress:
- to:
- ipBlock:
cidr: 10.30.0.0/24 # internal package mirror
ports:
- port: 8081
protocol: TCP
If scans start failing to resolve dependencies after enabling the policy, the
registry or source host they need is outside these rules; add it to
extraEgress.
Settings the chart does not name
Most keys above are spelled out one by one, which is clear about what the chart
supports and leaves it behind the portal at each release. env.extraEnv and
env.extraEnvFrom cover everything else, and they reach the backend, worker and
beat pods alike. The catalogue of what exists is
Environment variables.
Non-secret settings go in env.extraEnv:
env:
extraEnv:
SCANOSS_ENABLED: "true"
KEV_REFRESH_ENABLED: "false" # air-gapped: no CISA feed to reach
DISK_HARD_LIMIT_PCT: "98"
WEBHOOK_RATE_LIMIT: "240/minute"
Credentials go in a Secret you create, referenced from env.extraEnvFrom. A
value in extraEnv lives in your values file, and an SMTP password or an OAuth
client secret does not belong in a file people commit:
kubectl create secret generic trustedoss-notifications \
--from-literal=SMTP_HOST=smtp.example.com \
--from-literal=SMTP_USER=portal@example.com \
--from-literal=SMTP_PASSWORD='...' \
--from-literal=SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
env:
extraEnvFrom:
- secretRef:
name: trustedoss-notifications
This is how a Helm install reaches OAuth sign-in, SMTP / Slack / Teams notifications, the vendored-code identification service and the Jira link. None of them could be configured on a Helm install before, which is the gap this closes.
Verify it worked
-
The migration Job completed:
kubectl -n trustedoss get jobs# the trustedoss migrate Job should show COMPLETIONS 1/1
-
All pods are
Runningand backend pods areReady:kubectl -n trustedoss get pods# backend pods Ready means /health/ready returned 200 (schema at HEAD)
-
The readiness probe passes from inside the cluster:
kubectl -n trustedoss exec deploy/trusca-backend -- \curl -fsS http://localhost:8000/health/ready# → {"status":"ready","redis":"ok"}The
redisfield is observational only: it never turns a 200 into a 503. See the on-call runbook if it reads"degraded".
- The Ingress has an address and a valid certificate, then open
https://<ingress.host>/in a browser and sign in.
Troubleshooting
-
Backend pods stuck
NotReady./health/readyreturns503until the schema is at HEAD. Check the migration Job logs:kubectl -n trustedoss logs job/trusca-migrateA failed Job usually means the owner DSN (
DATABASE_URL_OWNER) lacks DDL privileges or cannot reach the database. -
Pods
CreateContainerConfigErrorwith an existing Secret. The referenced Secret is missing one of the four required keys. Confirm:kubectl -n trustedoss get secret trustedoss-prod-secrets -o jsonpath='{.data}' | tr ',' '\n'# expect DATABASE_URL_APP, DATABASE_URL_OWNER, REDIS_URL, SECRET_KEY -
Scans fail on multi-node clusters. The backend and worker share the scan workspace. Without a
ReadWriteManyStorageClass the worker cannot read what the backend wrote. Setworkspace.persistence.storageClassNameto an RWX class (nfs / efs / filestore / longhorn). -
TLS certificate never issues. The default annotations expect a cert-manager
ClusterIssuernamedletsencrypt-prod. Inspect the Certificate:kubectl -n trustedoss describe certificate
If you hit a chart bug, open an issue using the bug report template.
See also
- Install with Docker Compose — single-host install
- Upgrade — Docker Compose upgrade path
- Backup & restore — back up before upgrading
- Environment variables — every setting the chart maps
- Architecture — services, Trivy DB lifecycle, and the migration model
- Vulnerability data (Trivy DB) — air-gapped operation and DB refresh
- Hardening - the L1 database role-separation model this chart's
env.database.*split implements - Postgres sizing and connection tuning - sizing
max_connectionsagainst this chart's replica counts, bundled vs. external - v0.10.0 release notes — chart 0.10.0 breaking changes