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) and QUALOR_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 default organisation and the instance admin admin (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:8080 only (QUALOR_BIND_ADDRESS, QUALOR_PORT).
  • Migrations run at every start, before the server listens, under a lock. /readyz answers 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. Watch docker compose ps or /readyz from your monitoring.
  • Your own registry. Set QUALOR_IMAGE_PREFIX=mirror.acme.internal/qualor in .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

  1. Read the release notes.

  2. Back up the database (above).

  3. Set the new version in .env (QUALOR_VERSION=1.3.0; with the major tag 1, skip this step), then:

    docker compose pull server
    docker compose up -d

    Migrations run at start, and /readyz turns 200 once they have.

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