Repository navigation
Configuration Reference
Every environment variable YADT reads, grouped by what it's for. Source of
truth is always .env.example
in the repo — copy it to .env and fill in what you need; this page is the
readable walkthrough of the same file.
TL;DR — the minimum to get a running dashboard: PUBLIC_BASE_URL,
POSTGRES_PASSWORD, DMARC_APP_DB_PASSWORD, FERNET_KEY. That's local
email+password+TOTP login and DNS best-practice checks, with nothing
Microsoft-side to configure. Report ingestion (from either path) additionally
needs ENTRA_MAIL_CLIENT_ID/ENTRA_MAIL_CLIENT_SECRET — without it, the
dashboard runs fine but never ingests a report.
| Variable | Required | What it does |
|---|---|---|
PUBLIC_BASE_URL |
Yes | The HTTPS URL your reverse proxy exposes this at, e.g. https://dmarc.yourdomain.com. Part of the Entra SSO OAuth redirect URI — get it right before enabling SSO. |
POSTGRES_PASSWORD |
Yes | Password for the Postgres owner role, used only by the one-off migrate container. |
DMARC_APP_DB_PASSWORD |
Yes | Password for the separate, non-superuser dmarc_app role the app actually connects as at runtime — this is what makes row-level security apply at all (superusers and table owners are exempt from RLS regardless of FORCE ROW LEVEL SECURITY). |
FERNET_KEY |
Yes | Encrypts TOTP secrets (every local-auth user, including the platform admin). Platform-admin login enrolls TOTP, so this fails closed without it. Back it up: losing it makes existing TOTP enrollments undecryptable (users then sign in with a recovery code, or an admin resets their MFA). To rotate, list the new key first and the old one after it, comma-separated. Generate: openssl rand -base64 32 | tr '+/' '-_' or python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". |
POSTGRES_USER / POSTGRES_DB
|
No (defaults fine) | The Postgres owner role name and database name. |
API_BIND_ADDRESS |
Recommended | The host address the API is published on. Default 0.0.0.0 is every interface, and Docker's published ports bypass UFW. Set it to the address your reverse proxy connects to (127.0.0.1 if the proxy is on the same host). See Securing Your Install. |
WORKER_MEMORY_LIMIT |
Optional | Memory limit for the worker container (default 1g). Report mailboxes are public, so a report built to use a lot of memory restarts the worker rather than slowing the whole server. |
FORWARDED_ALLOW_IPS |
Recommended | Your reverse proxy's own IP, so real client IPs — not the proxy's — get logged in sessions and the sign-in audit log. Leave unset (trusts nothing external) if you're not behind a reverse proxy. |
| Variable | Required | What it does |
|---|---|---|
ENTRA_MAIL_CLIENT_ID / ENTRA_MAIL_CLIENT_SECRET
|
Required for report ingestion | Not optional the way the rest of this page is. Both ways of getting reports in — an organization's own connected mailbox, and the operator-hosted mailbox — authenticate to Microsoft Graph through this one app-only app registration (client-credentials flow). Leave it unset and the dashboard still runs fine, DNS best-practice checks work with zero Microsoft-side config, but no report is ever ingested by either path. |
| Variable | Required | What it does |
|---|---|---|
ENTRA_SSO_CLIENT_ID / ENTRA_SSO_CLIENT_SECRET
|
Optional | Enables "Sign in with Microsoft" (delegated OIDC) for organizations on Microsoft 365. Local email+password+TOTP login works the same either way. |
GOOGLE_SSO_CLIENT_ID / GOOGLE_SSO_CLIENT_SECRET
|
Optional (v0.3.0) | Enables "Sign in with Google" for organizations on Google Workspace. See Sign-in Providers. |
OIDC_ALLOW_PRIVATE_ISSUERS |
Optional, default false (v0.3.0) |
Lets organizations' own OpenID Connect providers be on a private address (and http://). Only when every organization on the server is trusted. |
| Variable | Required | What it does |
|---|---|---|
GOOGLE_MAIL_SERVICE_ACCOUNT_FILE / GOOGLE_MAIL_SERVICE_ACCOUNT_JSON
|
Optional (v0.3.0) | A Google Cloud service account's JSON key (a file path, or the JSON itself) for reading Google Workspace report mailboxes through domain-wide delegation. Needed in the api and the worker. See Connecting a Google Workspace Mailbox. |
| Variable | Required | What it does |
|---|---|---|
HOSTED_REPORTS_MAILBOX_ADDRESS |
Optional | For organizations with no mailbox of their own to dedicate: issues <mailbox>+<tag>@<mailbox's domain> addresses that land in this shared mailbox via plus-addressing (the provider must deliver name+anything@domain to name@domain). The addresses always use the mailbox's own domain. Leave blank to disable the feature. |
HOSTED_REPORTS_SOURCE |
Optional, default microsoft (v0.3.0) |
How the hosted mailbox is read: microsoft (a Microsoft 365 mailbox, needs HOSTED_REPORTS_TENANT_ID and the mailbox app), imap, or google (needs the Google service account below). |
HOSTED_REPORTS_TENANT_ID |
With microsoft
|
The Entra tenant the hosted Microsoft 365 mailbox is in. |
HOSTED_REPORTS_IMAP_HOST, _PORT (993), _SECURITY (ssl/starttls), _USERNAME, _PASSWORD
|
With imap (v0.3.0) |
The hosted mailbox's IMAP server. Set by you as platform admin, so a private address (your own mail server) is allowed. |
HOSTED_REPORTS_IMAP_FOLDER / HOSTED_REPORTS_IMAP_PROCESSED_FOLDER
|
Optional (INBOX / DMARC processed) |
Where reports arrive, and where they're moved once stored (created if missing). |
HOSTED_REPORTS_IMAP_AUTHSERV_ID |
With imap
|
The mail server's ID: the first word of the Authentication-Results header it adds to incoming mail. Only its checks count for the report sender check, and nothing is read until it's set; the hosted mailbox's sync error names the ID it found. |
HOSTED_REPORTS_ADDRESS_DOMAIN |
Optional, normally unset | Overrides the addresses' domain. Defaults to the mailbox's domain, which it has to match anyway. |
| Variable | Required | What it does |
|---|---|---|
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID
|
Optional | Auto-creates, and removes again, the RFC 7489 §7.1 authorization record each domain using a hosted address needs, in the Cloudflare zone of the hosted mailbox's domain. Use a scoped API Token (Zone:DNS:Edit on that one zone, nothing else), never the legacy Global API Key. Also set the token's Client IP Address Filtering to the server's outbound IP and give it an expiry date, so a leaked token is useless elsewhere and doesn't last forever. Leave blank to fall back to manual setup instructions instead. |
| Variable | Required | What it does |
|---|---|---|
PLATFORM_ADMIN_BOOTSTRAP_EMAIL / PLATFORM_ADMIN_BOOTSTRAP_PASSWORD
|
Recommended | If no platform admin exists yet, the migrate container creates one from these on startup. Change the password after first login — these vars can then be left as-is; bootstrap is a no-op once an admin already exists. |
PLATFORM_ADMIN_SESSION_IDLE_TIMEOUT_HOURS / PLATFORM_ADMIN_SESSION_ABSOLUTE_TIMEOUT_HOURS
|
Optional (default 4 / 24) | How long a break-glass admin sign-in lasts: ends after this many hours unused, and after the absolute limit however active. Normal sign-ins, including an operator organization's admins, keep 12 hours idle / 7 days. Deliberately not changeable from the admin console. |
API_DOCS_ENABLED |
Optional (default false) |
Serves the interactive API docs at /docs, /redoc and /openapi.json. Off on public instances; turn on for local development. |
PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_STACK_ID, PORTAINER_TLS_VERIFY
|
Optional, Portainer stacks only, on the updater service (from v0.2.0) |
Update now through Portainer's API; with UPDATER_SHARED_SECRET. See Deploying with Portainer. |
STARTTLS_CHECK_MODE |
Optional (default probe) |
Where the STARTTLS result comes from. probe: connect to each MX host on port 25. tls_rpt: the TLS-RPT reports senders send (last 14 days; pending until the first ones arrive; no result for domains whose TLS-RPT doesn't reach the app). off: skip it. Use tls_rpt where outbound port 25 is blocked (Azure, most cloud hosts). |
STARTTLS_CHECK_ENABLED |
Optional (default true) |
From v0.1.7. false turns the STARTTLS check off whatever STARTTLS_CHECK_MODE says. |
UPDATE_REHEARSAL_ENABLED |
Optional (default false, Azure only) |
Shows Run test update under admin console → Updates: every in-app update step on the running version, nothing changes. Turn on to check the updater after a redeploy or a permission change. |
| Variable | Required | What it does |
|---|---|---|
SECURITY_CONTACT_EMAIL |
Optional | Publishes /.well-known/security.txt with this contact for vulnerability reports. Leave blank to not serve it — there's nothing RFC-9116-correct to publish without at least one contact. |
| Variable | Required | What it does |
|---|---|---|
MTA_STS_POLICY_HOSTNAME / MTA_STS_POLICY_BODY
|
Optional | Not organization-facing — organizations publish/host their own via the Policy Builder, and this app stays read-only toward their DNS. This is only for the operator's own domain, if it sends mail and you want to publish its MTA-STS policy through this same app instance. MTA_STS_POLICY_HOSTNAME must exactly match the Host header of incoming requests. Leave both blank to not serve it at all. |
| Variable | Required | What it does |
|---|---|---|
IMAGE_TAG |
Optional | Which published tag docker compose pull/up fetches. Leave unset to track latest. Deliberately separate from APP_VERSION (baked into the image at build time) — never override APP_VERSION via env. |
UPDATER_SHARED_SECRET |
Optional | Enables the "Update now" button in the admin console — a container with Docker socket access (host-root-equivalent). Leave blank to not run it; the admin console still shows "update available" notices from the periodic check either way, just without a one-click trigger. Generate: openssl rand -hex 32. |
See Scaling Horizontally for the full picture. Quick reference:
| Variable | Default | What it does |
|---|---|---|
WORKER_CONCURRENCY |
4 |
Consumer loops per worker replica. |
WORKER_QUEUE_POLL_INTERVAL_SECONDS |
5 |
Idle queue poll interval. |
WORKER_JOB_STALE_SECONDS |
1800 |
Reclaim a job stuck running this long (i.e. its worker crashed) — must exceed your longest job. |
WORKER_HEALTH_PORT |
8080 |
Liveness endpoint port. |
LEADER_DATABASE_URL |
unset → falls back to DATABASE_URL
|
Set to a direct, unpooled Postgres connection if a transaction-mode pooler (e.g. PgBouncer) sits in front of DATABASE_URL — the leader's advisory lock is session-scoped and misbehaves under transaction pooling. |
RATE_LIMIT_BACKEND |
memory |
Set to postgres when running multiple api replicas, so the auth rate limiter is shared and correct across them instead of in-memory-per-process. |
DATABASE_READ_URL |
unset | Offloads report/trend/posture dashboard reads to a second connection. Entirely opt-in — see Scaling Horizontally for what's actually safe to point this at today. |
← Back to the README · Live demo · Report an issue · MIT licensed
- Quick Start: Docker Compose
- Deploying with Portainer
- Deploying on Azure
- Configuration Reference
- Onboarding an Organization
- IMAP Mailbox
- Google Workspace Mailbox
- Sign-in Providers
- Multi-Tenancy & RLS
- DMARC Subdomain Inheritance
- Report Attribution
- SPF Conditional Mode
- Detected Domains
- DNS Check Scheduling
- Domain Rating
🏗️ Architecture
🗺️ Roadmap