Install the server
The server is one container, qualor/server. It holds the API, the web UI, the analysis worker and
its own PostgreSQL 18, which it starts itself and keeps on a volume. It needs no Elasticsearch, Redis
or message broker. You need two files, compose.yml and .env, and nothing else from the Qualor
repository. For a larger installation, a managed database or several server replicas, point it at
an external PostgreSQL instead.
Requirements
| Minimum | Notes | |
|---|---|---|
| CPU | 1 vCPU | 2+ for many parallel pipelines |
| Memory | 1.5 GiB for the server with its database | reports near the 500 MiB decompressed ceiling need about 4 GiB |
| Disk | a few GiB for the data volume | reports are kept 7 days; analyses and measures are small |
| Software | Docker with Compose v2 | or any container platform that runs the image with a volume |
| Network | inbound HTTPS from CI runners and users | outbound only to your GitLab/GitHub and webhook receivers |
Images
All images are on Docker Hub, for linux/amd64:
| Image | What it is | Size |
|---|---|---|
qualor/server |
API, web UI and worker in one Node process, plus PostgreSQL 18; distroless, user 65532 | ~340 MB |
qualor/scanner |
the qualor CLI (its entrypoint), Node.js, a JRE 17, git and the pinned analyzers, with Trivy’s database |
~3.0 GB |
qualor/scanner-dotnet |
qualor/scanner plus the .NET 8 and .NET 10 SDKs and Roslynator, for C# |
~4.8 GB |
Tags. Every release is tagged with its full version (1.2.3), and also with its minor (1.2) and
major (1) version, which move to the newest matching release. The examples use 1. Pin the full
version where you want every pipeline to run exactly the same analyzers. Keep the server and the
scanner on the same release. Never use latest.
The source of the copyleft components in each image is published next to it, as qualor/server-sources
and qualor/scanner-sources with the same tag.
Air-gapped or rate-limited networks. Copy the images into your own registry and use those names everywhere below:
for image in server scanner scanner-dotnet; do
docker pull qualor/$image:1
docker tag qualor/$image:1 mirror.acme.internal/qualor/$image:1
docker push mirror.acme.internal/qualor/$image:1
done
Docker Compose
Create a directory for Qualor, for example /opt/qualor, and the two files in it.
The compose file
compose.yml:
name: qualor
services:
server:
image: ${QUALOR_IMAGE_PREFIX:-qualor}/server:${QUALOR_VERSION:?set QUALOR_VERSION in .env}
restart: unless-stopped
environment:
# Empty: the server runs its own PostgreSQL on the volume below.
DATABASE_URL: ${DATABASE_URL:-}
QUALOR_SECRET_KEY: ${QUALOR_SECRET_KEY:?set QUALOR_SECRET_KEY in .env}
QUALOR_BOOTSTRAP_ADMIN_USERNAME: ${QUALOR_BOOTSTRAP_ADMIN_USERNAME:-admin}
QUALOR_BOOTSTRAP_ADMIN_PASSWORD: ${QUALOR_BOOTSTRAP_ADMIN_PASSWORD:?set QUALOR_BOOTSTRAP_ADMIN_PASSWORD in .env}
QUALOR_PUBLIC_URL: ${QUALOR_PUBLIC_URL:-}
QUALOR_TRUST_PROXY: ${QUALOR_TRUST_PROXY:-}
QUALOR_SCM_INTERNAL_HOSTS: ${QUALOR_SCM_INTERNAL_HOSTS:-}
QUALOR_WORKER_CONCURRENCY: ${QUALOR_WORKER_CONCURRENCY:-1}
QUALOR_LOG_LEVEL: ${QUALOR_LOG_LEVEL:-info}
volumes:
- data:/var/lib/qualor
ports:
- '${QUALOR_BIND_ADDRESS:-127.0.0.1}:${QUALOR_PORT:-8080}:8080'
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
stop_grace_period: 60s # time for the database to shut down cleanly
healthcheck:
test:
- CMD
- /usr/local/bin/node
- -e
- "fetch('http://127.0.0.1:8080/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
interval: 10s
timeout: 5s
start_period: 60s
retries: 3
volumes:
data:
The settings file
Create .env with generated secrets. It must be readable only by you:
cd /opt/qualor
umask 077
cat > .env <<EOF
QUALOR_VERSION=1
QUALOR_SECRET_KEY=$(openssl rand -hex 32)
QUALOR_BOOTSTRAP_ADMIN_PASSWORD=$(openssl rand -hex 16)
# The address users and CI open Qualor at (links in MR/PR comments, the GitHub webhook URL):
QUALOR_PUBLIC_URL=https://qualor.example.com
# A TLS reverse proxy on this host (see below):
QUALOR_TRUST_PROXY=1
# Self-managed GitLab / GitHub Enterprise on an internal network, if any:
# QUALOR_SCM_INTERNAL_HOSTS=gitlab.corp.example.com
EOF
grep BOOTSTRAP .env # the first admin password; you change it at the first sign-in
Start
docker compose up -d
docker compose ps # wait until "server" is healthy (15–30 s)
- Secrets.
QUALOR_SECRET_KEY(at least 32 characters) andQUALOR_BOOTSTRAP_ADMIN_PASSWORD(at least 12) have no defaults, and compose refuses to start without them. - The database. Without
DATABASE_URL, the server starts the PostgreSQL 18 its image carries. The database lives on the volume at/var/lib/qualor, listens on a Unix socket inside the container only, and stops cleanly with the server. One server per volume: a second container on the same volume refuses to start. - First sign-in. The first start creates the
defaultorganisation and the instance adminadmin(QUALOR_BOOTSTRAP_ADMIN_USERNAME) with the bootstrap password. Open the server, sign in, and change the password with Change password at the top right. After a user exists, the bootstrap password is no longer used. - Network. The server publishes
127.0.0.1:8080only (QUALOR_BIND_ADDRESS,QUALOR_PORT). - Migrations run at every start, before the server listens, under a lock.
/readyzanswers 503 until they are applied. - Hardening. The container runs as non-root with a read-only root filesystem, no capabilities
and
no-new-privileges. Health checks only report status. Watchdocker compose psor/readyzfrom your monitoring. - Your own registry. Set
QUALOR_IMAGE_PREFIX=mirror.acme.internal/qualorin.env.
Other server settings from the table below go into the environment of the server service, for
example QUALOR_UPLOAD_MAX_COMPRESSED_BYTES: '104857600'.
External PostgreSQL
Use your own PostgreSQL 16 or later (18 is what Qualor is tested with) for a managed database (RDS,
Cloud SQL, Azure Database), your DBA’s backups and monitoring, or more than one server replica. Set
DATABASE_URL, and the server starts no database of its own:
# in .env
DATABASE_URL=postgres://qualor:<password>@db.example.com:5432/qualor?sslmode=require
The user needs to own the database, or be allowed to create the extensions citext and pg_trgm
in it. The server’s volume then stays empty.
To run PostgreSQL next to the server with Compose instead, add it as a second service:
services:
server:
# ... as above, plus:
environment:
DATABASE_URL: postgres://qualor:${POSTGRES_PASSWORD}@postgres:5432/qualor
depends_on:
postgres: { condition: service_healthy }
postgres:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: qualor
POSTGRES_DB: qualor
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql # PostgreSQL 18 images keep their data below this path
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U qualor -d qualor']
interval: 5s
timeout: 5s
retries: 20
volumes:
pgdata:
Add POSTGRES_PASSWORD=$(openssl rand -hex 32) to .env. Use a hex value, because the password goes
into a postgres:// URL.
Server settings
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
none | an external PostgreSQL 16+. Unset: the server runs its own |
QUALOR_DATA_DIR |
/var/lib/qualor |
the volume of the server’s own PostgreSQL (unused with DATABASE_URL) |
QUALOR_SECRET_KEY |
required | at least 32 characters. It keys the CSRF tokens and encrypts stored secrets (SCM tokens, GitHub App keys, webhook secrets) |
QUALOR_BOOTSTRAP_ADMIN_USERNAME / _PASSWORD |
admin / none |
the first instance admin, used on the first start only |
QUALOR_PUBLIC_URL |
none | the address users open Qualor at. It makes the links in MR/PR comments, commit statuses and check runs, and the GitHub webhook URL. Set it once Qualor has a real address |
QUALOR_SCM_INTERNAL_HOSTS |
none | the GitLab/GitHub Enterprise hosts on your internal network that Qualor may call, comma-separated, each with an optional port (gitlab.corp:8443). Without an entry, Qualor calls only hosts that resolve to public addresses |
QUALOR_TRUST_PROXY |
off | the reverse proxy in front: a hop count (1) or its IPs/CIDRs. true is rejected |
QUALOR_LOG_LEVEL |
info |
error, warn, info or debug |
QUALOR_SESSION_TTL_HOURS |
168 |
browser session lifetime |
QUALOR_UPLOAD_MAX_COMPRESSED_BYTES |
50 MiB | largest report upload, as sent |
QUALOR_UPLOAD_MAX_DECOMPRESSED_BYTES |
500 MiB | largest report once inflated (500 MiB is also the maximum) |
QUALOR_MAX_CONCURRENT_UPLOADS |
4 |
uploads read at once. More get 503 with Retry-After |
QUALOR_REQUEST_TIMEOUT_MS |
300000 |
longest a request may run |
QUALOR_WORKER_CONCURRENCY |
1 |
analysis jobs processed at once. Each slot can use several times one report’s size in memory |
NODE_EXTRA_CA_CERTS |
none | a PEM bundle, when your GitLab, GitHub Enterprise or webhook receivers use a private CA. Mount the file into the container |
Reverse proxy and TLS
Qualor does not terminate TLS. Put a reverse proxy on the same host in front of 127.0.0.1:8080, and
set QUALOR_TRUST_PROXY=1 in .env. Never publish the server on 0.0.0.0 without TLS, or tokens and
passwords cross the network in clear text.
Caddy obtains the certificate itself:
qualor.example.com {
reverse_proxy 127.0.0.1:8080
}
With nginx, put this inside a server block that has your certificate:
client_max_body_size 50m; # = QUALOR_UPLOAD_MAX_COMPRESSED_BYTES
proxy_request_buffering off; # the CLI waits for 100 Continue before it sends a report
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
A cloud load balancer works too. Give it the same body size limit and a 300 s timeout, and set
QUALOR_TRUST_PROXY to its hop count or address range.
If CI runners reach Qualor through a certificate from a private CA, give them that CA: set
QUALOR_CA_FILE (a path outside the checkout) or NODE_EXTRA_CA_CERTS in the job.
Backups
With the server’s own database, back up while it runs. From /opt/qualor:
docker compose exec -T server /opt/postgresql/bin/pg_dump -h /var/lib/qualor/run -U qualor -Fc qualor \
> /var/backups/qualor/qualor-$(date +%F).dump
A dump holds user names, password hashes, token hashes and encrypted secrets, so store it like a
secret. Back up .env as well (it holds QUALOR_SECRET_KEY), but separately from the dumps.
Without that key, the restored SCM tokens, GitHub keys and webhook secrets cannot be decrypted, and
you have to enter them again.
Restore with the server stopped. The restore command recreates the database and loads the
dump into it:
docker compose stop server
docker compose run --rm -T server restore < qualor-YYYY-MM-DD.dump
docker compose up -d
With an external PostgreSQL, use its own tools: pg_dump -Fc, and
pg_restore into an empty database while the server is stopped (never over the live one).
Upgrades
-
Read the release notes.
-
Back up the database (above).
-
Set the new version in
.env(QUALOR_VERSION=1.3.0; with the major tag1, skip this step), then:docker compose pull server docker compose up -dMigrations run at start, and
/readyzturns 200 once they have. -
Move the scanner in your CI to the same release.
Migrations only go forward. To roll back, set QUALOR_VERSION back to the previous version, and
restore the backup you took before the upgrade, as above.
The server’s own PostgreSQL moves between minor versions (18.x) with the image and needs nothing. A future move to a new major version will be described in that release’s notes (back up with the old release, restore with the new one).
Changing QUALOR_SECRET_KEY signs everyone out and makes stored secrets unreadable until they are
set again.
Health and monitoring
| Endpoint | Meaning |
|---|---|
GET /healthz |
the process is alive (no database access) |
GET /readyz |
the database is reachable and migrations are applied |
GET /api/v0/system/info (signed in) |
version, edition, features and limits |
Retention
A daily housekeeping job deletes old data:
| Data | Kept |
|---|---|
| uploaded reports | 7 days after processing |
| closed issues | 30 days |
| branches and MRs that have no analysis | 30 days |
| webhook deliveries | 30 days |
| analyses and measures | forever |
Trivy’s vulnerability database
Each scanner image carries a snapshot of Trivy’s vulnerability database from the day it was built,
and the scan never downloads one. Reports carry the database date, and a scan warns with
VULNERABILITY_DB_STALE once it is more than 14 days old. Keep the scanner on a current release
(the major tag 1 does that), or fetch a fresh database in the job and point QUALOR_TRIVY_CACHE_DIR
at it; see Languages and analyzers.
Building the images from source is described in deploy/README.md, for
contributors and for anyone who wants to.