Configuration comes from environment variables, and the same variable means the same thing however you deploy — Docker Compose in any shape, or the Kubernetes chart. There is no per-platform configuration format.

The app is ASP.NET Core, so nested settings use a double underscore for each level of nesting: Worker:ScheduleIntervalSeconds is set as Worker__ScheduleIntervalSeconds. A single underscore will not work and nothing will warn you — the value is ignored and the default applies. If a setting seems to have no effect, check the underscores first.

Only two settings are effectively required: the database connection string and the credential encryption key.

This page covers the settings people actually change. The complete list, including everything a release adds, lives in docs/ops/configuration.md in the application repository, where a test fails the build if a setting exists in code and is missing from the document. Treat that as canonical if the two ever disagree.

Runtime

APP_MODE

What the container is for. The same image serves all five.

ValueRuns
allConsole and mailbox polling in one process. What the quick-start compose file uses.
apiHTTP API plus the web console. No polling.
workerMailbox polling only, no HTTP listener.
migrateApplies pending database migrations and exits. Serves nothing.
mta-stsServes hosted MTA-STS policy files and health probes only — no console, no API, no auth stack, no worker. For an internet-facing policy host; see hosting MTA-STS policies.

Anything else fails at startup rather than falling back to api. That is deliberate: a typo like APP_MODE=woker would otherwise give you a container that is up, serves the console, and passes its healthcheck while ingesting nothing.

all is the default shape for a single host — one container, one log stream. Split into api + worker when ingestion is heavy enough to compete with the console, or when you want them to restart independently. See choosing a deployment.

Only one worker may run against a database. A container in worker or all mode takes a Postgres advisory lock at startup and exits if another already holds it. Two ingestion loops duplicate every sync pass and can send duplicate alert and digest email, so this is enforced rather than advised.

ASPNETCORE_URLS

Default http://+:8080. Change the port the API listens on.

AllowedHosts

Default *. Host-header allowlist.

Database

ConnectionStrings__Default

Npgsql connection string. Default points at localhost, which is only useful for local development — set it explicitly:

ConnectionStrings__Default=Host=postgres;Port=5432;Database=dmarc_analyzer;Username=postgres;Password=…

DATABASE_URL

A postgres://user:pass@host:port/database URI, accepted as an alternative to ConnectionStrings__Default and converted internally — for platforms (Render, Heroku, Railway, …) that hand you a managed database as a URI rather than an ADO.NET string. Takes priority when both are set. A sslmode query parameter is honored (disable, allow, prefer, require, verify-ca, verify-full).

Database__IamAuth

Default false. Log in to Amazon RDS, Aurora or RDS Proxy with a short-lived IAM authentication token instead of a password, generated per connection from the AWS credential chain (ECS task role, EC2 instance profile, EKS with IRSA). Leave the password out of the connection string.

TLS is required and the server is verified: sslmode=disable fails startup, and allow, prefer or require are raised to verify-full. Which CA to verify against is yours to supply. An RDS or Aurora endpoint needs the Amazon RDS bundle, named with Root Certificate= in the connection string (or ?sslrootcert= on a DATABASE_URL, or PGSSLROOTCERT). An RDS Proxy endpoint is already trusted by the image’s system store. The token is signed for the region the AWS SDK resolves (AWS_REGION and friends), not the one in the hostname.

The full recipe, including the IAM policy and the Compose and Helm wiring, is Postgres on AWS without a password.

Database__MigrateOnStartup

true in the shipped compose files. When true, the API applies pending EF Core migrations as it boots.

Set it false when something else owns schema changes — running more than one API replica, for instance, where replicas would otherwise race to apply the same migration. Then apply them with a migrate-mode container, which is what the Kubernetes chart does:

docker compose run --rm -e APP_MODE=migrate app

POST /api/v1/admin/database/migrate also works, with an admin session. Worker mode ignores this setting entirely — only the web host reads it.

Security

Security__CredentialEncryptionKey

Base64-encoded 32 bytes. Encrypts stored mailbox passwords at rest with AES-256-GCM. Generate one with:

openssl rand -base64 32

If it is unset the app still starts and stores credentials in plaintext, with a warning in the log — acceptable for a throwaway local test, not for anything real. Existing plaintext rows are re-encrypted lazily the next time each mailbox syncs, so adding a key later upgrades them without manual work.

Back this key up separately from the database. Losing it means re-entering every mailbox password; leaking it alongside a database dump exposes them.

Worker tuning

