Skip to main content

Environment variables

The portal reads its configuration from .env. The bundled .env.example enumerates every supported key. The install wizard (scripts/install.sh) populates the required keys with strong defaults; the rest you set as needed.

Audience

Operators tuning a deployment. Familiarity with .env files and Docker Compose's variable substitution.

Reading order

  1. .env in the repo root is loaded by docker-compose automatically.
  2. Backend code calls os.getenv() at runtime, never at module import time. This is CLAUDE.md rule #11. Restarting the container is enough to pick up a changed value — no rebuild needed.
  3. Compose substitutes ${VAR} references in docker-compose.yml from .env at docker-compose up time.

Every key listed below is read by apps/backend/core/config.py, docker-compose.yml, or scripts/* — the Read by column tells you which.

Required keys

These four must be present and non-empty. The wizard sets them.

KeySet byRead byNotes
SECRET_KEYwizard (openssl rand -hex 32)config.pyJWT signing key (HS256). Minimum 32 chars in non-dev. Rotating invalidates every refresh token.
DATABASE_URLwizardconfig.py, docker-compose.ymlpostgresql+asyncpg://user:pass@postgres:5432/trustedoss. Must use the postgres host (compose service name).
CORS_ALLOWED_ORIGINSwizardconfig.pyComma-separated. Production must enumerate origins explicitly — * is rejected at boot when allow_credentials=true.
DOMAINwizarddocker-compose.ymlHostname used by Traefik's host-rule. Stripped of scheme and path.

Application

KeyDefaultRead byDescription
APP_ENVdevconfig.pydev, staging, or prod. Drives a few CORS / log defaults.
LOG_LEVELINFOconfig.pyDEBUG, INFO, WARNING, ERROR.
LOG_MAX_SIZE20mdocker-compose.ymlSize at which a container's log file rotates.
LOG_MAX_FILE5docker-compose.ymlRotated files kept per container. With the default size, 100 MB per service.
DEMO_READ_ONLYfalseconfig.pyWhen truthy (1/true/yes/on), the backend runs as a read-only live demo: every non-auth mutation (POST/PUT/PATCH/DELETE) is rejected with an RFC 7807 403. Surfaces on GET /health so the SPA shows a banner. See Live demo.
IMAGE_TAG0.11.0docker-compose.ymlPinned tag for ghcr.io/trustedoss/trusca-backend, …/trusca-backend-worker, …/trusca-frontend.
UVICORN_WORKERS4Dockerfile.prod (uvicorn CLI), config.pyUvicorn worker process count for the backend container. Raising it uses more CPU cores per container instead of running more containers; multiply it into the connection-budget formula below before raising it.

Database

DATABASE_URL (above) is the canonical setting. The composed alternative below is provided so the GCP Cloud Run module can mount DB_PASSWORD from Secret Manager without baking the DSN into Terraform state. Set either DATABASE_URL or the four DB_* keys — never both.

KeyDefaultRead byDescription
DATABASE_URLconfig.py, docker-compose.ymlSee above.
DB_USERconfig.pyComposed-DSN: username. URL-encoded in the resulting DSN.
DB_PASSWORDconfig.pyComposed-DSN: password. URL-encoded so @, :, /, #, % survive parsing.
DB_HOSTconfig.pyComposed-DSN: host. May be a Cloud SQL Auth Proxy unix socket path (/cloudsql/...).
DB_PORT5432config.pyComposed-DSN: port.
DB_NAMEconfig.pyComposed-DSN: database name.
POSTGRES_USERtrustedossdocker-compose.ymlUsed by the postgres container's init. Must match DATABASE_URL.
POSTGRES_PASSWORDdocker-compose.ymlGenerated by the wizard.
POSTGRES_DBtrustedossdocker-compose.ymlDatabase name.
DATABASE_URL_OWNERfalls back to DATABASE_URLconfig.pyL1 role separation: the DDL-capable superuser DSN, used only for alembic upgrade head and the startup role check.
DATABASE_URL_APPfalls back to DATABASE_URLconfig.pyL1 role separation: the DML-only runtime DSN the backend and Celery worker connect with day to day.
POSTGRES_APP_PASSWORD(unset)docker-compose.ymlPassword for the trustedoss_app role Compose provisions on first boot when this is set. Required to actually run L1 under Compose, not just to declare the two DSNs above.
REQUIRE_DB_ROLE_SEPARATIONfalsecore/db_role.pyWhen true, refuses to start (rather than warning) if the connected role still holds DDL privileges. See Hardening for the full L1 role-separation setup.

If any of the four DB_* keys is set, all of them must be set (or the composed branch raises at boot). The portal uses async SQLAlchemy + asyncpg. Connection pool sizing (DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_SYNC_POOL_SIZE, DB_SYNC_MAX_OVERFLOW) is documented in .env.example's "Postgres connection budget" section: multiply the FastAPI pool by uvicorn workers × backend replicas, add the Celery worker and beat pools, and stay under Postgres max_connections. The backend logs a warning at boot if your deployment's shape does not fit.

Redis & Celery

KeyDefaultRead byDescription
REDIS_URLredis://redis:6379/0config.pyBroker + result backend.
CELERY_CONCURRENCY2docker-compose.ymlWorker process count. Each slot needs ~2 GB RAM at peak.

Authentication

KeyDefaultRead byDescription
SECRET_KEYconfig.pySee Required keys. HS256 signing.
ACCESS_TOKEN_EXPIRE_MINUTES30config.pyJWT access token lifetime.
REFRESH_TOKEN_EXPIRE_DAYS7config.pyRefresh token lifetime. Rotation + reuse detection enabled.
REGISTRATION_RATE_LIMIT5/minuteconfig.pyPer-IP slowapi limit for POST /auth/register, which performs a bcrypt password hash.
REFRESH_TOKEN_RETENTION_GRACE_DAYS1tasks/auth_token_retention.pyDays past a refresh token's own expires_at before the daily sweep deletes the row. A rotated / logged-out / reuse-revoked row keeps its original expires_at, so it is caught by this same predicate within one REFRESH_TOKEN_EXPIRE_DAYS window of being revoked; there is no separate revoked-at pass.
PASSWORD_RESET_TOKEN_RETENTION_GRACE_DAYS1tasks/auth_token_retention.pyDays past a password-reset token's own expires_at before the daily sweep deletes the row. Same reasoning as the refresh-token grace above.

Vulnerability data

The portal correlates SBOMs against CVEs using a local Trivy DB — a compiled bundle of NVD + OSV + GHSA + EPSS + KEV. See Vulnerability data (Trivy DB) for the lifecycle.

KeyDefaultRead byDescription
TRIVY_DB_REPOSITORYghcr.io/aquasecurity/trivy-dbconfig.pyOCI repository the Trivy DB is pulled from. Override for an air-gapped internal mirror — see Air-gapped operation.
TRIVY_DB_REFRESH_HOURS168 (weekly)config.pyCelery Beat schedule for the trivy_db_refresh task. Lower for fresher feeds, higher to reduce egress.
TRIVY_CACHE_DIR/var/lib/trivyintegrations/trivy.pyDirectory the DB is unpacked into. Backed by the shared trivy-cache volume — worker (rw) and backend (ro) mount it so the admin health / disk panels can read the DB state.
TRIVY_TIMEOUT_SECONDS300config.pyPer-scan timeout for trivy sbom. Raise to 600900 for very large monorepos.

KEV catalog

Independently of the Trivy DB bundle, the portal syncs the CISA KEV (Known Exploited Vulnerabilities) catalog into its vulnerability catalog once a day (Celery beat task trustedoss.kev_catalog_refresh, ~1,600 entries, delistings included). KEV-listed findings carry a badge and a remediation due date and drive the default Priority sort — see Vulnerabilities — KEV.

KeyDefaultRead byDescription
KEV_FEED_URLhttps://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.jsonconfig.pyURL the daily refresh downloads the KEV feed from. Override to point at an internal mirror of the CISA JSON.
KEV_REFRESH_ENABLEDtrueconfig.pyToggles the daily refresh. Set false on air-gapped deployments that cannot reach the feed — with the refresh off, no KEV data is loaded, so KEV badges and due dates are not shown and the Priority sort effectively degrades to severity → EPSS.
KEV_REFRESH_TIMEOUT_SECONDS30config.pyOutbound HTTP timeout for the CISA feed download.

Vulnerability SLA

Per-severity remediation-SLA windows, counted from a finding's project-level first detection (carried forward across re-scans and re-matches). The vulnerabilities list computes the due date and the overdue / imminent / ok state from these windows; a daily sweep raises an in-app alert when open findings cross their deadline. See Vulnerabilities — Remediation SLA and aging.

KeyDefaultRead byDescription
VULN_SLA_DAYS_CRITICAL7config.pyRemediation window (days) for Critical findings. A non-numeric or non-positive value falls back to the default rather than disabling the clock.
VULN_SLA_DAYS_HIGH30config.pyRemediation window (days) for High findings. Same fallback rule.
VULN_SLA_DAYS_MEDIUM90config.pyRemediation window (days) for Medium findings. Same fallback rule.
VULN_SLA_DAYS_LOW180config.pyRemediation window (days) for Low findings. Same fallback rule. Info / Unknown severities have no window and no key — they carry no SLA.
Narrowing a window makes findings overdue silently

These are read at call time, so changing one takes an edit and a restart. Every finding the new window puts past its deadline went past it in the past, and the SLA sweep only alerts on deadlines crossed within its trailing window, so none of them produce a notification. Nobody is told, and the change surfaces later as a longer ?sla=overdue list.

Each backend start logs vuln_sla.overdue_at_boot with the window values in force and the overdue count per severity. Comparing the line from before the restart with the one after shows what the change did: high from 30 to 7, overdue from 40 to 380. The count is exact at any size, because it is one aggregate rather than a scan; a scan would have to stop somewhere, and a deployment past that point would report the same number on both sides of the change.

The log is the whole of it. There is no alert, so somebody has to look, and the reason to keep it is the person who asks months later when the backlog grew.

| VULN_SLA_ALERTS_ENABLED | true | config.py | Toggles the daily SLA-breach sweep (Celery beat trustedoss.vuln_sla_sweep, 02:45 UTC). The sweep is a pure internal computation with no egress; only the exact tokens false / 0 / no disable it. |

Build gate

The CI build gate fails a build on Critical CVEs and forbidden licenses out of the box; those conditions are not env-driven. The single env knob below adds an optional EPSS dimension.

KeyDefaultRead byDescription
GATE_MALICIOUS_ENABLEDtruepolicy_gate.pyWhether the build gate blocks on known-malicious packages. On by default, unlike the other GATE_* knobs: those tune how strictly an existing signal is read, this one decides whether an active attack reaches production. Blocks regardless of severity — a malicious package has no honest version to upgrade to. When off the gate's malicious_component_count is 0 because nothing was checked, and malicious_gate_enforced says so. Only false / 0 / no disable.
GATE_EPSS_THRESHOLD(unset)config.pyOptional EPSS gate. A value from 0 to 1. When set, the build gate also fails if any open finding has epss_score >= GATE_EPSS_THRESHOLD, and the gate result carries epss_gate_count + epss_threshold. Unset (the default) disables the EPSS gate — only the existing Critical-CVE / forbidden-license conditions apply. Findings without an EPSS value never trip the gate. EPSS data is sourced from the Trivy DB, so only CVEs Trivy supplies a value for are eligible.
GATE_EPSS_ON_MISSING_DATAallowconfig.pyWhat the gate does when a configured GATE_EPSS_THRESHOLD could not be evaluated because no open finding on the scan carries an EPSS score. allow (the default) lets the build through, which is the behaviour every deployment had before this option existed, so upgrading changes no result. block fails the build, so a configured threshold cannot be ignored in silence. Applies to the "nothing scored at all" case only: partial coverage is normal, because EPSS does not score every CVE, and never blocks. The gate result reports which case it was in epss_outcome.

See build gate for the gate model and Gate the build on EPSS for the CI walkthrough.

Groups

KeyDefaultRead byDescription
GROUP_CASCADE_ENABLEDtrueconfig.pyWhether a membership or API key scoped to a group also reaches that group's subgroups. On by default: a group only reaches its descendants once an administrator has moved it into the hierarchy on purpose, so turning this on changes nothing for a deployment that has not yet nested any groups. false restores the pre-hierarchy behavior of a flat, single-group scope.
GROUP_SEARCH_RATE_LIMIT20/minuteconfig.pyslowapi limit for GET /v1/groups in search mode (?q=), keyed per authenticated user. This is the endpoint behind the project-creation group picker's live-typing search, so it is sized for keystroke-rate calls rather than the occasional read the drill-down mode gets.

See Nested groups for the cascade's user-facing behavior.

Scan pipeline

KeyDefaultRead byDescription
TRUSTEDOSS_SCAN_BACKENDrealconfig.pyreal (subprocess cdxgen / scancode / Trivy) or mock (fixture JSON). mock is the dev / CI default for the test harness; production must leave this as real.
SCAN_LOAD_TEST_DELAY_ENABLEDfalseconfig.pyLoad-test-only knob: when a scan pipeline task checks it and finds it enabled, it sleeps SCAN_LOAD_TEST_DELAY_SECONDS and marks the scan succeeded instead of running cdxgen/Trivy. The mock backend finishes too fast to build a queue, so this exists to hold a worker slot busy long enough to measure trigger-to-started_at and started_at-to-completed_at gaps under N concurrent triggers. Off by default, and refused outside APP_ENV=dev even when set. config.scan_load_test_delay_seconds() returns 0.0 (disabled) on any deployment where APP_ENV is not dev, logging a WARNING if the flag was set anyway. Never enable this against a real deployment; every scan it touches reports fabricated success.
SCAN_LOAD_TEST_DELAY_SECONDS5config.pySeconds a load-test-mode scan task sleeps before completing (see SCAN_LOAD_TEST_DELAY_ENABLED). Clamped to [0.1, 3600]. Only takes effect when the enable flag above is also set and APP_ENV=dev.
SCANCODE_TIMEOUT_SECONDS600config.pyHard wall-clock limit for the scancode first-party license stage. On timeout the scan continues with declared licenses only (best-effort).
SCANCODE_MAX_FILES20000config.pyCeiling on eligible first-party files (after the exclude filter). Over this, scancode is skipped and the scan keeps declared licenses only.
SCANCODE_MAX_DETECTIONS5000config.pyCap on the number of detected-license findings persisted per scan.
SCANCODE_MAX_RESULT_BYTES268435456 (256 MB)config.pyCeiling on the scancode JSON artefact before parsing — guards against an OOM from a hostile tree.
SCANOSS_ENABLEDfalseconfig.pyMaster opt-in for the SCANOSS vendored-OSS stage. Off by default. When true, the stage fingerprints the source tree and sends those fingerprints (never source) to SCANOSS_API_URL to identify copied-in OSS — enable only with operator consent to that external egress. When false the stage is skipped entirely (no scanner, no egress). See Components & licenses → Vendored-OSS identification.
SCANOSS_API_URLhttps://api.osskb.orgconfig.pySCANOSS knowledge-base endpoint the fingerprints are matched against (used only when SCANOSS_ENABLED=true). Point this at a self-hosted SCANOSS instance to keep fingerprints inside your network.
SCANOSS_API_KEY(empty)config.pyOptional API key for SCANOSS_API_URL (a paid / self-hosted endpoint). Empty uses the free api.osskb.org tier.
SCANOSS_TIMEOUT_SECONDS300config.pyHard wall-clock limit for the SCANOSS stage. On timeout the scan continues without vendored-OSS results (best-effort).
TRUSTEDOSS_VERSIONunknownconfig.pyThe version this deployment states as its own — in SLSA provenance, on the About screen, and as the SBOM tool version in every document TRUSCA produces. Release images inject the tag at build time (ARG TRUSTEDOSS_VERSION). The default is deliberately not a plausible version number: a placeholder would make all three assert a release that does not exist, and unknown is what the 2026 SBOM minimum elements ask for when no identifier is available.
TRUSTEDOSS_COMMITunknownconfig.pyThe commit this image was built from, injected at build time the same way as TRUSTEDOSS_VERSION. Surfaced on the About screen and the trusca_build_info metric so an operator can tell which build is actually running.
TRUSTEDOSS_BUILT_ATunknownconfig.pyUTC build timestamp (ISO 8601), same injection mechanism as TRUSTEDOSS_COMMIT. Also published as trusca_build_time_seconds so a collector can compute image age without parsing a metric label.
SBOM_AUTHOR(unset)config.pyThe entity that creates the SBOM data, recorded as the SBOM author on every export. The 2026 SBOM minimum elements ask an SBOM to name it, and nothing in a scan can discover it — it is the organisation operating the portal. Left unset the field is omitted rather than filled with a placeholder, which would satisfy the element while telling a recipient nothing.
SCAN_SCOPE_FILTER_ENABLEDtrueconfig.pyMaster switch for the runtime-scope post-filter: source scans drop non-deployable dependencies (Maven test/provided, npm devDependencies) from the SBOM before persist, signing and Trivy matching. Pure local transformation, no egress. Only the exact tokens false / 0 / no disable it. See Components & licenses → Runtime-scope filtering.
SCAN_SCOPE_FILTER_MAVEN_ENABLEDtrueconfig.pyMaven half of the scope filter (drops cdxgen scope optional/excluded nodes). Disable if a project relies on Maven <optional>true</optional> runtime dependencies — cdxgen tags those optional exactly like test scope, so they are dropped too.
SCAN_SCOPE_FILTER_NODE_ENABLEDtrueconfig.pynpm half of the scope filter (drops packages the committed/generated package-lock.json classifies as dev). A package absent from the lockfile is always kept.
LICENSE_FETCH_ENABLEDtrueconfig.pyPost-cdxgen license enrichment. When cdxgen emits a component with no SPDX license (common for a bare requirements.txt / go.mod), the pipeline asks the component's public registry (PyPI / Maven Central / crates.io / pkg.go.dev / RubyGems / NuGet) for the declared license by purl and caches it — this pulls the "unknown" license ratio down. Only a package name+version leaves the network (the registry the package manager already contacts), so it defaults on, unlike the SCANOSS fingerprint egress. Set false / 0 / no on an air-gapped deployment so an unlicensed component stays unknown instead of paying a per-component network timeout. The fetcher also re-checks a component whose SBOM recorded exactly one licence that would block the build: package metadata often lists several licences the recipient may choose between, and an SBOM that carries only the first of them fails a gate the package does not deserve to fail.
EXTERNAL_PACKAGE_LOOKUP_ENABLEDtrueconfig.pyWhether the deps.dev package/advisory lookup (pre-adoption catalog search) makes outbound calls, gating both GET /v1/external-packages and GET /v1/external-advisories/{id}. Same shape as LICENSE_FETCH_ENABLED: only a package name/ecosystem or an advisory id leaves the network, to a fixed public host (api.deps.dev), so it defaults on. Set false / 0 / no on an air-gapped deployment to hide the lookup entry points and answer 404 rather than a per-call timeout.
MALICIOUS_ENABLEDtrueconfig.pyKnown-malicious package flagging: components are matched against a snapshot of OSV MAL- advisories vendored with the release and stamped flagged / clear on the shared catalog. Fully offline, zero egress. Not a vulnerability axis — no finding is created and the count never enters severity totals. Turning this off leaves the columns unset, which surfaces render as not assessed rather than clean. Only false / 0 / no disable. See Components & licenses → Known-malicious packages.
MALICIOUS_REFRESH_ENABLEDfalseconfig.pyWhether the weekly beat may rebuild the malicious snapshot from the OSV archives (~274 MB). Off by default like every other new egress target. The re-stamp half of that beat always runs — it is local — so leaving this alone still lets a release upgrade reach existing rows. Air-gapped installs never turn it on.
MALICIOUS_WAIVE_MAX_DAYS30license_policy_service.pyLongest lifetime a malicious-package waiver may have. Shorter than LICENSE_WAIVE_MAX_DAYS on purpose: a licence waiver can be a settled decision, a malicious waiver only buys time to challenge the advisory upstream. Lowering it does not shorten waivers already written.
MALICIOUS_SNAPSHOT_STALE_DAYS60config.pyAge at which the admin panel calls the malicious snapshot stale. Shorter than the EOL panel's 180 because advisories are published daily.
EOL_ENABLEDtrueconfig.pyEnd-of-life flagging: components matching the endoflife.date product whitelist are stamped eol / supported / unknown on the shared catalog. Fully offline — verdicts come from a snapshot vendored with the release, zero egress. Only false / 0 / no disable. See Components & licenses → End-of-life flagging.
EOL_SNAPSHOT_PATH(empty — vendored file)config.pyOperator override for the endoflife.date snapshot. Air-gapped installs can build a fresher snapshot on a connected host (python3 scripts/refresh_eol_snapshot.py), mount it, and point this at the file.
EOL_REFRESH_ENABLEDfalseconfig.pyOpt-in live fetch: the weekly beat downloads fresh lifecycle data from EOL_FEED_URL_TEMPLATE. Off by default — this is new egress to a third-party host; the beat's local re-stamp pass runs either way. Only the exact tokens true / 1 / yes enable (fail-closed, the SCANOSS posture).
EOL_FEED_URL_TEMPLATEhttps://endoflife.date/api/{product}.jsonconfig.pyPer-product API template for the live fetch ({product} is substituted). Point at an internal mirror to keep the egress inside your network.
EOL_REFRESH_TIMEOUT_SECONDS15config.pyHTTP timeout per product request during the live fetch. Bounded [1, 120]; the whole sweep is additionally capped at 60 s wall-clock.
WORKSPACE_HOST_PATH/tmp/trustedossconfig.py, docker-compose.ymlHost directory mounted into the worker as /workspace. Holds repo clones + scan artefacts (cdxgen SBOM, scancode output). The compose stack overrides this to /workspace inside the container.
ORT_RULES_PATH/opt/trustedoss/ort/rules.ktsdocker-compose.ymlLegacy path inside the worker, vestigial after the ORT stage was removed. Nothing reads it and the file it names no longer exists — license-tier classification comes from _LICENSE_CATEGORY_DEFAULTS in apps/backend/tasks/scan_source.py.
JSONB_ROW_SIZE_LIMIT_BYTES262144 (256 KB)config.pyPer-row JSON byte ceiling before the writer truncates and emits a warning. Guards the I-1 unbounded-payload class.

Scan retention

These keys tune the automatic retention sweep that reclaims superseded and stale scan snapshots. The sweep runs as a Celery beat task every 6 hours. See Scan retention for the full model.

KeyDefaultRead byDescription
SCAN_RETENTION_SUPERSEDED_GRACE_DAYS7config.pyDays a superseded snapshot is kept before the sweep reclaims it. A snapshot is superseded when a newer successful scan lands on the same (project, normalized ref) target. Set higher to keep more rollback history per target.
SCAN_RETENTION_KEEP_LAST30config.pyMinimum number of ref-less and failed scans kept per project, regardless of age. The sweep never trims below this floor — it protects ad-hoc and diagnostic scans that carry no ref target.
SCAN_RETENTION_MAX_AGE_DAYS180config.pyHard age ceiling. Any non-release scan older than this is reclaimed by the sweep even if it is still the live snapshot for its target. Scans labelled metadata.release are exempt and kept forever.

Webhook receivers

Both receivers are public: the signature covers the body, so the body is read and the repository resolved before any credential is checked. These two keys bound what an unauthenticated caller can make that cost. See Webhooks.

KeyDefaultRead byDescription
WEBHOOK_MAX_BODY_BYTES2097152 (2 MiB)config.pyBodies over this are refused with 413 before being buffered. Clamped to 6553626214400; the ceiling is what GitHub itself refuses to deliver above.
WEBHOOK_RATE_LIMIT120/minuteconfig.pyslowapi limit string, keyed on source IP (the only identity available before the signature is checked). A 429 costs a delivery (no Git host retries a 4xx on its own), so raise this rather than lose events.

WebSocket gateway

KeyDefaultRead byDescription
WEBSOCKET_MAX_CONNECTIONS_PER_USER8config.pyPer-user concurrent connection ceiling, enforced against a Redis-backed registry shared by every backend process (exact regardless of worker or pod count). The scan detail page opens two sockets per open tab, so 8 covers four tabs at once. The connection that pushes a user over the cap is admitted; the user's oldest connection is evicted with close code 1001 (reason="newer_connection").
WEBSOCKET_MAX_CONNECTIONS_GLOBAL500config.pySystem-wide concurrent connection ceiling across every user, same Redis-backed registry as above. A connection that would push the total over this cap is refused outright (close code 4429, reason="capacity_at_limit") rather than evicting anyone else's connection.
WEBSOCKET_AUTH_TIMEOUT_SECONDS1.0config.pyHow long the gateway waits for the first {"type":"auth"} frame. Connections that miss the window are closed with 1008 / reason="auth_timeout".

Notifications

KeyDefaultRead byDescription
SMTP_HOST(empty)config.pySMTP server. Without it, email notifications raise NotificationDisabled and the channel is skipped.
SMTP_PORT587config.pySMTP port. STARTTLS expected on 587.
SMTP_USER(empty)config.pySMTP username.
SMTP_PASSWORD(empty)config.pySMTP password.
SMTP_USE_STARTTLStrueconfig.pySet false only for SMTP servers that demand implicit TLS on 465 or are testing on 25.
SMTP_FROMno-reply@trustedoss.localconfig.pyFrom: header for outgoing notifications. Override per environment.
SMTP_TIMEOUT_SECONDS10config.pyPer-call SMTP socket timeout.
SLACK_WEBHOOK_URL(empty)config.pyOrg-wide Slack webhook for super_admin notifications. Per-team webhooks are configured in the UI.
TEAMS_WEBHOOK_URL(empty)config.pyOrg-wide MS Teams webhook.
NOTIFICATION_HTTP_TIMEOUT_SECONDS10config.pyOutbound HTTP timeout for Slack / Teams webhooks.

Password reset

KeyDefaultRead byDescription
PASSWORD_RESET_BASE_URLhttp://localhost:5173config.pyFrontend base URL embedded in reset emails. The link template is {base}/reset-password?token={token}.
PASSWORD_RESET_RATE_LIMIT5/minuteconfig.pyPer-IP slowapi limit for POST /auth/forgot-password.
REFRESH_RATE_LIMIT30/minuteconfig.pyPer-IP slowapi limit for POST /auth/refresh. Higher than the login limit because every open tab refreshes on a schedule; low enough that a stolen refresh cookie cannot be polled to keep a freshly minted access token in hand.
LOGIN_THROTTLE_ENABLEDtrueconfig.pyWhether failed sign-ins are counted per address as well as per IP. Off leaves only the per-IP limit, which does not see guessing spread across many addresses.
LOGIN_THROTTLE_FAILURES10config.pyConsecutive failures for one address before it is refused. Deliberately above the per-IP budget of 5/minute: a threshold of 5 would fire first for anyone signing in from one machine, making that limiter's documented sixth-attempt 429 unreachable and meeting a mistyped password with the harsher control. This one is for failures accumulating against one address from many sources.
LOGIN_THROTTLE_WINDOWS60,300,900,1800config.pyHow long each successive refusal lasts, in seconds. The last value is the ceiling and repeats. A window always expires on its own, and opening the next one costs a fresh threshold of failures rather than a single attempt, so holding somebody out means sustaining the guessing. It cannot be made impossible: any control that refuses after N failures can be held open by supplying N failures. The two ways back that do not depend on the attacker stopping are completing a password reset, which needs the inbox, and POST /v1/admin/users/{id}/unlock-sign-in. Values below 1 are rejected with a warning.
PASSWORD_RESET_CONFIRM_RATE_LIMIT5/minuteconfig.pyPer-IP slowapi limit for POST /auth/reset-password. The token is the credential there, so the endpoint is a guessing surface like login and gets the same default.
PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS300config.pyMinimum seconds between two reset emails to the same address. Returned as Retry-After on cooldown.

OAuth (demo SaaS only)

These apply to the demo SaaS deployment. Self-hosted installs leave them empty (the /auth/oauth/{provider}/authorize endpoint then returns 503 with oauth_provider_disabled = true).

KeyDefaultRead byDescription
GITHUB_CLIENT_ID(empty)config.pyGitHub OAuth App client ID.
GITHUB_CLIENT_SECRET(empty)config.pyGitHub OAuth App client secret.
GOOGLE_CLIENT_ID(empty)config.pyGoogle OAuth client ID.
GOOGLE_CLIENT_SECRET(empty)config.pyGoogle OAuth client secret.
OAUTH_STATE_TTL_SECONDS300config.pyLifetime of the signed state JWT (CSRF guard). RFC 6749 §10.12.
OAUTH_HTTP_TIMEOUT_SECONDS10config.pyOutbound HTTP timeout to OAuth provider APIs.
OAUTH_LOGIN_REDIRECT_DEFAULThttp://localhost:5173/config.pyWhere the SPA lands after a successful OAuth callback.
OAUTH_LOGIN_REDIRECT_FAILUREhttp://localhost:5173/loginconfig.pyWhere the SPA lands when the callback fails. Receives ?error=oauth_failed.

Single sign-on (generic OpenID Connect)

The deployment's own identity provider. One provider, not a list: an organisation has one, and every endpoint is read from its discovery document, so naming the issuer is most of the wiring. Leave OIDC_ISSUER empty and no SSO button appears.

KeyDefaultRead byDescription
OIDC_ISSUER(empty)config.pyIssuer URL, for example https://login.example.com. Must be https: this one request decides every other endpoint, so it is the one that must not be tamperable. Endpoints in the document must sit on the same host as the issuer, which is checked at sign-in.
OIDC_CLIENT_ID(empty)config.pyClient id registered with the provider.
OIDC_CLIENT_SECRET(empty)config.pyClient secret. The exchange is a confidential-client authorization code flow.
OIDC_SCOPESopenid email profileconfig.pyScopes requested at sign-in. openid is added back if omitted.
OIDC_GROUPS_CLAIMgroupsconfig.pyUserinfo claim listing group membership. Unlike the address there is no standard claim to insist on, since nothing vouches for a group list either way.
OIDC_GROUP_ROLE_MAP(empty)config.pygroup:grade pairs, comma separated, deciding the grade a person gets on the team created at first sign-in. Empty means everyone keeps the historical grade. Once set, someone matching no group gets the lowest grade, because a deployment that has mapped its groups has said what matching none of them means. super_admin is refused here even if written: whoever can create a group in the provider would otherwise be able to mint a portal administrator.
AUTH_AUTO_REGISTERfalseconfig.pyWhether an unknown person who authenticates through the deployment's own identity provider becomes a user. Off means they are refused and an administrator adds them, one at a time or in bulk. On a deployment pointed at a company directory everybody in the company can authenticate and only some of them are meant to have an account, which is why this is off rather than on. The hosted providers (GitHub, Google) are unaffected and keep creating an account on first sign-in.
DEFAULT_MEMBER_ROLE(empty)config.pyThe grade a deployment has chosen for people nobody graded: a bulk-registration row naming no role, or a first sign-in through your own identity provider on a deployment that has not set OIDC_GROUP_ROLE_MAP. Once a group map exists it decides the grade on its own, and somebody matching none of its groups gets viewer regardless of this setting. Empty means no choice was made and each path keeps what it granted before this setting existed, which is developer for an account an administrator adds and the personal-team team_admin for a first sign-in. A value this does not recognise resolves to viewer with a warning, rather than silently landing on something more permissive. super_admin is refused even if written.
TICKET_WEBHOOK_URL(empty)config.pyWhere to post an event worth raising a ticket for. Empty means off, and off means nothing is called at all. The portal posts a structured JSON event and your own adapter turns it into a ticket, because the mapping from event to ticket is where organisations differ most. The post is made by a background task, never by the flow that produced the event.
TICKET_WEBHOOK_TOKEN(empty)config.pyBearer token sent with the post. Empty means none, which is right when the URL already carries a secret in its path.
TICKET_WEBHOOK_EVENTS(empty)config.pyWhich event kinds are worth a ticket, comma separated. Empty means all of them, the same reading an empty condition has on a notification routing rule.
AUDIT_EXPORT_URL(empty)config.pyWhere to hand the audit trail as it accumulates. Empty means off; the audit API is unchanged either way. A background task posts batches every five minutes and moves its position only after the collector accepts one. A fresh destination starts at the beginning of the trail rather than at the moment it was configured.
AUDIT_EXPORT_TOKEN(empty)config.pyBearer token sent with each batch.
AUDIT_EXPORT_BATCH_SIZE500config.pyRows per post, clamped to 1..5000. A deployment that has fallen behind catches up over several runs rather than in one request the collector may refuse.
AUDIT_EXPORT_LAG_SECONDS30config.pyHow far behind the present the export reads, clamped to 0..3600. Not a throttle: a row is stamped when its transaction commits, so ordering by the stamp alone can place a row behind a position already passed, and it would never be sent. Raise it if your deployment holds long transactions.
AUDIT_LOG_RETENTION_DAYS90config.pyAge past which an already-exported audit row is purge-ready. Does not delete anything: audit_logs is append-only at the database layer, and the sanctioned purge is a manual, two-operator SQL session. The daily readiness report only counts + logs rows that are both this old and past the export cursor above. See Operational data retention and Audit log → Retention.
METRICS_ENABLEDfalseconfig.pyWhether this deployment publishes an operational metrics endpoint at /metrics. Off answers 404 rather than 403, so a deployment without a scrape target looks like one without the feature. What it publishes is a fixed list of aggregate counts held to tests/contracts/metrics-series.json; no project, package or person's name appears in the output.
METRICS_TOKEN(empty)config.pyA bearer token a scraper must present. Empty means open to anyone who can reach the endpoint, which is the usual arrangement when /metrics is off the public ingress. Set it when the endpoint is reachable from somewhere you do not control. A wrong token answers 404, the same as switched off, and the comparison is constant-time.
QUEUE_BACKLOG_METRICS_ENABLEDfalseconfig.pyWhether /metrics also publishes the broker-backlog series (trusca_broker_queue_backlog, trusca_scan_queue_wait_seconds). Off by default and independent of METRICS_ENABLED: the other series in that document only read Postgres, and this one opens a second connection to the Celery broker, so turning the endpoint on does not by itself turn this series on too.
QUEUE_BACKLOG_ALERT_ENABLEDfalseconfig.pyWhether the S6 beat sweep that turns a sustained Celery-queue backlog into a Slack/Teams alert runs. A Compose deployment has no autoscaler layer, so this is the signal the product gives instead - see Docker Compose - Scan capacity for the formula it pairs with. Hard dependency on QUEUE_BACKLOG_METRICS_ENABLED: turning this on while that is off does not error, it degrades to a per-tick skip logged at WARNING.
QUEUE_BACKLOG_ALERT_SCAN_QUEUE_THRESHOLD10config.pyMessages waiting on trustedoss.scan before the sweep counts it as backlogged. Scan slots stay busy for tens of minutes (scan_hard_time_limit_seconds()), so a handful of queued scans is ordinary.
QUEUE_BACKLOG_ALERT_DEFAULT_QUEUE_THRESHOLD100config.pySame idea for trustedoss.default, an order of magnitude higher because that queue carries short, frequent work (notifications, backups, audit export, catalog-refresh beats - see S3's queue split) that a healthy deployment clears in seconds.
QUEUE_BACKLOG_ALERT_SUSTAIN_SECONDS600config.pyHow long a queue must stay over its threshold, in seconds, before it alerts. A momentary spike is not an incident; still breached this long after it first crossed is.
QUEUE_BACKLOG_ALERT_COOLDOWN_SECONDS3600config.pyMinimum gap between two alerts for the same queue while it stays breached, so a multi-hour incident pages on this interval rather than on every 5-minute beat tick. A queue that outlives the cooldown alerts again.
SCAN_QUEUE_SLOT_COUNT2config.pyS7: this deployment's scan-queue Celery slot count (WORKER_REPLICAS x CELERY_CONCURRENCY on Compose, worker.scan.replicaCount x worker.scan.concurrency on Helm), used only to compute the estimated_wait_seconds field on a 429 from the team concurrency cap. The backend cannot observe the live worker count itself, so set this to match your own deployment shape; a wrong value only makes the estimate wrong, never the 429 decision itself.
SCAN_AVERAGE_DURATION_SECONDS1200config.pyS7: typical scan-slot occupancy (20 minutes), the M in the installation guide's capacity formula. Used with SCAN_QUEUE_SLOT_COUNT for the estimated_wait_seconds estimate above. Deliberately not the 3900s hard time limit, which is a worst case, not a typical duration.
WEBHOOK_CAPACITY_RETRY_ENABLEDtrueconfig.pyS7: whether a webhook-triggered scan turned away by the team concurrency cap or the disk guard is retried automatically on a bounded exponential backoff, instead of staying dropped until an operator resends the delivery from the Git host. The one toggle in the concurrency-scaling plan that defaults ON - off is the pre-S7 defect, not a preserved behaviour. Manual redelivery still works either way.
PERMISSION_CACHE_TTL_SECONDS0 (off)config.pyHow long a resolved principal may be reused before it is read again, in seconds. Off by default, so every authenticated request reads the user and their memberships. Whatever you set is the longest a revocation can go unfelt: a demoted person keeps their old grade, and a deactivated one their session, for up to this long. A portal write drops the entry in the worker that handled it, but the image runs four workers, so that is an optimisation rather than a second guarantee. Clamped to 300 seconds; anything that is not a positive whole number of seconds means off.
AUTH_SELF_REGISTRATIONtrueconfig.pyWhether anybody may create their own account at the sign-up form. On by default, because that form is how the hosted signup works. Turning AUTH_AUTO_REGISTER off alone closes nothing: somebody signs up under their work address, then signs in through the company provider, and the callback links the identity to the account they just made. An enterprise deployment turns both off. Closed answers 404, the same answer as any route that is not there.

The address comes from the standard email claim and the provider must report it verified. There is no setting to read it from another claim: email_verified vouches for email alone, so an address taken from elsewhere carries a flag that describes something different, and on several providers that other claim is editable by its holder. Providers let an administrator map their claim onto email, which is where that belongs.

Issuer, client id and secret must all be set, and the issuer must be https, before the provider reports itself configured. A deployment missing any of them shows no button rather than one that fails on click.

The portal does not validate ID token signatures, by design. The authorization code is exchanged directly with the issuer's token endpoint over TLS and the subject is read from userinfo over the same channel, which is what OpenID Connect Core §3.1.3.7 permits for a token obtained straight from the token endpoint. What the portal does check is that the discovery document belongs to the configured issuer and that every endpoint it names is on the issuer's own host.

Operational data retention

Three tables age out on their own occurrence-time clock; there is no export cursor or usage flag to wait on the way audit_logs has. A daily beat reclaims each past the age below. See Data retention for the full model, including why audit_logs itself is a read-only report rather than a delete.

KeyDefaultRead byDescription
NOTIFICATION_RETENTION_DAYS180tasks/operational_retention.pyAge past which an in-app notification (read or unread) is reclaimed.
WEBHOOK_DELIVERY_RETENTION_DAYS90tasks/operational_retention.pyAge past which an inbound GitHub/GitLab webhook-delivery record is reclaimed. Matches AUDIT_LOG_RETENTION_DAYS by default.
REPORT_DOWNLOAD_RETENTION_DAYS365tasks/operational_retention.pyAge past which a report-download history row (SBOM / NOTICE / vulnerability-report emit record) is reclaimed.
TASK_RUN_RETENTION_DAYS90services/task_run_recorder.pyAge past which a background task-run history row is reclaimed. One row per task execution, so this table grows with scheduler traffic rather than user activity.

Backups

KeyDefaultRead byDescription
BACKUP_RETENTION_DAYS7scripts/backup.shscripts/backup.sh --no-prune overrides on a per-run basis.
BACKUP_DIR<repo>/backupsscripts/backup.shWhere the backup script writes.

Disk guards

KeyDefaultRead byDescription
DISK_HARD_LIMIT_PCT95.0apps/backend/core/config.pyRed gauge + new scans blocked + admin notification. Accepted range 50100; a value outside it is clamped and a value that is not a number falls back to 95.0, both logged at WARNING.
DISK_GUARD_EXTRA_PATHS/apps/backend/core/config.pyFilesystems the disk guard checks besides the workspace one, comma-separated. The default is the container root, which is where the toolchain caches land. Needed because WORKSPACE_HOST_PATH is often a network mount, and then reading only it reports the network volume's free space while the container's own filesystem fills. Set to an empty string to check only the workspace. A path that does not resolve is skipped with a warning, not treated as full.

Traefik / TLS

KeyDefaultRead byDescription
DOMAINdocker-compose.ymlSee Required keys.
TLS_EMAILdocker-compose.ymlEmail used by Let's Encrypt's HTTP-01 challenge. Required for cert issuance.
TRAEFIK_LOG_LEVELINFOdocker-compose.ymlDEBUG is useful when chasing routing issues.

Optional integrations

KeyDefaultRead byDescription
TICKET_STATUS_REFRESH_RATE_LIMIT10/minuteconfig.pyslowapi limit for POST /vulnerability_findings/{id}/ticket-status/refresh, keyed per authenticated user. Jira credentials themselves are not an env var: a super-admin stores one login per Jira Cloud host per organization via PUT /v1/admin/organizations/{id}/ticket-credentials (#385). See Ticket status.
CLIENT_ERROR_REPORT_RATE_LIMIT30/minuteconfig.pyslowapi limit for POST /v1/client-errors, the frontend ErrorBoundary's crash-report intake (#423), keyed per IP since the endpoint is unauthenticated (a render crash can happen before there is a token to attach). Looser than the auth endpoints above: a broken render path can legitimately re-throw on every re-render for one visitor, and the cost per call is a single log line, not a bcrypt hash.
HTTP_PROXY / HTTPS_PROXY / NO_PROXY(empty)subprocess envHonored by git clone, cdxgen, and the trivy --download-db-only boot / refresh path.
SSL_CERT_FILE / SSL_CERT_DIR(empty)subprocess env, portal HTTPSPrivate certificate authority for Trivy, cosign, govulncheck and the portal's own outbound calls. Replaces the trust set for the portal, so the file must also carry the public roots. See Private certificate authorities.
NODE_EXTRA_CA_CERTS(empty)subprocess envPrivate certificate authority for cdxgen. Additive.
REQUESTS_CA_BUNDLE(empty)subprocess envPrivate certificate authority for scancode and scanoss. The portal's own HTTPS calls ignore it.
GIT_SSL_CAINFO / GIT_SSL_CAPATH(empty)subprocess envPrivate certificate authority for git clone. git reads neither SSL_CERT_FILE nor CURL_CA_BUNDLE, so cloning from an internal host needs one of these.
REGISTRY_CONFIG_HOST_PATH./secrets/registrydocker-compose onlyHost directory mounted read-only at the fixed container path /etc/trusca/registry on the scan-pipeline worker. Holds whichever of settings.xml / .npmrc / pip.conf / .netrc your private registries need. See Private registries for dependency resolution.
MVN_ARGS(empty)cdxgen's own Maven invocationExtra arguments cdxgen appends to every mvn command it runs. Set to --settings /etc/trusca/registry/settings.xml for a private Maven registry. There is no MAVEN_SETTINGS variable: Maven itself only reads ~/.m2/settings.xml or a -s/--settings flag.
NPM_CONFIG_USERCONFIG(empty)subprocess envnpm's own environment variable for an alternate .npmrc path, not a cdxgen feature.
PIP_CONFIG_FILE(empty)subprocess envpip's own environment variable for an alternate pip.conf path, not a cdxgen feature.

Bootstrap / scripts

These keys are read only by the bootstrap and demo-seed scripts. They are not consumed by the running backend, but you set them at install / demo time.

KeyDefaultRead byDescription
ADMIN_EMAILapps/backend/scripts/create_super_admin.pyEmail of the first super-admin to provision when the script is invoked. Lower-cased and stripped on read.
ADMIN_PASSWORDapps/backend/scripts/create_super_admin.pyPassword for the bootstrap super-admin. Must be ≥ 12 characters; the script aborts otherwise.
DEMO_SUPER_ADMIN_PASSWORDa fixed value published with the public demoapps/backend/scripts/seed_demo.pyPassword given to every seeded demo account. Unset means the published default, not a generated one, so pin your own on any instance others can reach. Seeding refuses to run at all unless APP_ENV is dev or demo. Must be ≥ 12 characters when set.
SUBJECT_USER_ID-apps/backend/scripts/anonymise_user.pyThe user whose personal data the anonymisation command erases. A UUID; the command aborts on anything else. See User anonymisation.
CONFIRM-apps/backend/scripts/anonymise_user.pyMust be yes for the anonymisation to run. Two super admins approving is a decision about the subject; this is the operator confirming they mean to run it now, against this id, on this deployment. The erasure cannot be undone.
MODEcountapps/backend/scripts/reencrypt_secrets.pycount reports how many stored secrets are still encrypted under an older key and changes nothing; rewrite moves them onto the newest key. count must report zero before an old key is removed from GITHUB_APP_ENCRYPTION_KEY, because rows still holding ciphertext from a removed key cannot be recovered. See Rotating the encryption key.

Validation

The backend validates the configuration on startup (apps/backend/main.py lifespan):

  • Refuses to start if SECRET_KEY is shorter than 32 chars (any non-dev APP_ENV).
  • Refuses to start if CORS_ALLOWED_ORIGINS contains * while credentials are allowed.
  • Refuses to start in APP_ENV=prod if any origin uses plain http://.
  • Refuses to start if DB_* keys are partially set (all-or-nothing on the composed DSN path).

Failures emit a structured log line and crash the process — there is no permissive fallback.

Verify it worked

After editing .env:

docker-compose -f docker-compose.yml restart backend worker beat
docker-compose -f docker-compose.yml logs --tail=50 backend | grep backend_starting

The startup log emits a single backend_starting event with the app_env field. Secrets are never logged.

See also