Getting started¶
Two tracks, matched to why you're here:
- Run / evaluate / self-host → pull the prebuilt images (below). Recommended.
- Develop / contribute → build from source with
scripts/setup.sh(further down).
Run from prebuilt images (recommended)¶
Prerequisite: Docker (Compose v2). No source checkout, no conda, no Node, no Azure tenant.
curl -O https://raw.githubusercontent.com/TheurgicDuke771/DataQ/main/docker-compose.ghcr.yml
export OPENBAO_TOKEN=$(openssl rand -hex 16) # root token for the bundled vault
export DATAQ_SIGNIN_EMAIL=you@example.com # the address allowed to sign in
docker compose -f docker-compose.ghcr.yml up
This pulls the published images from GHCR and brings up Postgres + Redis + the API +
Celery worker + the UI + a local mail catcher, runs migrations, and seeds demo data.
Open http://localhost:3000, type the address you exported, and read the 6-digit
code in the bundled inbox at http://localhost:8025. API + Swagger at
http://localhost:8000/docs.
- Sign-in works with no SMTP relay. The stack bundles its own mailbox (Mailpit, MIT), so DataQ's real mailer runs its real SMTP path and the message lands in a web inbox on your machine instead of the internet. No mailbox has to exist; nothing leaves the host.
- Multi-arch: the images are
linux/amd64+linux/arm64, so Apple Silicon runs native (not emulated). - Loopback-only: every port binds to
127.0.0.1— the stack is reachable from your own machine but never the LAN. That matters more than usual for:8025, which serves live sign-in codes to anyone who can reach it. Not for production — a real deploy uses the OpenTofu stack (deploy/terraform/azure, ADR 0024). - Pin a release instead of the moving stable tags:
DATAQ_BACKEND_TAG=vX.Y.Z DATAQ_FRONTEND_TAG=vX.Y.Z docker compose -f docker-compose.ghcr.yml up. - Reset:
docker compose -f docker-compose.ghcr.yml down -v(drops the seeded DB). - Omitting
DATAQ_SIGNIN_EMAILstops the stack and says so — there is no no-sign-in default to fall into.
Choosing an auth mode¶
DataQ ships two ways to log a human in:
| Mode | For | You must bring | Sign-in looks like |
|---|---|---|---|
otp ← the default |
A small team, or anyone evaluating locally | an SMTP relay in production — nothing locally, the compose stacks bundle a mailbox | you type your address, DataQ emails a 6-digit code, you type it back |
oidc |
An organisation that already has an IdP | an OIDC app registration (Azure AD, Okta, Keycloak, Cognito, …) | your normal SSO redirect |
otp is what you boot into. Both compose stacks used to start with no sign-in at
all, which meant anyone who could reach the port administered a tool that stores
warehouse credentials — and an eval stack's defaults are the security posture people
actually run (the same reasoning that moved the default secret store off the plaintext
one). What made otp unusable as a default was the SMTP relay it used to require; the
bundled catcher
removes that, so the only thing left to supply is who is allowed in — one variable,
DATAQ_SIGNIN_EMAIL, which scripts/setup.sh asks for. Setting it empty is the
downgrade; leaving it unset stops the stack rather than picking for you.
The mode is never inferred. Anything unrecognised or half-configured renders an "authentication not configured" banner rather than quietly falling back to something permissive — and the backend refuses to boot on a half-configured OTP block rather than coming up unable to log anybody in.
Two selectors, set them together. The frontend's DATAQ_AUTH_MODE (injected at
runtime by nginx — ADR 0028) and the backend's own mode (inferred from its AZURE_* or
AUTH_EMAIL_* settings) are separate contracts, and neither can derive the other.
Backend OTP on with the frontend on oidc shows an SSO flow against an IdP that isn't
configured; the reverse shows a code form whose endpoints 503.
otp — email one-time codes (ADR 0032)¶
The default, and the rung for teams that have email but no IdP.
Locally there is nothing to configure but your address. Both compose stacks run a
Mailpit container (MIT) as the mailbox: DataQ performs a
real SMTP submission against it — the same connect → AUTH → send path a production
relay gets — and the message appears in a web inbox at http://localhost:8025. The
mailer runs with AUTH_EMAIL_TLS_MODE=none there, which is the plaintext downgrade
none exists for and is logged loudly on every send: it is correct against a container
you started on your own machine and wrong against anything else.
In production you still bring your own relay — bundling an outbound mailer is a deliberate non-goal (direct-to-MX from an arbitrary self-hosted IP gets sign-in codes spam-foldered, and a vendor relay would leak sign-in metadata to a third party). Frontend:
DATAQ_AUTH_MODE=otp
Backend — all four mailer values plus at least one allowlist entry (there is no open registration; DataQ holds failing-row samples, which are PII):
AUTH_EMAIL_SMTP_HOST=smtp.example.com
AUTH_EMAIL_SMTP_PORT=587
AUTH_EMAIL_USERNAME=dataq@example.com
AUTH_EMAIL_FROM=dataq@example.com
AUTH_EMAIL_PASSWORD_SECRET_NAME=dataq-smtp # the VALUE lives in your secret store
AUTH_OTP_ALLOWED_DOMAINS=example.com # and/or AUTH_OTP_ALLOWED_EMAILS=...
WORKSPACE_ADMIN_EMAILS=you@example.com # bootstrap: your own address
Give this block to both the api and the worker: the api validates the request and mints the
code, but the worker is what actually sends the email (the send runs off the request path so a
slow relay cannot leak who is allow-listed).
A worker without it logs otp_send_task_failed and no code ever arrives.
First sign-in: put your own address in both the allowlist and WORKSPACE_ADMIN_EMAILS,
then sign in to your own mailbox. There is no seeded password to rotate.
The sign-in form is deliberately credential-only — no sign-up step, no name field, per ADR
0032 — so a first-time OTP user's row has no name yet. The app offers a one-time, skippable
prompt for one right after that first sign-in. Skipping it is fine — the name is
cosmetic, never an authz input — and it (or any later change) is always available from
Profile, for every auth mode, not just otp.
Before letting anyone else in, prove the mailer works: as a workspace admin, POST
/api/v1/admin/auth-email/test sends a real message to your own address and, on failure,
names the stage that broke (connect / tls / auth / send). Far better to find a bad
relay here than at a teammate's first sign-in, when the only symptom is a code that never
arrives. It exercises the api's transport block synchronously; the worker sends the real
codes with its own copy of that block, so if the pre-flight passes and codes still never arrive,
read the worker log for otp_send_task_failed.
One consequence worth knowing up front: with no IdP there is no bearer token for /mcp to
validate, so in this mode a PAT (dq_live_…) is the only credential MCP accepts. AI
clients need an API key; a session cookie will not do.
Read Security & data handling before enabling it — under OTP the mailbox is the credential, so mailbox compromise is account compromise. The session is an HttpOnly cookie with a fixed 24 h life and no refresh token; signing in again is the refresh. Codes expire in 10 minutes, are single-use, and allow 5 attempts.
The mailer defaults to SMTP + STARTTLS on 587, verified against the system trust
store. Two more transports are available via AUTH_EMAIL_TLS_MODE: implicit for a
submission relay on :465 (SMTPS), and none — a deliberate plaintext downgrade,
logged loudly on every send, for a loopback relay or a throwaway test rig only. An
internal relay signed by a private CA doesn't need the container's whole-process
trust store touched: point AUTH_EMAIL_CA_BUNDLE at its PEM and only the mailer's own
connection trusts it. There is no option to skip certificate verification — the bundle
is the answer to "my relay's cert isn't publicly trusted", not a way around checking it.
oidc — self-hosting with your own identity provider¶
The compose eval runs the frontend with DATAQ_AUTH_MODE=otp against the bundled
mailbox, which is fine for evaluation and for a team on a trusted network, but is not an
IdP. The frontend is one generic image whose auth config is injected at runtime
(nginx serves /config.js from the DATAQ_AUTH_* env), so the same image goes from eval
to real SSO with no rebuild (ADR 0028):
- As-pulled with no auth env it shows an "authentication not configured" banner. For real
SSO, run the same image with
DATAQ_AUTH_MODE=oidc+DATAQ_AUTH_AUTHORITY(e.g.https://login.microsoftonline.com/<tenant>/v2.0) +DATAQ_AUTH_CLIENT_ID(your SPA app registration) +DATAQ_AUTH_API_SCOPE(api://<api-client-id>/<scope>), and run the backend with the matchingAZURE_*settings. For a non-Azure IdP whose scope vocabulary differs, the optionalDATAQ_AUTH_SCOPEreplaces the requested scope string entirely — AWS Cognito rejects the default list'soffline_access, so a Cognito deployment setsDATAQ_AUTH_SCOPE="openid email profile"(with the backend'sOIDC_ISSUER+OIDC_AUDIENCEinstead ofAZURE_*). - The frontend reverse-proxies
/api+/mcpto the backend atDATAQ_API_UPSTREAM, resolving it via the DNS server it detects from the container's/etc/resolv.confat startup — so the one image works on Docker's embedded DNS (Compose) and cluster DNS (Kubernetes / Container Apps) without a rebuild. - MCP (
/mcp) is fail-closed: it needs a working sign-in configuration. Underoidcit validates the bearer token; under the eval stack'sotpit is served with a PAT (dq_live_…) as its only credential (there is no IdP to issue a token, and a session cookie is deliberately rejected) — see API keys.
Develop from source¶
Prerequisites: conda (the backend uses a conda env — not venv/poetry), Docker + Docker Compose, and Node 24+ / pnpm 9+ for the frontend.
git clone https://github.com/TheurgicDuke771/DataQ.git
cd DataQ
./scripts/setup.sh # creates the `dataq` conda env, installs pre-commit, pulls
# images, runs DB migrations, seeds dev data, writes a local .env
conda activate dataq
docker-compose up # Postgres + Redis + FastAPI (:8000) + React (:3000) + Celery
# + Mailpit (:8025), the local inbox for sign-in codes
setup.sh asks which address may sign in and writes the answer to your gitignored
.env as DATAQ_SIGNIN_EMAIL. That address is allow-listed and made a workspace
admin, and you sign in by reading the code at http://localhost:8025 — no cloud account
or IdP, no SMTP relay. You can change the address later by editing that one variable and
re-running docker compose up.
Which file wins. The compose stack sets the whole AUTH_EMAIL_* block in its own
environment:, which beats env_file: unconditionally — so for the api/worker
containers .env.app is ignored for these keys in every state, including when the
switch is empty. Putting a real relay in .env.app and clearing the switch does not
select it; the stack refuses to start until you choose a mode. To point the compose stack at a real relay,
keep DATAQ_SIGNIN_EMAIL set and override the same key names in the root .env:
DATAQ_SIGNIN_EMAIL=you@example.com
AUTH_EMAIL_SMTP_HOST=smtp.example.com
AUTH_EMAIL_TLS_MODE=starttls # the bundled catcher's default is `none` — plaintext
.env.app is still the file for host-side dev (uvicorn on your own machine, which
reads it directly) — point AUTH_EMAIL_SMTP_HOST at localhost if you want the catcher
there too (Mailpit publishes 127.0.0.1:1025).
Configuration¶
All runtime config is environment variables read by the backend's Settings. The
complete, commented reference is
.env.app.example —
copy it to .env.app (gitignored) and adjust. Never commit secrets; scripts/setup.sh
generates local-dev credentials on first run.
Running tests¶
The DB-backed tests need a real Postgres (gen_random_uuid()/jsonb, which SQLite
can't host); notification tests also need Redis. The suite is ~4,800 tests and growing —
exact counts below drift with every PR, so treat them as shapes, not contracts. There are
three ways to run:
# 1. One command — brings up the compose Postgres + Redis, provisions a dedicated
# `dataq_test` DB, runs the whole suite incl. the real-broker E2E (this is what
# CI runs; the script sets DATAQ_E2E=1 for you — see the note below):
scripts/test-backend.sh # → everything passes
scripts/test-backend.sh -k notifications # extra pytest args pass through
# 2. Plain pytest — incl. the VS Code / PyCharm test runner. With the compose
# services up, conftest AUTO-DETECTS the local Postgres (from .env, on dataq_test)
# so the DB tests run — no env vars, no wrapper. The one real-broker E2E test
# still skips here (see the note below):
docker compose up -d postgres redis
conda run -n dataq python -m pytest backend/tests # → all pass, 1 skipped
# 3. No services at all — the DB-backed tests skip, the pure-unit suite still runs green:
conda run -n dataq python -m pytest backend/tests # → passes with the DB tests skipped
# Frontend:
cd frontend && pnpm test
The auto-detect is safe: it only kicks in when
TEST_DATABASE_URLis unset, targets a separatedataq_testdatabase (your dev DB + seed data are untouched), and is a no-op in CI (which setsTEST_DATABASE_URLexplicitly). The one test that needs an extra, EXPLICIT opt-in is the real-infra E2E (test_probe_e2e) — it spins up a live Celery worker + broker and does real commits/TRUNCATEs, so it requiresDATAQ_E2E=1alongsideDATABASE_URL+REDIS_URL(not merely the latter two): conftest's auto-detect above meansDATABASE_URLalone is no longer a reliably deliberate signal, so a fourth, conftest-never-sets-it-for-you flag keeps this one test a conscious choice.scripts/test-backend.shand CI both set it; a barepytestdoes not.
Before pushing, run the same gate CI does: Ruff, Black --check, mypy, Bandit, pytest
(backend) and ESLint, Prettier --check, Vitest (frontend). See the
Contributing guide.