All optional. Defaults below are what ships in the image.

VariableDefaultWhat it does
Worker__ScheduleIntervalSeconds3600Seconds between polling passes. 60 minutes, 24/7. Floor of 15s.
Worker__MaxMessagesPerSync500Messages fetched per batch, not per pass — a pass keeps drawing batches until the mailbox is drained or the drain budget below runs out, checkpointing between them.
Worker__MailboxDrainBudgetMinutes20How long one source may keep drawing batches before the pass moves on to the next source.
Worker__MaxRetryAttempts3Attempts per mailbox before the run is recorded as failed.
Worker__RetryBaseDelaySeconds2Base for exponential backoff between those attempts.
Worker__StaleRunTimeoutMinutes90A sync stuck in running this long is auto-closed as failed.
Worker__SyncRunTimeoutMinutes30Hard cap on a single mailbox sync.
Worker__EnforceSingleInstancetrueRefuse to start when another worker already holds the ingestion lock. Turning this off removes the only guard that works on every platform.

A polling pass that fails (database unreachable, for instance) is retried sooner than the normal interval — backing off from 5 seconds up to ScheduleIntervalSeconds — so a transient outage doesn’t idle ingestion for a whole hour.

Email delivery

Alerts (and the future digest) are sent over SMTP. Until Host and FromAddress are set, delivery is off — alerts are still recorded and visible in the API, they just aren’t emailed. Nothing errors.

VariableDefaultWhat it does
Email__Host—SMTP relay hostname. Setting this and FromAddress enables delivery.
Email__Port587
Email__UseStartTlstrueSet false only for a local relay that doesn’t offer STARTTLS.
Email__Username—Leave blank for an unauthenticated relay (common for an in-cluster MTA).
Email__Password—
Email__FromAddress—Sender address. Make sure your own DMARC policy permits it.
Email__FromNameDMARC Analyzer
Email__BaseUrl—e.g. https://dmarc.example.com, used to build links in emails.

Check it works without waiting for something to break:

curl -s -b /tmp/cj -X POST \
  'https://dmarc.example.com/api/v1/admin/notifications/test?to=you@example.com'

Every curl on this page needs an admin session cookie — -b /tmp/cj sends the one signing in saved. Without it the answer is 401, not a test email.

Alerts

A worker pass raises an alert when a domain’s compliance drops sharply against its own baseline, or when its published DMARC policy is weakened (for example p=reject back to p=none, which silently removes protection).

VariableDefaultWhat it does
Alerts__EnabledtrueMaster switch.
Alerts__IntervalMinutes60How often rules are evaluated.
Alerts__ComplianceDropPercent15Percentage-point drop from baseline that raises a failure spike.
Alerts__MinMessages100Ignore days quieter than this — a few failures on a quiet domain is noise.
Alerts__BaselineDays7Days of history used as the comparison baseline.
Alerts__CooldownHours24Don’t re-raise the same alert for the same domain within this window.

Per client you can override the thresholds or switch alerting off entirely (Clients → edit, or PATCH /api/v1/clients/{id}):

{ "alertsEnabled": true, "alertComplianceDropPercent": 25, "alertMinMessages": 500 }

Alerts are measured against report data, not the wall clock — a domain whose reports arrive late won’t look like an outage.

Who gets notified

Add recipients on the Notifications page, or with POST /api/v1/notification-recipients. A recipient with a clientId covers that one client. Omit clientId and the address covers several clients — every client by default:

curl -s -b /tmp/cj -X POST https://dmarc.example.com/api/v1/notification-recipients \
  -H 'Content-Type: application/json' \
  -d '{"clientId":"<id>","email":"ops@client.example","kind":"alert"}'

kind is alert, digest, or both. For an address covering several clients, each client is set to rollup, separate or off (Notifications → the recipient’s client dialog, or PUT /api/v1/notification-recipients/{id}/routing). off also stops that client’s alerts to that address, so a contact who covers two clients never hears about a third. Clients added later take the recipient’s default mode.

Alert history is at GET /api/v1/alerts?days=30, and POST /api/v1/admin/alerts/evaluate runs an evaluation immediately instead of waiting for the next pass.

Monthly digest

Once a month recipients get a summary of the previous whole month: compliance and how it moved, volume, the failing sources grouped by IP, every domain with its effective policy, and the domains needing attention. It is HTML with a plain-text part. An address covering several clients gets one roll-up for the clients set to rollup and a mail of its own for each client set to separate — see who gets notified. Clients with no domains get no digest.

