Skip to content

Installing Quire with Docker Compose

Install Quire on your own infrastructure with Docker Compose.

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.me and *.localhost resolve to 127.0.0.1, which is what docker/.env.example uses. The stack’s own proxy service 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_PORT and QUIRE_PROXY_HTTPS_PORT move 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 init

docker/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:

  1. postgres becomes healthy. On the very first start its init script (docker/postgres/init/90-passwords.sh) sets the four role passwords.
  2. migrate applies 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.
  3. init (apps/web/src/first-run.ts) records the application database under QUIRE_DATABASE_ID and, when QUIRE_SETUP_ADMIN_EMAIL is set, creates the first organisation and its administrator. The sign-in address and a generated password are printed once, in docker compose logs init.
  4. web, content, worker, scheduler, collab and centrifugo start.
  5. proxy starts once web and content are 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.

Email

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, for localhost, *.localhost and lvh.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.crt

    Add quire-local-ca.crt to the system or browser trust store. curl takes 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

  • init exits with “QUIRE_DATABASE_ID is not a UUID”: set it with uuidgen.
  • web restarts with “did not start on compose”: the log lists each setting it cannot honour and what to use instead.
  • Changing a role password in .env after the first start does nothing: the init script runs once. Use ALTER ROLE.
  • Uploads fail with a scan error while CLAMAV_URL is set: ClamAV downloads its signatures on first start, which takes a few minutes.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close