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.
Operators tuning a deployment. Familiarity with .env files and Docker Compose's variable substitution.
Reading order
.env in the repo root is loaded by docker-compose automatically.
- 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.
- 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.
| Key | Set by | Read by | Notes |
|---|
SECRET_KEY | wizard (openssl rand -hex 32) | config.py | JWT signing key (HS256). Minimum 32 chars in non-dev. Rotating invalidates every refresh token. |
DATABASE_URL | wizard | config.py, docker-compose.yml | postgresql+asyncpg://user:pass@postgres:5432/trustedoss. Must use the postgres host (compose service name). |
CORS_ALLOWED_ORIGINS | wizard | config.py | Comma-separated. Production must enumerate origins explicitly — * is rejected at boot when allow_credentials=true. |
DOMAIN | wizard | docker-compose.yml | Hostname used by Traefik's host-rule. Stripped of scheme and path. |
Application
| Key | Default | Read by | Description |
|---|
APP_ENV | dev | config.py | dev, staging, or prod. Drives a few CORS / log defaults. |
LOG_LEVEL | INFO | config.py | DEBUG, INFO, WARNING, ERROR. |
DEMO_READ_ONLY | false | config.py | When 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_TAG | 0.11.0 | docker-compose.yml | Pinned 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.
| Key | Default | Read by | Description |
|---|
DATABASE_URL | — | config.py, docker-compose.yml | See above. |
DB_USER | — | config.py | Composed-DSN: username. URL-encoded in the resulting DSN. |
DB_PASSWORD | — | config.py | Composed-DSN: password. URL-encoded so @, :, /, #, % survive parsing. |
DB_HOST | — | config.py | Composed-DSN: host. May be a Cloud SQL Auth Proxy unix socket path (/cloudsql/...). |
DB_PORT | 5432 | config.py | Composed-DSN: port. |
DB_NAME | — | config.py | Composed-DSN: database name. |
POSTGRES_USER | trustedoss | docker-compose.yml | Used by the postgres container's init. Must match DATABASE_URL. |
POSTGRES_PASSWORD | — | docker-compose.yml | Generated by the wizard. |
POSTGRES_DB | trustedoss | docker-compose.yml | Database 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
| Key | Default | Read by | Description |
|---|
REDIS_URL | redis://redis:6379/0 | config.py | Broker + result backend. |
CELERY_CONCURRENCY | 2 | docker-compose.yml | Worker process count. Each slot needs ~2 GB RAM at peak. |
Authentication
| Key | Default | Read by | Description |
|---|
SECRET_KEY | — | config.py | See Required keys. HS256 signing. |
ACCESS_TOKEN_EXPIRE_MINUTES | 30 | config.py | JWT access token lifetime. |
REFRESH_TOKEN_EXPIRE_DAYS | 7 | config.py | Refresh 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.
| Key | Default | Read by | Description |
|---|
TRIVY_DB_REPOSITORY | ghcr.io/aquasecurity/trivy-db | config.py | OCI repository the Trivy DB is pulled from. Override for an air-gapped internal mirror — see Air-gapped operation. |
TRIVY_DB_REFRESH_HOURS | 168 (weekly) | config.py | Celery Beat schedule for the trivy_db_refresh task. Lower for fresher feeds, higher to reduce egress. |
TRIVY_CACHE_DIR | /var/lib/trivy | integrations/trivy.py | Directory 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_SECONDS | 300 | config.py | Per-scan timeout for trivy sbom. Raise to 600–900 for very large monorepos. |
Build / policy 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.
| Key | Default | Read by | Description |
|---|
GATE_EPSS_THRESHOLD | (unset) | config.py | Optional 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
| Key | Default | Read by | Description |
|---|
TRUSTEDOSS_SCAN_BACKEND | real | config.py | real (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_SECONDS | 600 | config.py | Hard wall-clock limit for the scancode first-party license stage. On timeout the scan continues with declared licenses only (best-effort). |
SCANCODE_MAX_FILES | 20000 | config.py | Ceiling on eligible first-party files (after the exclude filter). Over this, scancode is skipped and the scan keeps declared licenses only. |
SCANCODE_MAX_DETECTIONS | 5000 | config.py | Cap on the number of detected-license findings persisted per scan. |
SCANCODE_MAX_RESULT_BYTES | 268435456 (256 MB) | config.py | Ceiling on the scancode JSON artefact before parsing — guards against an OOM from a hostile tree. |
WORKSPACE_HOST_PATH | /tmp/trustedoss | config.py, docker-compose.yml | Host 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.kts | docker-compose.yml | Legacy 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_BYTES | 262144 (256 KB) | config.py | Per-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.
| Key | Default | Read by | Description |
|---|
SCAN_RETENTION_SUPERSEDED_GRACE_DAYS | 7 | config.py | Days 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_LAST | 30 | config.py | Minimum 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_DAYS | 180 | config.py | Hard 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
| Key | Default | Read by | Description |
|---|
WEBSOCKET_MAX_CONNECTIONS_PER_USER | 3 | config.py | Per-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_SECONDS | 1.0 | config.py | How long the gateway waits for the first {"type":"auth"} frame. Connections that miss the window are closed with 1008 / reason="auth_timeout". |
Notifications
| Key | Default | Read by | Description |
|---|
SMTP_HOST | (empty) | config.py | SMTP server. Without it, email notifications raise NotificationDisabled and the channel is skipped. |
SMTP_PORT | 587 | config.py | SMTP port. STARTTLS expected on 587. |
SMTP_USER | (empty) | config.py | SMTP username. |
SMTP_PASSWORD | (empty) | config.py | SMTP password. |
SMTP_USE_STARTTLS | true | config.py | Set false only for SMTP servers that demand implicit TLS on 465 or are testing on 25. |
SMTP_FROM | no-reply@trustedoss.local | config.py | From: header for outgoing notifications. Override per environment. |
SMTP_TIMEOUT_SECONDS | 10 | config.py | Per-call SMTP socket timeout. |
SLACK_WEBHOOK_URL | (empty) | config.py | Org-wide Slack webhook for super_admin notifications. Per-team webhooks are configured in the UI. |
TEAMS_WEBHOOK_URL | (empty) | config.py | Org-wide MS Teams webhook. |
NOTIFICATION_HTTP_TIMEOUT_SECONDS | 10 | config.py | Outbound HTTP timeout for Slack / Teams webhooks. |
Password reset
| Key | Default | Read by | Description |
|---|
PASSWORD_RESET_BASE_URL | http://localhost:5173 | config.py | Frontend base URL embedded in reset emails. The link template is {base}/reset-password?token={token}. |
PASSWORD_RESET_RATE_LIMIT | 5/minute | config.py | Per-IP slowapi limit for POST /auth/forgot-password. |
PASSWORD_RESET_EMAIL_COOLDOWN_SECONDS | 300 | config.py | Minimum 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).
| Key | Default | Read by | Description |
|---|
GITHUB_CLIENT_ID | (empty) | config.py | GitHub OAuth App client ID. |
GITHUB_CLIENT_SECRET | (empty) | config.py | GitHub OAuth App client secret. |
GOOGLE_CLIENT_ID | (empty) | config.py | Google OAuth client ID. |
GOOGLE_CLIENT_SECRET | (empty) | config.py | Google OAuth client secret. |
OAUTH_STATE_TTL_SECONDS | 300 | config.py | Lifetime of the signed state JWT (CSRF guard). RFC 6749 §10.12. |
OAUTH_HTTP_TIMEOUT_SECONDS | 10 | config.py | Outbound HTTP timeout to OAuth provider APIs. |
OAUTH_LOGIN_REDIRECT_DEFAULT | http://localhost:5173/ | config.py | Where the SPA lands after a successful OAuth callback. |
OAUTH_LOGIN_REDIRECT_FAILURE | http://localhost:5173/login | config.py | Where the SPA lands when the callback fails. Receives ?error=oauth_failed. |
Backups
| Key | Default | Read by | Description |
|---|
BACKUP_RETENTION_DAYS | 7 | scripts/backup.sh | scripts/backup.sh --no-prune overrides on a per-run basis. |
BACKUP_DIR | <repo>/backups | scripts/backup.sh | Where the backup script writes. |
Disk guards
| Key | Default | Read by | Description |
|---|
DISK_HARD_LIMIT_PCT | 95.0 | apps/backend/services/scan_service.py | Red gauge + new scans blocked + admin notification. |
Traefik / TLS
| Key | Default | Read by | Description |
|---|
DOMAIN | — | docker-compose.yml | See Required keys. |
TLS_EMAIL | — | docker-compose.yml | Email used by Let's Encrypt's HTTP-01 challenge. Required for cert issuance. |
TRAEFIK_LOG_LEVEL | INFO | docker-compose.yml | DEBUG is useful when chasing routing issues. |
Optional integrations
| Key | Default | Read by | Description |
|---|
JIRA_ENABLED | false | (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 env | Honored 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.
| Key | Default | Read by | Description |
|---|
ADMIN_EMAIL | — | apps/backend/scripts/create_super_admin.py | Email of the first super-admin to provision when the script is invoked. Lower-cased and stripped on read. |
ADMIN_PASSWORD | — | apps/backend/scripts/create_super_admin.py | Password for the bootstrap super-admin. Must be ≥ 12 characters; the script aborts otherwise. |
DEMO_SUPER_ADMIN_PASSWORD | (auto-generated) | apps/backend/scripts/seed_demo.py | Override 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