VariableDefaultWhat it does
Digest__EnabledtrueMaster switch.
Digest__DayOfMonth1Earliest day of the month the previous month’s digest may go out (1–28).
Digest__CheckIntervalHours6How often the worker checks whether one is due.

A domain is flagged as needing attention on the triggers below. These are the instance defaults: each client can override any of them on the Clients page, and for the numeric ones 0 switches the trigger off.

VariableDefaultWhat it does
Digest__LowCompliancePercent98Flag a domain whose aligned pass rate for the month is below this percentage.
Digest__ComplianceDropPoints2Flag a domain whose pass rate fell by at least this many percentage points on the month before.
Digest__MinMessages100Domains with fewer messages in the month are not judged on their pass rate — on a quiet domain one forwarded message is a whole point.
Digest__NoReportstrueFlag a domain whose reports stopped: at least Digest__MinMessages the month before, none this month — usually a broken rua= address. A domain that is always quiet is not flagged.
Digest__TightenAfterDays60Suggest a stricter policy for a domain at p=none or p=quarantine that has held Digest__TightenCompliancePercent for this many days. A suggestion, not a problem: it does not count towards “needs attention”.
Digest__TightenCompliancePercent98The pass rate a domain must hold before a stricter policy is suggested.

Sending is idempotent — a month already sent is never sent again, even across restarts — so the check interval is safe to lower.

Preview exactly what would be sent, without sending it — for one client, or every mail one recipient would get (roll-up included):

curl -s -b /tmp/cj 'https://dmarc.example.com/api/v1/admin/digest/preview?clientId=<id>'
curl -s -b /tmp/cj 'https://dmarc.example.com/api/v1/admin/digest/preview?recipientId=<id>'

Both return { "mails": [...] }, each with its subject, text and HTML. In 0.13.2 and earlier this endpoint returned { summary, body }. The Notifications page shows the same preview, rendered.

POST /api/v1/admin/digest/send sends anything due immediately. Recipients need kind of digest or both.

Branding

The agency’s identity on digest mail and in the console — the sign-in page, the header and the browser tab title. Both are the agency speaking to its clients, so a client who signs in sees your brand, not ours. One brand for the whole instance.

VariableDefaultWhat it does
Branding__NameDMARC AnalyzerName shown at the top and bottom of every digest, in the console header when no logo is set, and in the tab title (Domains · <name>).
Branding__LogoUrl(empty)Absolute https:// URL of a logo. In a digest it replaces the name at the top, and the footer still names the agency in text, because many mail clients hold images back. In the console it replaces the product’s logo and name, so it should carry your name itself; it sits on a light background. Anything that is not an http(s) URL is ignored.
Branding__AccentColor#0c7568Six-digit hex colour. In a digest: the top rule, links and the button. In the console: primary buttons, links, focus rings and the active navigation item. Text on a button turns dark when white would not be legible, and brand-coloured text is darkened until it is. Anything else falls back to the default.
Branding__HidePoweredByfalseOnce any of the three above is changed, the console shows a small “Powered by DMARC Analyzer” line on the sign-in page and at the foot of the navigation. true hides it. An unbranded instance never shows it.

Retention

A daily worker pass deletes DMARC data older than each client’s retention window (RetentionMonths on the client, 27 by default). Clients with LegalHold set are skipped entirely. See upgrading and backup for the operational side.

VariableDefaultWhat it does
Worker__RetentionEnabledtrueMaster switch. Set false to keep everything indefinitely.
Worker__RetentionIntervalHours24How often the pass runs. Retention is measured in months, so daily is ample.
Worker__RetentionBatchSize500Reports deleted per transaction, so a large backlog doesn’t hold locks across the table.

Retention is measured against each report’s reporting window end, not its ingest date — a backfilled mailbox doesn’t reset the clock on old reports. A non-positive RetentionMonths is treated as misconfiguration and falls back to 27 rather than deleting everything.

Purging the database does nothing to the mailbox: ingestion only reads, so the same report mail sits there indefinitely unless you turn on mailbox retention deletion per source — off by default, and the one pass in this application that removes data it does not own. See the mailbox copy for what it does and why it is opt-in.

VariableDefaultWhat it does
Worker__MailboxRetentionGraceDays30Extra days on top of the retention window before mail is deleted, so a clock skew or a paused worker cannot destroy mail the database has not re-read yet.
Worker__MailboxRetentionIntervalHours24Gap between mailbox retention passes.

