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

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.

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 defaults are tuned for the FastAPI worker count (4 uvicorn workers × 5 connections each = 20).

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.

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.
VULN_SLA_ALERTS_ENABLEDtrueconfig.pyToggles 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_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.

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

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.
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).
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.
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. The file is a placeholder and has no effect in this release — 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.

WebSocket gateway

KeyDefaultRead byDescription
WEBSOCKET_MAX_CONNECTIONS_PER_USER3config.pyPer-user concurrent connection ceiling. The 4th connection from the same user evicts the oldest with close code 1001 (reason="newer_connection"). The cap is enforced per worker process — multi-worker deployments allow up to N × worker-count.
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.
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.

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/services/scan_service.pyRed gauge + new scans blocked + admin notification.

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
JIRA_ENABLEDfalse(none)Stub only — not consumed by any code path in this release. Reserved for the Phase B Jira integration; included in .env.example so existing deployments do not break when the feature lands.
JIRA_URL(empty)(none)Stub. See above.
JIRA_TOKEN(empty)(none)Stub. See above.
HTTP_PROXY / HTTPS_PROXY / NO_PROXY(empty)subprocess envHonored by git clone, cdxgen, and the trivy --download-db-only boot / refresh path.

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_PASSWORD(auto-generated)apps/backend/scripts/seed_demo.pyOverride for the demo seed's super-admin password. Required when APP_ENV is staging or prod; must be ≥ 12 characters when set.

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