Install with Docker Compose
This is the supported install path for self-hosted deployments. The scripts/install.sh wizard pulls images, generates secrets, and creates the first super_admin user — typically in under 10 minutes on a warm Docker cache. Alembic migrations are applied automatically by the backend container on start (AUTO_MIGRATE, default true), so neither path below needs a manual alembic upgrade head.
Operators with sudo on a Linux host. Familiarity with docker-compose and basic shell. Not for end users — point them at the URL once the install completes.
Prerequisites
- Linux host (tested on Ubuntu 22.04 LTS, Debian 12, RHEL 9). macOS works for development but is not a supported production target.
- Docker Compose.
docker-compose(V1, hyphenated) is the project standard; theinstall.shwizard prefers it but falls back to thedocker compose(V2) plugin when V1 is absent — so a stock modern host works. See the V1/V2 note. openssl— used to generate the SECRET_KEY and database password.curl— used by the post-install health probe (and by the no-clone quick install above).- Outbound HTTPS to GitHub Container Registry (
ghcr.io, where the portal images and the Trivy DB are published). For air-gapped operation, mirror the Trivy DB to an internal OCI registry — see Vulnerability data — Air-gapped operation. - Disk: ≥ 20 GB free for images, the workspace mount, and at least seven days of backups.
- CPU/RAM: 4 vCPU / 8 GB RAM minimum. Real source scans (cdxgen + scancode) peak at ~6 GB on the worker — give it headroom.
Verify your environment:
docker-compose --version # prints Compose 1.x (preferred)
# …or, if you only have the V2 plugin, the wizard falls back to:
docker compose version # prints Compose v2.x
openssl version
curl --version
df -h / # at least 20 GB free
Evaluation install (dev stack)
Want to try TRUSCA before committing a production host? The
dev stack (docker-compose.dev.yml) stands the portal up from a clone and
lets you seed a realistic demo dataset, so you go from clone to a populated
dashboard in a few commands.
A laptop, a throwaway cloud VM, or any 2 vCPU / 4 GB RAM host. For a real deployment use the install wizard instead — the dev stack trades production hardening (TLS, role separation, the full 6 GB scan worker) for a low-friction first look. Do not expose it to the public internet.
Requirements
- 2 vCPU / 4 GB RAM (vs. 4 vCPU / 8 GB recommended for the full stack).
docker-compose(V1) andgit.- A clone of the repository.
Bring up the stack
git clone https://github.com/trustedoss/trusca.git
cd trusca
cp .env.example .env
The dev image runs uvicorn --reload directly, so — unlike the production
image — it does not auto-apply migrations on boot. Create the schema first so
the backend reports healthy as soon as it starts (otherwise the health-gated
celery-worker blocks up):
docker-compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
Then bring the full stack up:
docker-compose -f docker-compose.dev.yml up -d
The schema is already applied, so postgres, redis, backend,
celery-worker, celery-beat, and frontend report healthy within about
30 seconds (docker-compose -f docker-compose.dev.yml ps).
Seed the demo dataset
docker-compose -f docker-compose.dev.yml exec backend \
python -m scripts.seed_demo
The seed (apps/backend/scripts/seed_demo.py, idempotent) creates 1 org,
3 teams, 5 users, 5 projects, plus a realistic mix of CVEs, license findings,
obligations, and in-app notifications — about 10 seconds.
Open http://localhost:5173/ and sign in:
| Account | Password | |
|---|---|---|
| Super admin | admin@demo.trustedoss.dev | DemoTest2026! |
| Team admin | frontend-admin@demo.trustedoss.dev | DemoTest2026! |
| Developer | dev@demo.trustedoss.dev | DemoTest2026! |
The demo password is set in .env.example and is intentionally weak — never
reuse it on a host that anyone else can reach.
How vulnerabilities show up
The seeded demo dataset ships findings directly; the worker also downloads the Trivy DB on first boot if it has internet egress. The host does not need any external vulnerability engine — Trivy and its DB live entirely inside the worker container.
For air-gapped evaluation (no ghcr.io egress), see Vulnerability data — Air-gapped operation.
Real source scans (cdxgen + scancode) peak at ~6 GB on the worker. The dev stack is sized for browsing the seeded dataset, not for production scanning. It can run a small scan but will struggle on a large repository. The dev stack also skips TLS and the L1 DB role separation — do not expose it to the public internet. Use the install wizard for anything beyond a first look.
Tear down when you are done:
docker-compose -f docker-compose.dev.yml down
# add -v to also delete the Postgres / workspace volumes (wipes the demo data)
Prerequisites for HTTPS deployments
Before running the wizard, make sure your host meets these three conditions. The wizard does not validate them and Traefik will fail silently if any is missing.
- DNS: an
Arecord (orCNAME) on the domain you plan to use (e.g.oss.acme.com) must point at your host's public IP. Verify withdig +short oss.acme.com. - Firewall: ports
80and443must be reachable from the public internet. Traefik uses HTTP-01 challenge on:80to issue the Let's Encrypt certificate; once that succeeds it redirects all traffic to:443. UFW / cloud-provider firewall / security group all need both open. - TLS_EMAIL: the wizard collects this when the public URL is
https://.... Let's Encrypt sends expiry warnings and rate-limit escalation here; use a real mailbox you check.
For HTTP-only / localhost installs (development, air-gapped UAT),
none of the above applies — the wizard skips TLS_EMAIL and Traefik
does not enter the ACME flow.
Quick install (no clone)
If you just want the stack running and don't need the helper scripts, you can install directly from the published images without cloning the repository — a single-file install experience. The production images are published to GitHub Container Registry (ghcr.io/trustedoss/trusca-backend, …/trusca-backend-worker, …/trusca-frontend) and pull anonymously.
Fetch the three files the compose stack needs (the compose file, the env template, and the one-time Postgres role init script), edit .env, then start:
mkdir -p trustedoss && cd trustedoss
BASE=https://raw.githubusercontent.com/trustedoss/trusca/v0.10.0
# 1. The self-contained production compose file (no `build:` section — pulls
# images from ghcr.io) and the env template.
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL "$BASE/.env.example" -o .env
# 2. The compose file mounts one repo file into Postgres for first-boot role
# provisioning. Fetch it to the path the compose file expects.
mkdir -p scripts
curl -fsSL "$BASE/scripts/postgres-init.sh" -o scripts/postgres-init.sh
chmod +x scripts/postgres-init.sh
# 3. Edit .env — at minimum set SECRET_KEY (openssl rand -hex 32), strong
# POSTGRES_PASSWORD / POSTGRES_APP_PASSWORD, DOMAIN, TLS_EMAIL, and
# CORS_ALLOWED_ORIGINS=https://<your-domain>. Pin IMAGE_TAG to the release
# you want (defaults to 2.0.0).
$EDITOR .env
# 4. Pull and start.
docker-compose -f docker-compose.yml pull
docker-compose -f docker-compose.yml up -d
The published backend image's entrypoint applies Alembic migrations automatically on start (AUTO_MIGRATE, default true) and only then starts uvicorn — so the schema is at HEAD by the time the backend reports healthy. You do not need to run alembic upgrade head by hand. Automatic migration does not create users, so you still bootstrap the first admin once:
# Read the password into the shell WITHOUT echoing it, then pass only the
# variable NAME to `-e` so the value is inherited from the calling shell and
# never lands in argv (visible in `ps -ef`) or in your shell history.
read -rs ADMIN_PASSWORD; export ADMIN_PASSWORD # type the 12+ char password, press Enter
# Create the first super_admin (the schema is already at HEAD).
docker-compose -f docker-compose.yml exec -T \
-e ADMIN_EMAIL=you@example.com \
-e ADMIN_PASSWORD \
backend python -m scripts.create_super_admin
unset ADMIN_PASSWORD # clear it from the shell once the user exists
Avoid -e ADMIN_PASSWORD='literal': the literal is visible to any user who
runs ps -ef while the command executes and is written to your shell history.
Passing the bare name (-e ADMIN_PASSWORD) makes Docker inherit the value
from the environment instead.
The single-role .env template ships AUTO_MIGRATE=true and it just works. If you run an L1 role-separated stack (separate DATABASE_URL_OWNER for DDL and DATABASE_URL_APP for runtime), the runtime container only holds the DML-only app DSN and cannot run DDL, so automatic migration must be off.
- With the wizard (Step 2):
install.shdetects L1 (DATABASE_URL_OWNERis set and differs from the runtime DSN) and writesAUTO_MIGRATE=falseto.envautomatically, then applies migrations as the owner role itself. You do not need to set anything. - On this no-clone path: there is no wizard, so you must set
AUTO_MIGRATE=falsein.envyourself for an L1 stack and runalembic upgrade headas the owner role (overrideDATABASE_URLwithDATABASE_URL_OWNERfor that one command). If you leave ittrueon an L1 stack the backend entrypoint fails fast (exit 1, no crash-loop) with a clear DDL-permission error in the logs.
Liveness vs. readiness: how the stack waits for the schema
The backend exposes two unauthenticated health endpoints. They answer different questions, and the Compose / Kubernetes startup gates depend on the distinction.
| Endpoint | Question it answers | Touches the DB? | Used by |
|---|---|---|---|
GET /health | Is the uvicorn process up and accepting requests? (pure liveness) | No | Kubernetes livenessProbe; liveness-only consumers |
GET /health/ready | Is the Postgres schema at the Alembic HEAD revision, i.e. is it safe to serve traffic and start workers? (readiness) | Yes (a read-only SELECT on alembic_version) | Compose backend healthcheck; Kubernetes readinessProbe |
/health/ready returns 200 {"status":"ready"} only when the schema matches HEAD. Otherwise it returns 503 with an RFC 7807 application/problem+json body summarising the revision mismatch (it never leaks the DSN or credentials).
Since (Track B), the backend service's Compose healthcheck probes /health/ready, so the worker and beat services — which declare depends_on: backend (condition: service_healthy) — start only after the schema is migrated, under both toggles:
AUTO_MIGRATE=true(single-role default): the backend container runsalembic upgrade headon start and/health/readyflips to200once it finishes. Workers then start against a migrated schema. This is the normal path and needs no operator action.AUTO_MIGRATE=false(L1 role-separated stack): uvicorn answers/healthimmediately, but/health/readystays503(the container stayshealth: starting) until your externalalembic upgrade head(run as the owner role —install.sh/upgrade.shdo this) brings the schema to HEAD. This is intended: the worker and beat wait for the schema instead of starting against a not-yet-migrated database. If you forget to run the migration on an L1 stack, the backend will simply never become healthy — checkdocker-compose logs backendand run the owner-role migration.
unhealthyThe backend healthcheck uses a generous start_period (60s). A large first migration on a big database can run for a while before /health/ready turns 200; the start_period keeps Docker from marking the container unhealthy (and restarting it) before that first migrate completes.
The install.sh wizard (Steps 1–3 below) does all of this for you — secret generation, the health-wait loop, the migration, and the admin bootstrap — and it also works with the Compose V2 plugin (docker compose) if your host doesn't have V1. Use the no-clone path when you want full control over each step or are baking your own automation.
Step 1 — Clone the repository
git clone https://github.com/trustedoss/trusca.git
cd trusca
If you maintain a fork, clone the fork instead. Pin to a release tag for reproducible installs:
git checkout v0.10.0
Step 2 — Run the install wizard
bash scripts/install.sh
The wizard does the following in order:
- Verifies
docker-compose,openssl, andcurlare on PATH. - Copies
.env.exampleto.envif.envis absent (or backs up the existing one on request). - Generates a 64-hex-char
SECRET_KEYand a strong PostgreSQL password. - Prompts for the public URL the portal should be reachable at, then writes
CORS_ALLOWED_ORIGINSandDOMAINto.env. - Decides the migration policy: if it detects an L1 role-separated stack (
DATABASE_URL_OWNERis set and differs from the runtime DSN), it writesAUTO_MIGRATE=falseto.envso the runtime container does not attempt an app-role DDL run; single-role stacks keep the defaulttrue. docker-compose pull— pulls the pinned images.docker-compose up -d— starts the stack. On a single-role stack the backend container applies Alembic migrations on start (AUTO_MIGRATE=true); on L1 it does not (policy set in the previous step).- Waits for the backend
/healthendpoint to return 200 (60-second timeout). - Runs
alembic upgrade headonce as the owner role (DATABASE_URL_OWNER). On L1 this is the authoritative DDL pass (the runtime container only holds the DML-only app DSN); on a single-role stack where the entrypoint already migrated it is an idempotent re-check — already-applied revisions are skipped. - Prompts for the first super-admin email and password (12+ characters, confirmed). Automatic migration does not create users, so this step always runs.
- Prints the final URL and next-steps reminder.
What you should see at the end
Installation complete
✓ TRUSCA is running at: https://trustedoss.example.com
Login: you@example.com
Admin panel: https://trustedoss.example.com/admin
API docs: https://trustedoss.example.com/api/docs
Step 3 — Sign in and verify
- Open the URL printed by the wizard.
- Sign in with the super-admin credentials.
- Visit /admin/health — every component should be green: backend, postgres, redis, worker, beat. The worker downloads the Trivy DB on first boot (1–3 minutes); the Vulnerability data card flips to green once the download completes.
To operate the Trivy DB (refresh cadence, air-gapped mirror, troubleshooting), see Vulnerability data (Trivy DB).
Step 4 — Schedule backups
Off-host backups are not optional in production. Add a cron entry:
sudo crontab -e
# m h dom mon dow command
0 3 * * * cd /opt/trustedoss-portal && bash scripts/backup.sh >> /var/log/trustedoss-backup.log 2>&1
scripts/backup.sh writes a timestamped directory under backups/ containing postgres.sql.gz, workspace.tar.gz, and a manifest.json. Old backups are pruned after 7 days (override with BACKUP_RETENTION_DAYS in .env).
For full restore procedures see backup & restore.
End-to-end first-success checklist (30 minutes)
After bash scripts/install.sh completes:
- Open
https://<your-host>— login screen renders, browser shows a valid TLS lock (if HTTPS). - Log in with the super-admin email/password the wizard printed.
- Wait for the worker to finish the first Trivy DB download —
docker-compose -f docker-compose.yml logs --tail=100 worker | grep trivy_dbshowstrivy_db_download_completewithin 1–3 minutes of first boot. Until it lands, the Vulnerabilities tab on a new scan is empty. - Go to
/admin/teams→ New team → name itengineering. - Ask a teammate to register at
/register, then add them at/admin/users → <user> → Memberships → Add to team. - Switch to the teammate's session → create a project at
/projects → New projectwith a small public repo (test). - Trigger a scan; the right-slide progress drawer should walk
through
bootstrap → fetch → prep → cdxgen → scancode → sbom_upload → vuln_match → finalizein about 2-5 minutes. WebSocket frames at v0.10.0 still carry the historical slugsdt_upload/dt_findingsfor compatibility — the on-screen labels read the new names. - Open the project's Vulnerabilities tab — any CVEs from the test repo should be listed.
If any step fails, see /docs/installation/troubleshooting and the
Admin → Health dashboard.
Troubleshooting
Port 80 or 443 already in use
Bind for 0.0.0.0:443 failed: port is already allocated
Another process holds the port. List bound ports and free them:
sudo ss -tlnp | grep -E ':80|:443'
If you intend to keep an existing reverse proxy, edit docker-compose.yml to drop the Traefik service and route /v1, /auth, /ws, /health, /health/ready to the backend container, and / to the frontend.
Backend never becomes healthy
✗ backend did not become healthy. Run: docker-compose -f docker-compose.yml logs backend
The most common causes:
DATABASE_URLreferences a host that is not on the compose network. Ensure the host part ispostgres(the service name), notlocalhostor127.0.0.1.- The Postgres container is not yet healthy.
docker-compose psshould showpostgresasUp (healthy). If it is restarting, checkdocker-compose logs postgresfor credential mismatches with.env. - Automatic migration failed. With
AUTO_MIGRATE=true(default) the backend runsalembic upgrade headon start and exits non-zero if it fails after its retry loop, so the container never becomes healthy. Read the alembic traceback indocker-compose logs backend. On an L1 role-separated stack the runtime DSN cannot run DDL — setAUTO_MIGRATE=falseand run the migration as the owner role (the wizard does this in Step 2).
Out of disk space mid-install
The Docker layer cache for cdxgen + scancode + Trivy is around 4 GB. If /var/lib/docker runs out, the pull aborts. Free space and re-run docker-compose pull followed by docker-compose up -d.
Need to start over with a fresh .env
Delete .env (or move it aside) and re-run the wizard:
mv .env .env.backup
bash scripts/install.sh
The wizard will re-generate secrets. Existing data in PostgreSQL is preserved — secrets in .env only affect new sessions, but rotating SECRET_KEY invalidates all current refresh tokens and forces every user to sign in again. Prefer this over editing secrets by hand.
Uninstall
To stop the stack but keep data:
docker-compose -f docker-compose.yml down
To remove everything including the database and workspace:
docker-compose -f docker-compose.yml down -v
sudo rm -rf /opt/trustedoss/workspace
docker-compose down -v deletes the named volumes (postgres-data, redis-data, traefik-acme, workspace). There is no recovery without a recent backup.
Maintainer note — publishing the images (one-time org setup)
The portal images are published to GitHub Container Registry by the release.yml workflow, triggered by pushing a vX.Y.Z git tag (or via Run workflow with a tag input). For that workflow to push, the organisation must let GitHub Actions write packages — Org → Settings → Actions → Workflow permissions → Read and write permissions (or grant the repo the Write role under the package's Manage Actions access). The workflow uses the built-in GITHUB_TOKEN; no personal access token is required.
After the first push, set each package's visibility to Public (ghcr package → Package settings → Change visibility → Public) so operators can docker pull anonymously — the no-clone quick install relies on this. Each release publishes an immutable X.Y.Z tag and a movable X.Y tag; there is no latest tag (CLAUDE.md rule #9).
Why docker-compose V1, not V2?
The project's development and CI environment standardizes on Compose V1 (docker-compose) — V2 syntax differences are not exercised in our internal pipelines, and PRs that introduce docker compose (V2) into the dev/CI surface are blocked by review (see CLAUDE.md rule #10).
That constraint is internal. For end-user installs, the install.sh wizard prefers V1 but falls back to the V2 plugin (docker compose) so a stock modern host — where V1 reached end-of-life in 2023 — works out of the box. The compose files themselves use the V1 file format, which V2 also reads.