---
title: "Installing Quire with Docker Compose"
description: "Install Quire on your own infrastructure with Docker Compose."
image: "https://docs.quirelms.com/og.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.quirelms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Installing Quire with Docker Compose

<span id="installing-quire-with-docker-compose"></span>

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](/ops/vercel/) and [Cloudflare Workers](/ops/cloudflare/) run
the web tier only. Upgrades are in [upgrade.md](/ops/upgrade/), and backups and
the restore drill in [backup-restore.md](/ops/backup-restore/).

## What you need <!--quire: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:first-run-->

```sh
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 <!--quire: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: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 <!--quire:settings-->

Every process reads `docker/.env`. The template, `docker/.env.example`, lists
each setting with its default. The groups:

### Addresses <!--quire: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 <!--quire: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](/ops/key-rotation/) |
| `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 <!--quire: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 <!--quire: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 <!--quire: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 <!--quire: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 <!--quire: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 <!--quire: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 <!--quire: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: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 <!--quire: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 <!--quire: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:

  ```sh
  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 <!--quire: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 <!--quire: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.

Source: https://docs.quirelms.com/ops/install/index.mdx
