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.22.6) 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. |
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)
helm lint charts/trustedoss \
--set env.secret.secretKey="$SECRET" \
--set env.secret.apiKeyHmacSecret="$HMAC_SECRET" \
--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 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 env.secret.secretKey="$(openssl rand -hex 32)" \
--set env.secret.apiKeyHmacSecret="$(openssl rand -hex 32)" \
--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).
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
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 five 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 five 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)
When existingSecret is unset, both env.secret.secretKey and
env.secret.apiKeyHmacSecret are required inputs to the chart itself: the
release fails to render rather than deriving API_KEY_HMAC_SECRET from
secretKey. Generate each independently with openssl rand -hex 32.
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.22.6 | 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.existingSecret | "" | Pre-created Secret with all five 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. |
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/trustedoss-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/trustedoss-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