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 four.

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.

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__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__HostSMTP relay hostname. Setting this and FromAddress enables delivery.
Email__Port587
Email__UseStartTlstrueSet false only for a local relay that doesn’t offer STARTTLS.
Email__UsernameLeave blank for an unauthenticated relay (common for an in-cluster MTA).
Email__Password
Email__FromAddressSender address. Make sure your own DMARC policy permits it.
Email__FromNameDMARC Analyzer
Email__BaseUrle.g. https://dmarc.example.com, used to build links in emails.

Check it works without waiting for something to break:

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

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 with POST /api/v1/notification-recipients. Omit clientId to have an address receive alerts for every client:

curl -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. 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 each client’s recipients get a summary of the previous whole month: compliance and how it moved, volume, unauthenticated sources, how many domains are enforcing, and the domains needing attention.

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.

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 a client would receive, without sending it:

curl 'https://dmarc.example.com/api/v1/admin/digest/preview?clientId=<id>'

POST /api/v1/admin/digest/send sends anything due immediately. Recipients need kind of digest or both — see who gets notified.

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 a worked example.

VariableDefaultWhat it does
Auth__Oidc__EnabledfalseMaster switch.
Auth__Oidc__AuthorityIssuer URL, e.g. https://login.example.com.
Auth__Oidc__ClientIdClient ID from your provider.
Auth__Oidc__ClientSecretClient 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__DefaultRoleclient_viewerRole given to auto-provisioned users. Deliberately the least privileged.
Auth__Oidc__RequireHttpsMetadatatrueOnly set false against a local test IdP over HTTP.

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

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.