Skip to main content

Install on Kubernetes with Helm

Audience

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

Installed from a checkout, not a registry

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.

Vulnerability matching ships in-chart

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

WorkloadKindNotes
backendDeploymentFastAPI API. AUTO_MIGRATE=false — migrations run in the Job.
workerDeployment (+ optional HPA)Celery worker (cdxgen / scancode / Trivy).
beatDeployment (replicas: 1)Celery scheduler — singleton.
frontendDeploymentReact SPA on nginx (:8080).
postgresStatefulSetOptional bundle (postgres.bundled).
redisDeploymentOptional bundle (redis.bundled).
migrateJob (pre-install / pre-upgrade hook)alembic upgrade head as the owner role.
ingressIngresscert-manager TLS; API + SPA routing.

Prerequisites

  • A Kubernetes cluster and a kubectl context with permission to create the namespace and workloads.
  • Helm 3.
  • An ingress controller (the chart defaults to class nginx).
  • cert-manager with a ClusterIssuer named letsencrypt-prod for the default TLS configuration (override via ingress.annotations).
  • On multi-node clusters, a ReadWriteMany StorageClass for the shared scan workspace (workspace.persistence.storageClassName). A single-node cluster can use the per-pod emptyDir fallback.

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.

Bundled datastores are for evaluation

The in-cluster PostgreSQL and Redis have modest defaults and a single replica. For anything beyond a trial, use external managed datastores (below).

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 what env.database.ownerUrl buys you: a compromise of the backend cannot drop the audit trigger or alter tables.
  • db.role.separation.missing: the runtime holds DDL rights. Expected when env.database.url points 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.

Versions before this check

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
Secret contents are mandatory

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_APP
  • DATABASE_URL_OWNER
  • REDIS_URL
  • SECRET_KEY (at least 32 characters)
  • API_KEY_HMAC_SECRET (at least 32 characters, independent of SECRET_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.

CORS in production

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 (and env.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 redis chart with sentinel.enabled: true, or Redis Cluster) gives you failover behind one client-visible address. Point env.redis.url at 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:

KeyDefaultPurpose
image.tag0.22.6Image 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.bundledtruefalse → use env.database.* (external).
redis.bundledtruefalse → use env.redis.url (external).
env.trivy.dbRepositoryghcr.io/aquasecurity/trivy-dbOverride for an air-gapped internal mirror — see Air-gapped operation.
env.trivy.dbRefreshHours168Weekly Trivy DB refresh; lower for fresher feeds.
worker.trivyDbPersistence.enabledtrueMount 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.replicaCount2Prefer 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

  1. The migration Job completed:

    kubectl -n trustedoss get jobs
    # the trustedoss migrate Job should show COMPLETIONS 1/1
  1. All pods are Running and backend pods are Ready:

    kubectl -n trustedoss get pods
    # backend pods Ready means /health/ready returned 200 (schema at HEAD)
  1. 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 redis field is observational only: it never turns a 200 into a 503. See the on-call runbook if it reads "degraded".

  1. 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/ready returns 503 until the schema is at HEAD. Check the migration Job logs:

    kubectl -n trustedoss logs job/trustedoss-migrate

    A failed Job usually means the owner DSN (DATABASE_URL_OWNER) lacks DDL privileges or cannot reach the database.

  • Pods CreateContainerConfigError with 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 ReadWriteMany StorageClass the worker cannot read what the backend wrote. Set workspace.persistence.storageClassName to an RWX class (nfs / efs / filestore / longhorn).

  • TLS certificate never issues. The default annotations expect a cert-manager ClusterIssuer named letsencrypt-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