This is the full product on one host: the LMS, its background work, the
realtime and collaborative editing services, and every optional service
behind a profile. The design is docs/architecture/23-ops.md section 2.
Other targets: Vercel and Cloudflare Workers run the web tier only. Upgrades are in upgrade.md, and backups and the restore drill in backup-restore.md.
What you need
- Docker Engine 27 or later with the Compose plugin 2.30 or later.
- 4 CPU cores and 8 GB of memory for the default stack; 8 cores and 16 GB
with
--profile full(ClamAV alone holds about 1.5 GB of signatures). - A DNS name for the web tier and a second one for untrusted content. They must be different hosts: SCORM packages and uploaded HTML run on the content origin so they can never read the LMS’s cookies.
- For a local test,
lvh.meand*.localhostresolve to 127.0.0.1, which is whatdocker/.env.exampleuses. The stack’s ownproxyservice serves both over https with a local certificate authority, so nothing else is installed (see “TLS”). - Ports 80 and 443 free on the host (
QUIRE_PROXY_HTTP_PORTandQUIRE_PROXY_HTTPS_PORTmove them).
First run
QUIRE_APP_ORIGIN=https://learn.example.org \
QUIRE_CONTENT_ORIGIN=https://content.example-content.org \
QUIRE_SETUP_ADMIN_EMAIL=you@example.org \
docker/scripts/init-env.sh
docker compose -f docker/compose.yaml up -d --build
docker compose -f docker/compose.yaml logs initdocker/scripts/init-env.sh writes docker/.env from docker/.env.example
with every secret generated (database passwords, the signing and master
keys, the content launch key pair) and the audit checkpoint signing key in
docker/secrets/audit-signing-key.pem, which Compose mounts into the workers
as a secret. It needs only sh, awk and openssl, and it refuses to
overwrite an existing docker/.env. Copy both files off the host: without
QUIRE_MASTER_KEY a restored database cannot decrypt its stored credentials.
To fill the file by hand instead, cp docker/.env.example docker/.env; the
file says how to generate each secret.
Both origins must be https: the content service refuses plain http in
production, and they must not share a registrable domain. The proxy
service terminates TLS for both (see “TLS”); init-env.sh refuses an
http:// origin.
The stack starts in a fixed order, and each step waits for the one before it:
postgresbecomes healthy. On the very first start its init script (docker/postgres/init/90-passwords.sh) sets the four role passwords.migrateapplies every migration and bootstraps the job queue in the control database and in every dedicated tenant database, checks that they all agree, then exits (docs/ops/upgrade.md). Migrations run on every start and are idempotent, so an upgrade is a new image and a restart.init(apps/web/src/first-run.ts) records the application database underQUIRE_DATABASE_IDand, whenQUIRE_SETUP_ADMIN_EMAILis set, creates the first organisation and its administrator. The sign-in address and a generated password are printed once, indocker compose logs init.web,content,worker,scheduler,collabandcentrifugostart.proxystarts oncewebandcontentare healthy.
Open https://demo. followed by your application domain (the init log
prints the exact sign-in address), and sign in. On a local install, trust
the proxy’s certificate authority first (see “TLS”). Change the generated password at /account/security.
A process started without a required secret refuses to start and names the missing setting in its log. Nothing starts half-configured.
Services and profiles
| Service | Profile | What it does |
|---|---|---|
| postgres | always | The database (PostgreSQL 18 with pgvector, built from docker/postgres.Dockerfile), with WAL archived from the first boot |
| migrate, init | always | One-shot: migrations, then first run |
| web | always | The LMS, on QUIRE_HTTP_PORT (8080) |
| content | always | The untrusted-content origin, on QUIRE_CONTENT_PORT (8081) |
| worker | always | Background jobs: email, reports, file processing, webhooks |
| scheduler | always | Recurring jobs: registers the 64 runtime schedules and hands them to the worker; one leader at a time |
| collab | always | Collaborative editing websocket, on QUIRE_COLLAB_HTTP_PORT (1234) |
| centrifugo | always | Realtime fan-out, on QUIRE_REALTIME_PORT (8000) |
| proxy | always | Caddy, the TLS front door on ports 80 and 443 (see “TLS”) |
| valkey | cache |
Cache and rate limits |
| clamav | scan |
Malware scanning of uploads |
| gotenberg | preview |
Office to PDF previews, certificate rendering |
| imgproxy | images |
Resized and converted images |
| transcoder | video |
The worker image with an LGPL-only ffmpeg, for video renditions |
| seaweedfs | storage |
S3-compatible object storage on this host |
| otelcol | observability |
An OpenTelemetry collector |
| mailpit | devmail |
Catches all outgoing mail, for trying Quire out |
| backup | backup |
One-shot base backup; see backup-restore.md |
| backup-scheduler, backup-offsite | backup |
A base backup every QUIRE_BACKUP_INTERVAL_HOURS, and encrypted off-host copies with a weekly verification drill |
| h5p | h5p |
The H5P LTI 1.3 tool image you supply in QUIRE_H5P_IMAGE, on QUIRE_H5P_PORT (8090); see “Connecting an H5P provider” |
--profile full starts every optional service except backup and h5p.
Start one with docker compose -f docker/compose.yaml --profile scan up -d.
Without an optional service Quire still works and says what is missing: no
scanner means uploads are stored unscanned and the administrator is told; no
Gotenberg means files offer download instead of a preview; no transcoder means
video plays as the original file.
Every third-party image and its licence obligations are listed in
docker/third-party-containers.yaml.
Connecting an H5P provider
Quire does not embed or ship an H5P runtime or sidecar (ADR 0019). If you use H5P, provide your own hosted subscription or operate your own self-hosted H5P instance separately from Quire. Register that provider as an LTI 1.3 external tool and add its content to courses as tool activities. Quire exchanges grades and activity/grading progress through LTI Assignment and Grade Services (AGS). If the provider also sends xAPI statements, configure that separately for Quire’s xAPI statement store; AGS grade/progress exchange does not send xAPI statements. Moodle imports report H5P activities as needing an LTI tool connection. The provider remains responsible for its H5P runtime, authoring, content bank and attempt history.
To run your own self-hosted instance on this host, set QUIRE_H5P_IMAGE to its
image and start the h5p profile. Compose publishes it on QUIRE_H5P_PORT
(8090) and keeps its data in the h5p-data volume; the image, and the
obligations that come with it, stay yours.
Settings
Every process reads docker/.env. The template, docker/.env.example, lists
each setting with its default. The groups:
Addresses
| Setting | Meaning |
|---|---|
QUIRE_APP_ORIGIN |
The LMS’s public address, such as https://learn.example.com |
QUIRE_CONTENT_ORIGIN |
The content origin, a different host |
QUIRE_PLATFORM_DOMAINS |
Domains organisations live under, comma separated |
QUIRE_MARKETING_ORIGIN |
Optional. The marketing site, default https://quirelms.com. The only origin the waitlist form (POST /api/waitlist, POST /waitlist) accepts and redirects to. Comma separated; a www. variant is allowed only if listed |
QUIRE_DEPLOY_TARGET |
compose here. See the other guides for vercel and cloudflare |
QUIRE_TRUSTED_PROXY_CIDRS |
Proxies whose X-Forwarded-For is believed |
Secrets
| Setting | Meaning |
|---|---|
QUIRE_SECRET_KEY |
Signs sessions and tokens. 64 hex characters |
QUIRE_MASTER_KEY |
Wraps stored credentials such as SSO and webhook secrets. 32 bytes, base64. The web tier and the worker need the same value. Rotation: key-rotation.md |
QUIRE_MASTER_KEY_VERSION |
The master key’s version label, v1 when unset. Raise it when you rotate |
QUIRE_MASTER_KEY_RETIRED |
Earlier master keys still needed to read what they sealed, as v1=<base64>. Remove after a rotation finishes with nothing unresolved |
QUIRE_COLLAB_SIGNING_KEY |
Shared by web and collab to sign editing tokens |
QUIRE_BACKUP_SIGNING_KEY |
Signs course backups (optional) |
Keep a copy of QUIRE_MASTER_KEY somewhere other than this host. A database
restored without it cannot decrypt the credentials it holds.
Database
| Setting | Meaning |
|---|---|
POSTGRES_PASSWORD |
The superuser, used by the container and backups |
QUIRE_DB_APP_PASSWORD, QUIRE_DB_MIGRATOR_PASSWORD, QUIRE_DB_REPORT_PASSWORD, QUIRE_DB_AUDIT_PASSWORD |
Role passwords, set on first start |
DATABASE_URL |
The application role. Row-level security applies to every query it makes |
DATABASE_MIGRATOR_URL, QUIRE_MIGRATION_URL |
The migrator role, for migrate and init |
QUIRE_SUPERUSER_URL |
Used only by first run |
QUIRE_REPORT_DATABASE_URL |
The read-only report role, for reports and the report builder |
QUIRE_AUDIT_DATABASE_URL |
The audit role, for the audit console and SIEM export |
QUIRE_DATABASE_ID |
Any UUID, fixed for the life of the install |
Role passwords are applied only when the database volume is first created.
To change one later, use ALTER ROLE and then update the matching URL.
QUIRE_REPORT_DATABASE_URL is used for the physical database configured by
DATABASE_URL. For any other registered physical database, set its own
quire_report connection URL in the web and worker environments, then put
the variable name in that database’s Reporting environment variable field
as env:NAME. The reference must point to the same database as its app
connection, ideally its read replica. Every report surface follows the
tenant to its own database’s report connection: the report builder and
saved reports, scheduled deliveries, report exports, analytics, the audit
log, the REST audit resources and the assistant’s audit search. None of
them ever borrows another database’s report URL. When a database has no
report connection, ordinary reports run on that database’s own application
connection, while analytics and every audit read refuse and say so, because
the application role cannot read the audit trail.
Drivers
| Setting | This release | Notes |
|---|---|---|
QUIRE_STORAGE_DRIVER |
local (default), s3 or azure |
local keeps files in the files volume. s3 covers AWS S3, R2, GCS interoperability and other S3-compatible stores, with resumable multipart uploads |
QUIRE_REALTIME_DRIVER |
inprocess (default), sse, centrifugo or durable_objects |
inprocess is right for one web container; use centrifugo or sse when there are several |
QUIRE_CACHE_DRIVER |
memory (default), postgres or valkey |
memory is per process; use valkey or postgres so rate limits hold across containers |
QUIRE_VIDEO_DRIVER |
ffmpeg (default) or progressive_mp4 |
Or a hosted provider: Cloudflare Stream, Mux or Bunny, by their keys |
QUIRE_IMAGE_DRIVER |
noop (default), imgproxy or cloudflare |
noop serves every image at its original size. imgproxy needs the images profile and the settings below; cloudflare uses Cloudflare Images |
QUIRE_MEETING_PROVIDER |
bbb, zoom, teams, meet, jitsi or in_process |
The platform default for live sessions. Unset, live sessions say they are not configured, until an organisation connects its own account under Integrations, Live session provider. An organisation’s own account always wins over this value. Each provider’s own settings (BBB_URL and BBB_SECRET, the ZOOM_*, TEAMS_*, GOOGLE_MEET_* and JITSI_* variables) are read only for the provider named here |
QUIRE_MEETING_REGIONS |
A comma list of eu, uk, us |
Where the platform default provider processes meetings. Unset, it is not checked against an organisation pinned to a region, as before. An organisation’s own account states its regions on its page |
A driver value this release does not include is refused when the web tier starts, with the setting named, rather than silently replaced by the default.
Images
Pages ask for images at four fixed sizes through
/api/files/{id}/image/{size}, which checks the same access as the file
itself and then redirects to the image service. Each organisation may ask
for QUIRE_IMAGE_SPECS_PER_HOUR (default 2000) new image and size pairs an
hour; sizes already produced that hour do not count. Use valkey or
postgres for QUIRE_CACHE_DRIVER with more than one web container, so the
limit holds across them.
| Setting | Driver | Notes |
|---|---|---|
IMGPROXY_URL |
imgproxy |
The address browsers reach imgproxy on, for example https://images.example.org. The images profile publishes it on QUIRE_IMAGES_PORT (8082) |
IMGPROXY_KEY, IMGPROXY_SALT |
imgproxy |
Hex strings, the same values imgproxy is started with. Generate each with openssl rand -hex 32. Quire signs every image address with them, so imgproxy renders nothing Quire did not ask for |
QUIRE_IMAGE_SOURCE_ORIGIN |
imgproxy with local storage |
Where imgproxy fetches originals from. Compose sets http://web:3000. With s3 or azure storage imgproxy fetches from the bucket and this is not used |
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_IMAGES_TOKEN, CLOUDFLARE_IMAGES_ACCOUNT_HASH |
cloudflare |
An API token with Images edit permission, and the account hash from Images, Developer resources. Turn on flexible variants for the account |
CLOUDFLARE_IMAGES_SIGNING_KEY |
cloudflare |
Optional. When set, images are private and every address is signed and expires. Without it, images are public at addresses derived from QUIRE_SECRET_KEY that nobody can guess |
Cloudflare Images keeps its own copy of each original it serves. When a file is deleted, the worker deletes that copy before the original.
Queue
Background jobs use pg-boss in the same Postgres database, so there is no
queue service to run and nothing to configure. Jobs are enqueued in the same
transaction as the change that caused them, so a crash cannot lose one or
send one twice. QUIRE_QUEUE_DRIVER is pgboss here, its default; vercel
and cloudflare move only the light notification and webhook deliveries to
the platform’s own queue, and the Vercel and Cloudflare guides describe them
and how their web tiers enqueue.
Set one of:
QUIRE_EMAIL_PROVIDER_CONFIG: a JSON object naming an HTTP provider and its credentials, such as{"provider":"postmark","token":"..."}. Postmark, Amazon SES, Mailgun, SendGrid and Resend are supported.QUIRE_SMTP_URL:smtp://user:password@host:587. This target only; the serverless targets block SMTP.
QUIRE_MAIL_FROM is the sender. For trying Quire out, start the devmail
profile, set QUIRE_SMTP_URL=smtp://mailpit:1025, and read mail at
http://localhost:8025.
Optional services
| Setting | With profile |
|---|---|
CLAMAV_URL=tcp://clamav:3310 |
scan |
GOTENBERG_URL=http://gotenberg:3000 |
preview |
IMGPROXY_KEY, IMGPROXY_SALT |
images |
VALKEY_URL=redis://valkey:6379 |
cache |
QUIRE_OPENSEARCH_URL or QUIRE_MEILISEARCH_URL |
External search; Postgres full text otherwise |
QUIRE_BREACH_CHECK_PROVIDER=off, QUIRE_BREACH_CHECK_URL |
Password breach check. On by default against api.pwnedpasswords.com (only a five character hash prefix is sent); off disables it, and the URL points at a range API you host |
Observability
OTEL_EXPORTER_OTLP_ENDPOINT names the collector every process sends
traces and metrics to; with the observability profile it is
http://otelcol:4318, and docker/otel-collector.yaml is where you add the
exporter for your backend. The web tier, worker, scheduler, content and collab
processes export spans over OTLP/HTTP (web requests, tenant database
transactions, worker jobs and outbound calls) when it is set, and metrics to
the same endpoint every minute (OTEL_METRICS_EXPORTER=none turns them off).
OTEL_TRACES_SAMPLER_ARG sets the share of traces kept. Logs go to standard
output at LOG_LEVEL, and Compose rotates them. Traces never carry personal
data.
Regional egress (EU data residency)
QUIRE_REGION=eu says the stack serves European Union organisations. The
worker then holds every outbound request made for an organisation pinned to
the EU to an allowlist (21-compliance.md section 8.1). The allowlist is the
hosts that the configured services declare for the region (the storage
endpoint, the email provider, a hosted video provider, the organisation’s own
storage targets, AI providers and email account), the hosts of any service
under an active derogation, and the hosts you list in
QUIRE_EGRESS_ALLOW_HOSTS. A request to any other public host is refused
before it is sent, the refusal is written to the organisation’s audit trail as
privacy/egress_refused, and it is listed under Compliance, Data residency.
| Setting | Values | Effect |
|---|---|---|
QUIRE_EGRESS_ALLOW_HOSTS |
A comma list of hostnames, or *.example.org for every subdomain |
Extra hosts an EU organisation may reach. Webhook, xAPI and SIEM endpoints, blog feeds and Amazon SES hosts belong here, because they are an organisation’s own choice and no service declares them. Loopback, private addresses and single label names such as web or clamav are your own network and are never checked |
UK and US organisations are not held to a host list; they keep the service
region checks. Set the list on the worker; the admin page reads it on the web
tier to show the allowlist, so put it in docker/.env, which every service
reads.
The application check gives a clear error and an audit entry, and it is not
the guarantee: code can be wrong. The guarantee is the network. Compose does
not enforce it for you. For a regional stack, put the worker and web
services on an internal: true network whose only route out is an egress
proxy (for example Squid or a tinyproxy container) that allows the same hosts
as QUIRE_EGRESS_ALLOW_HOSTS plus the hosts of your configured services, and
set HTTPS_PROXY for those services. The residency page lists the exact
hosts the application allows, so the two lists can be compared.
Health
| Endpoint | Meaning |
|---|---|
/healthz |
Liveness: the process answers. Compose health checks use this |
/readyz |
Readiness: dependencies reachable, and each optional service reported as configured or not. Point your load balancer here |
docker compose -f docker/compose.yaml ps shows each service’s health.
TLS
The proxy service (Caddy, Apache-2.0, docker/caddy/Caddyfile) is part of
the default stack. It answers on ports 80 and 443 and routes:
| Host or path | Goes to |
|---|---|
QUIRE_PROXY_CONTENT_HOST |
content |
QUIRE_PROXY_APP_HOST, every tenant subdomain and custom domain |
web |
/_collab/ on those hosts |
collab (websocket, QUIRE_COLLAB_URL) |
/_realtime/connection/ on those hosts |
centrifugo’s client websocket; its server API is never exposed |
/_images/ on those hosts |
imgproxy, with the images profile (IMGPROXY_URL) |
init-env.sh derives QUIRE_PROXY_APP_HOST, QUIRE_PROXY_CONTENT_HOST,
QUIRE_PROXY_HTTPS_PORT, QUIRE_COLLAB_URL and IMGPROXY_URL from the two
origins, so they cannot drift apart. Edit them together if you change an
origin by hand.
Certificates follow QUIRE_PROXY_TLS:
-
internal(the default): Caddy’s own certificate authority, forlocalhost,*.localhostandlvh.me. Trust its root once, then browse:docker compose -f docker/compose.yaml cp \ proxy:/data/caddy/pki/authorities/local/root.crt ./quire-local-ca.crtAdd
quire-local-ca.crtto the system or browser trust store.curltakes it with--cacert. -
An e-mail address: automatic ACME certificates (Let’s Encrypt, then ZeroSSL) for real hostnames. DNS for both origins and every tenant host must point here, and ports 80 and 443 must be reachable from the internet.
Tenant hosts are issued on demand, at the first visit, and only when web
confirms the name belongs to this install (/tls-allowed, asked on the
Compose network). No wildcard certificate or DNS provider plugin is needed,
and a stranger pointing a name at the host cannot make it request
certificates. Certificates and the local authority live in the
caddy-data volume; back it up with the rest if you use internal.
Web believes X-Forwarded-For from the proxy alone: the proxy has a fixed
address (QUIRE_PROXY_ADDRESS, default 172.29.64.10) on a fixed subnet
(QUIRE_COMPOSE_SUBNET), and QUIRE_TRUSTED_PROXY_CIDRS names that address.
If the subnet collides with a network on the host, change both and run
docker compose down before up.
Behind your own reverse proxy
To use a load balancer or proxy you already run instead, leave proxy out
(docker compose up -d --scale proxy=0) and terminate TLS in front of web
(8080), content (8081), collab (1234, websocket) and centrifugo (8000,
websocket). Set the public addresses in QUIRE_APP_ORIGIN,
QUIRE_CONTENT_ORIGIN and QUIRE_COLLAB_URL (wss://), and your proxy’s
address range in QUIRE_TRUSTED_PROXY_CIDRS.
Troubleshooting
initexits with “QUIRE_DATABASE_ID is not a UUID”: set it withuuidgen.webrestarts with “did not start on compose”: the log lists each setting it cannot honour and what to use instead.- Changing a role password in
.envafter the first start does nothing: the init script runs once. UseALTER ROLE. - Uploads fail with a scan error while
CLAMAV_URLis set: ClamAV downloads its signatures on first start, which takes a few minutes.