Self-hostable availability polls: propose dates, share a link, guests vote yes / if need be / no without an account, you finalize, and everyone gets the event in their calendar.
A single static Go binary. SQLite by default, PostgreSQL when you want it. No Node, no Redis, no external services.
On a narrow screen the matrix becomes a vertical list — one row per slot, with its tally and its three buttons. A designed alternative, not a horizontally scrolled table.
- Create a poll in seconds, with or without an account — time slots (timezone-aware, DST-safe) or whole days
- Guest voting with a personal link to come back and change a vote; live tallies with the leading option highlighted
- Comments, hidden-participants mode, required-email mode
- Accounts via Google, GitHub, Microsoft, any OIDC provider, or email magic links; polls claimable from their admin link
- Spaces (organizations) with
owner/admin/memberroles, email invitations, ownership transfer, per-space retention and timezone - Finalize a date → participants receive the
.icsinvitation (METHOD:REQUEST); cancel → they receive theCANCEL; per-poll calendar feed and CSV export - Background job queue in SQLite with retries and a visible dead-letter
- English and French, automatic with a manual switch; dark mode
- Rate limiting, automatic purge of inactive polls (with a warning email), instance admin page, optional Prometheus metrics
Grab a release — a static binary for your platform from the releases page, or the container image:
docker run -p 8080:8080 -v quorum-data:/data ghcr.io/lporcheron/quorum:latest
docker compose up # or the example docker-compose.ymlFrom source, with Go ≥ 1.26:
make run # builds and starts on :8080
make dev # live-reload development loop
make test lint # what CI runs
make test-postgres # the same suite against PostgreSQL
make screenshots # regenerates the images aboveDeploying is copying one binary (or one ~19 MB container image) and mounting one directory for the SQLite file.
Releases are cut manually from the Release GitHub Action: it mints a
CalVer tag (YYYY.MM.DD.HHmmss), publishes cross-compiled binaries
with checksums, and pushes the linux/amd64 + linux/arm64 image to
GHCR. Nothing is published on push.
Everything is optional; defaults in parentheses.
| Variable | Purpose |
|---|---|
QUORUM_ADDR |
Listen address (:8080) |
QUORUM_BASE_URL |
Public URL of the instance (http://localhost:8080) |
QUORUM_DB_PATH |
SQLite database file (quorum.db; /data/quorum.db in Docker) |
QUORUM_LOG_LEVEL |
debug, info, warn, error (info) |
QUORUM_LOG_FORMAT |
json or text (json) |
| Variable | Purpose |
|---|---|
QUORUM_OAUTH_GOOGLE_CLIENT_ID / _CLIENT_SECRET |
Google sign-in |
QUORUM_OAUTH_GITHUB_CLIENT_ID / _CLIENT_SECRET |
GitHub sign-in |
QUORUM_OAUTH_MICROSOFT_CLIENT_ID / _CLIENT_SECRET |
Microsoft sign-in |
QUORUM_OAUTH_MICROSOFT_TENANT |
Entra tenant (common) |
QUORUM_OIDC_ISSUER_URL |
Generic OIDC discovery URL |
QUORUM_OAUTH_OIDC_CLIENT_ID / _CLIENT_SECRET |
Generic OIDC client |
QUORUM_OIDC_NAME |
Label on the OIDC login button (SSO) |
OAuth callback URLs are <base URL>/auth/<google|github|microsoft|oidc>/callback.
| Variable | Purpose |
|---|---|
QUORUM_SMTP_HOST |
SMTP server; setting it enables email |
QUORUM_SMTP_PORT |
SMTP port (587) |
QUORUM_SMTP_USERNAME / QUORUM_SMTP_PASSWORD |
SMTP credentials (optional) |
QUORUM_SMTP_FROM |
Sender address (required with SMTP) |
| Variable | Purpose |
|---|---|
QUORUM_ADMIN_EMAILS |
Comma-separated emails granted the /admin page; they may always open an account, whatever the registration gates below say |
QUORUM_REGISTRATIONS_OPEN |
Allow new accounts (true); overridable at runtime from /admin; existing users always sign in |
QUORUM_GUEST_POLLS_OPEN |
Let signed-out visitors create polls (true); overridable at runtime from /admin; when off, the landing page points them at sign-in |
QUORUM_EMAIL_ALLOWED_DOMAINS |
Comma-separated sign-up domain allowlist (empty = all) |
QUORUM_TRUST_PROXY |
Use X-Forwarded-For for rate limiting (false; enable only behind a proxy that sets it) |
QUORUM_METRICS |
Serve Prometheus metrics on /metrics (false) |
Closing registrations, or setting a domain allowlist, before anyone has
signed in would otherwise lock the instance for good — the admin list
grants a page, it does not create an account. So an address in
QUORUM_ADMIN_EMAILS is always allowed to register. It is the way in on
a locked-down instance, and the way back to /admin to reopen it.
SQLite (the default) needs nothing. For bigger instances, point
DATABASE_URL at PostgreSQL (≥ 13) and the same binary uses it — one
schema, one query set, migrations applied at startup either way.
| Variable | Purpose |
|---|---|
DATABASE_URL |
postgres://user:pass@host/db switches the store to PostgreSQL (empty = SQLite at QUORUM_DB_PATH) |
PostgreSQL buys operational comfort — your existing backups, monitoring and connection tooling — not horizontal scaling. Run one instance. Three pieces of state live in the process, not the database: the job queue picks up due work without claiming it, so a second instance would send every email twice; hot settings are cached until that process writes them, so a change made on one instance would never reach the other; and the rate limiter counts per process. A single binary comfortably serves the load this tool is built for.
SQLite (the default): the database runs in WAL mode, so never copy
quorum.db alone while the server runs — you would miss the WAL. Use
one of:
sqlite3 /data/quorum.db ".backup '/backups/quorum.db'" # safe online snapshotor Litestream for continuous replication to object storage. Restoring is copying the file back and starting the server.
PostgreSQL: standard tooling — pg_dump/pg_restore or your
provider's snapshots. Quorum keeps no state outside the database.
quorum import-rallly moves an existing self-hosted
Rallly instance into Quorum from a plain-format
dump of its PostgreSQL database. It is a subcommand of the same binary,
so it ships inside the container image — nothing extra to install.
If Rallly runs in Docker Compose, dump from its database service
(-T keeps Docker from mangling the output; adjust the service name,
user and database to your setup):
docker compose exec -T rallly_db pg_dump --format=plain -U postgres rallly > rallly.sqlOtherwise, straight from PostgreSQL:
pg_dump --format=plain rallly > rallly.sqlWithout -db or -database-url, the importer targets the database this
instance is configured to serve (QUORUM_DB_PATH, or DATABASE_URL) —
so in a container it needs no flag beyond -dump. It prints the target
it opened before doing anything.
Stop the Quorum container first if it is already running: the import wants the database to itself.
Docker Compose — mount the dump into a one-off container:
docker compose stop quorum
docker compose run --rm -v "$PWD/rallly.sql:/dump.sql:ro" \
quorum import-rallly -dump /dump.sql -dry-run # rehearse
docker compose run --rm -v "$PWD/rallly.sql:/dump.sql:ro" \
quorum import-rallly -dump /dump.sql -links-out /data/links.txt
docker compose start quorumPlain Docker — same idea, naming the data volume yourself:
docker run --rm -v quorum-data:/data -v "$PWD/rallly.sql:/dump.sql:ro" \
ghcr.io/lporcheron/quorum:latest import-rallly -dump /dump.sql -dry-runBinary or source:
quorum import-rallly -dump rallly.sql -dry-run
quorum import-rallly -dump rallly.sql -db quorum.db
quorum import-rallly -dump rallly.sql -database-url postgres://…
go run ./cmd/quorum import-rallly -dump rallly.sql -dry-run # from sourceStart with -dry-run: it performs every insert, reports the counts and
everything it had to skip, then rolls the transaction back and writes
no data. The real import runs in a single transaction against a fresh
database (it refuses a target that already has polls unless you pass
-force) and brings over users, spaces, memberships, polls, options,
participants, votes and comments. Finalized Rallly polls come out
finalized in Quorum, with the chosen option matched.
Polls keep their Rallly IDs, and Quorum answers Rallly's
/invite/{id} URLs with a redirect — so invitation links already in
circulation keep working once Quorum serves the old domain.
If the dump comes from a Rallly version whose schema differs from the one the importer expects, it stops before touching the database and names the tables and columns it could not find. That is deliberate: a partial import is worse than none.
- Accounts are recreated by email only. Rallly stores no passwords Quorum could reuse; each user just signs in on Quorum (magic link or OAuth) with the same address and is reunited with their polls.
- Anonymous guests are not turned into accounts. Their polls are
imported as guest polls, each with a fresh admin link the importer
hands back once — on stdout, or into a file with
-links-out(written 0600, and it refuses to overwrite). Pass those links to their organizers; they cannot be recovered afterwards. In a container, write them somewhere that survives it, such as/data/links.txtin the volume. - Participant edit links cannot be migrated (Quorum stores only token hashes). Votes are all there; a voter who wants to change theirs votes again or asks the organizer.
- Hidden scores have no Quorum equivalent: a poll that hid its results until voting comes out with them visible, and the importer says so.
The dump contains personal data — delete it once the import is done.