Backup offload

Ships the configuration export and the append-only history tables to S3-compatible object storage on a schedule — MinIO, Cloudflare R2, Backblaze B2, or AWS itself. Bucket empty disables the whole feature, the same way an empty Email__Host above leaves alerts and digests inert.

VariableDefaultWhat it does
Backup__Bucket(empty)Destination bucket. Empty disables offload.
Backup__IntervalMinutes30Gap between offload passes.
Backup__Endpoint(empty)Custom S3 endpoint for MinIO, R2, B2. Empty targets AWS.
Backup__Regionus-east-1AWS region, or the signing region when Endpoint is set.
Backup__AccessKeyId, Backup__SecretAccessKey(empty)Leave both empty to use the ambient credential chain — an instance role or IRSA beats a long-lived key in configuration.
Backup__PrefixdmarcKey prefix, so one bucket can hold more than one install.
Backup__IncludeHistorytrueShip the audit trail, alerts, digests, sync runs and ingest ledger as immutable dated objects.
Backup__ArchiveReportMailfalseAlso archive raw report mail as it is ingested, so report history survives independently of the mailbox. Off by default — it is the largest thing this feature can store.

Refuses to run with no credential encryption key configured — in that state mailbox passwords are stored in plaintext, and shipping them to a bucket is worse than leaving them in the database. See the configuration export and offloading it to object storage for the operational side, including why bucket versioning matters.

Single sign-on (OIDC)

Off by default; see single sign-on for how it fits together, or a provider walked through screen by screen: Microsoft Entra ID, Authentik and Google, which need a ClientSecret; and Zitadel and Keycloak, which issue none.

VariableDefaultWhat it does
Auth__Oidc__EnabledfalseMaster switch.
Auth__Oidc__Authority—Issuer URL, e.g. https://login.example.com.
Auth__Oidc__ClientId—Client ID from your provider.
Auth__Oidc__ClientSecret—Client secret.
Auth__Oidc__Scopesopenid profile emailRequested scopes.
Auth__Oidc__DisplayNameSSOLabel on the login button.
Auth__Oidc__AutoProvisionfalseCreate a local user on first successful SSO login.
Auth__Oidc__TrustUnverifiedEmailfalseLink a login to an existing account when the provider asserts nothing about the address. For Entra ID, which sends no email_verified claim. Does not override a provider that answers “not verified”. Leave off unless the provider’s addresses are administered. (0.9.0 or newer.)
Auth__Oidc__DefaultRoleclient_viewerRole given to auto-provisioned users. Deliberately the least privileged.
Auth__Oidc__RequireHttpsMetadatatrueOnly set false against a local test IdP over HTTP.
Auth__Oidc__DisableLocalLoginfalseTurn off password sign-in and redirect the login page straight to this provider. Requires Enabled=true. See requiring SSO for everyone.

Local passwords and OIDC are interchangeable front doors by default — both mint the same application session, and authorisation is always evaluated in-app, never delegated to the identity provider.

MTA-STS (MtaSts)

A background pass checks every domain’s MTA-STS record and policy from the outside in. It needs no configuration to run. The last two keys matter only if you also host policy files here — see hosting MTA-STS policies.

KeyDefaultWhat it does
MtaSts__EnabledtrueRun the check pass and keep per-domain state fresh.
MtaSts__CheckIntervalHours6Gap between passes.
MtaSts__FetchTimeoutSeconds10Total budget for one policy-file fetch, connect included.
MtaSts__MaxConcurrentChecks4Domains checked concurrently during a pass.
MtaSts__AllowPrivateNetworksfalseLet the policy fetch reach loopback, private and link-local addresses. Off because mta-sts.<domain> hostnames derive from operator-entered domains; turn it on only for an instance monitoring intranet mail domains.
MtaSts__PolicyHost(empty)The hostname client CNAMEs point at, shown as the CNAME target in the console’s publish instructions. Empty shows a configure-me hint.
MtaSts__ServeCacheSeconds60In-memory TTL and Cache-Control: max-age for served policy bodies — also how long a dedicated mta-sts container may serve a superseded body after a console edit.

Logging

Logging__LogLevel__Default defaults to Information, and Logging__LogLevel__Microsoft.AspNetCore to Warning. Set the former to Debug when diagnosing ingestion.

Sessions

Not configurable at present, documented so you know the behaviour: the dmarc_session cookie is HttpOnly, times out after 12 hours idle, and has a 7 day absolute maximum.