Configuration Settings
All configurable settings for DMART. Bound to the strongly-typed DmartSettings record and loaded from a config.env dotenv file and/or environment variables. Values are read once at boot and validated before the server starts — there is no hot reload and no file watcher.
Loading Order
Sources are layered with a strict precedence (later wins): appsettings.json < config.env < environment variables (Dmart__Xxx).
The config.env file is located with a first-match-wins lookup:
-
The path in the
BACKEND_ENV(orDMART_ENV) environment variable — used for dev/CI, e.g. a repo-local file -
~/.dmart/config.env— per-user install -
/etc/dmart/config.env— system-wide RPM/DEB install
A cwd-relative ./config.env is not looked up implicitly. Because the file may carry JWT_SECRET and DATABASE_PASSWORD, DMART refuses to read it when it is world-writable, and warns at every start when it is world-readable.
Where the first file comes from. dmart init writes ~/.dmart/config.env from the config.env.sample shipped beside the binary, with a fresh random JWT_SECRET, and leaves an existing file alone. The sample's values are not all the defaults in the tables below: it sets JWT_ACCESS_EXPIRES=3600, DATABASE_DRIVER="postgresql" with DATABASE_PASSWORD="somepassword", and ADMIN_EMAIL="admin@example.com", among others. The RPM, DEB and APK packages instead seed /etc/dmart/config.env from config.env.packaged: SQLite at /var/lib/dmart/dmart.db, SPACES_FOLDER="/var/lib/dmart/spaces", JSON logs, and the placeholder JWT_SECRET, which must be replaced before the server starts.
Strict validation. Configuration is bound and validated at startup (ValidateOnStart). Any unknown key in config.env is rejected and aborts startup — there is no silent fallthrough for typos. (Environment variables are not checked this way.) Keys of settings that no longer exist — REDIS_HOST/PORT/PASSWORD/CONNECTION, ACTIVE_DATA_DB, WEBSITE_URL and WEBSITE_DIR — are unknown keys and stop the boot. ALLOW_PASSWORD_RESET_RESEND_AFTER is ignored with a warning. MANAGEMENT_SPACE is retired: the management space is always management. MANAGEMENT_SPACE="management" still starts, with a warning to remove the line; any other value refuses to start.
Frontends read the server's values. The config.json DMART serves cxb and catalog carries OTP_LENGTH, ALLOW_OTP_RESEND_AFTER, OTP_TOKEN_TTL, MAX_UPLOAD_SIZE_MB, DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, UI_LIST_LIMIT, and whether the password and username rules are custom (password_rule_custom, username_rule_custom), with their hints (password_hint, username_hint). A frontend DMART does not serve can read the same from GET /public/client-config. The backend_timeout in that config.json is at least REQUEST_TIMEOUT + 5 seconds, so the server's own timeout answer arrives before the frontend gives up.
The frontends' own config.json. DMART serves cxb and catalog the first it finds of: the file named by --cxb-config (or DMART_CXB_CONFIG), ./config.json, ~/.dmart/config.json, and the copy built into each app. Its keys are title, footer, short_name, display_name, description, default_language, languages and backend, plus catalog's switches enable_websocket, enable_messaging, enable_surveys, enable_poll, enable_reaction_and_comment, enable_public_view and enable_notifications. A blank backend means the page's own origin. Any other value must be on the page's own site, because both apps authenticate by a SameSite=Lax cookie (see Base URL & Authentication). cxb draws schema and workflow diagrams by sending their text to https://www.plantuml.com/plantuml, unless plantuml_server names another server.
General Configuration
| Setting | Description | Default |
|---|---|---|
APP_URL |
Public base URL of the server, put in front of the short links /managed/shortening/… hands out. Empty makes them host-relative. |
"" |
MAX_QUERY_LIMIT |
Hard cap on the number of records returned by a query. A limit of -1 asks for it. |
10000 |
DEFAULT_QUERY_LIMIT |
Page size of a query that omits limit: the managed and public query routes, CSV, export, Parquet export and the MCP tools (which stop at 50). Above MAX_QUERY_LIMIT it is cut to the cap, with a startup warning. |
10 |
UI_LIST_LIMIT |
How many items cxb and catalog ask for when they load a whole list rather than a page: spaces, a space's folders, the schema, workflow, role, group and permission pickers, notifications and comments. Never above MAX_QUERY_LIMIT. |
1000 |
QUERY_TOTAL_CAP |
Largest exact total a query computes. Above it, total is the cap and total_is_lower_bound is true. 0 = unlimited. |
100000 |
RETRIEVE_TOTAL_DEFAULT |
Whether a query that omits retrieve_total is counted. A skipped count reports total as -1. /public/query counts only when asked. |
true |
ENABLE_SMART_SEARCH |
Plain words in a search match whole and partial words in entry values, with spelling correction, best match first. False keeps the plain substring match. | true |
SEARCH_TEXT_INDEX_BATCH_SIZE / SEARCH_TEXT_INDEX_PAUSE_MS |
Pace of the background job that fills the search text of rows that have none, such as rows written outside DMART's own saves (see Plain-text search): the most rows a batch takes (1–10000), and a pause between batches in milliseconds (0–60000). A batch that takes over a second halves the next one, down to 10 rows, and quick ones double it back up to this size. Each row filled is rewritten in every index on entries; a pause spreads that write load over longer. |
500 / 0 |
UNIQUENESS_MAX_PROBES |
Most searches one uniqueness rule may run for a single write; past it the write is refused | 1000 |
EVENTS_LOG_MAX_BYTES |
Size at which a space's events.jsonl rolls over (one previous file is kept). 0 = never. |
52428800 (50 MiB) |
URL_SHORTER_EXPIRES |
Short-link expiration (seconds) | 3600 |
REQUEST_TIMEOUT |
Seconds a request may run before DMART stops it and answers 504. WebSockets, /mcp, and zip import and export (/managed/import, /managed/export) run without it. |
35 |
MAX_UPLOAD_SIZE_MB |
Largest file, in MB (1–500), accepted by the routes that take one: resource_with_payload (managed and public), resources_from_csv, import and public attach. The file is held in memory while it is stored, and must arrive within REQUEST_TIMEOUT. Every other request stays capped at 50 MB, or at this limit when it is lower. Keep the reverse proxy's body limit at least this high. |
50 |
JQ_TIMEOUT |
Timeout (seconds) for the jq subprocess used by join sub-queries carrying a jq_filter |
2 |
JQ_MAX_CONCURRENCY |
How many jq subprocesses may run at once, process-wide |
4 |
JQ_QUEUE_TIMEOUT_SECONDS |
Seconds a request waits for a free jq slot before it fails |
5 |
Outbound calls have timeouts of their own, each 1–3600 seconds: SMTP_TIMEOUT, SMS_TIMEOUT, OAUTH_TIMEOUT and EMBEDDING_TIMEOUT, listed with their services below. Startup warns when one of them, times the calls a request makes in a row (up to three for a social sign-in), is not shorter than REQUEST_TIMEOUT: the request's own deadline would cut it off first, and the client would get a bare timeout instead of the call's error.
Server & Network
| Setting | Description | Default |
|---|---|---|
LISTENING_HOST |
Hostname/IP the Kestrel server binds to | "0.0.0.0" |
LISTENING_PORT |
Port the server listens on (HTTP + WebSocket share this port) | 8282 |
CXB_URL |
URL path prefix for the embedded CXB admin SPA. Set to / to serve it at the root. |
"/cxb" |
CAT_URL |
URL path prefix for the embedded Catalog SPA | "/cat" |
ALLOWED_CORS_ORIGINS |
Comma-separated origins allowed to make cross-site requests. Empty allows only the same-host origin. | "" |
TRUSTED_PROXIES |
Comma-separated IPs/CIDRs whose X-Forwarded-For is trusted (for real client IP behind nginx/an LB). Loopback is always trusted. An entry that does not parse is dropped without a warning, unlike in ADMIN_IP_ALLOWLIST, where it stops startup. A CIDR entry must name the network address: 10.0.0.0/24, not 10.0.0.5/24. |
"" |
FORWARDED_FOR_HOP_COUNT |
Number of proxy hops in front of DMART. At least 1: 0 counts as 1. | 1 |
OTLP_ENDPOINT |
OpenTelemetry collector endpoint for metrics/traces. Empty disables observability entirely. | "" |
With ASPNETCORE_URLS set in the environment, that names the listen address instead of LISTENING_HOST and LISTENING_PORT. dmart selfcheck, and dmart cli given no URL, reach the server at LISTENING_HOST:LISTENING_PORT from the same config.env, reading a wildcard host such as 0.0.0.0 as 127.0.0.1. Signing in without a password, with a token minted from this config.env, they send that token only to that address or a loopback one (localhost, 127.0.0.0/8, ::1), and refuse any other URL before a token is minted (see CLI).
Database & Storage
DMART keeps all runtime data in one SQL database: PostgreSQL (via Npgsql) or SQLite. PostgreSQL is the full tier. SQLite is a reduced tier for a single node, the edge, development and CI, and the default for the RPM, DEB and APK packages and the container. There is no Redis and no filesystem runtime store; all caches are in-process.
| Setting | Description | Default |
|---|---|---|
DATABASE_DRIVER |
postgresql (or postgres) or sqlite; any other value stops startup. Unset, DMART picks PostgreSQL when POSTGRES_CONNECTION or DATABASE_PASSWORD is set or another DATABASE_* connection setting differs from its default, and SQLite otherwise; the startup log says which and why. |
"" (inferred) |
SQLITE_PATH |
The SQLite database file, used only on SQLite. Relative paths resolve against the working directory; the packages use /var/lib/dmart/dmart.db. Keep it on a local disk, not NFS. |
"dmart.db" |
AUTH_CACHE_TTL |
Seconds the per-request user and session lookups are cached. 0 = check the database on every request. The node that ends a session or deactivates a user evicts at once; on other replicas the session or user keeps working for up to this long. | 5 |
AUTHZ_CACHE_TTL |
Seconds a user's resolved permissions are cached. The node that changes them evicts at once; this bounds how stale other replicas can be. 0 = no TTL. | 60 |
What SQLite costs. Payload filters (@payload.body.x:v) scan every row, with no index, so their cost grows with the corpus. One writer runs at a time. ILIKE folds case for ASCII letters only. A number stored as a JSON string sorts as text. sum and avg are floating point. Semantic search and four aggregation reducers (Query & Search) are PostgreSQL only. The database runs in WAL mode, which keeps -wal and -shm files beside it, so a file backup copies all three together (or use dmart export). It serves one node: the locks that let several DMART processes share a database are PostgreSQL's. Use PostgreSQL when a payload-filtered query over the whole corpus must stay under about 50 ms, or when write latency under concurrent writers must stay bounded.
Storage that does not survive a reboot. On Linux, startup warns with NON_DURABLE_STORAGE when SPACES_FOLDER or the SQLite file sits on a tmpfs or ramfs. The usual cause is a volume that failed to mount, leaving the empty directory beneath it in use.
On PostgreSQL, provide a full connection string with POSTGRES_CONNECTION, or leave it unset and let DMART assemble one from the individual DATABASE_* components below.
PostgreSQL Connection
| Setting | Description | Default |
|---|---|---|
POSTGRES_CONNECTION |
Full Npgsql connection string. When set, the component settings below are ignored. | "" (unset) |
DATABASE_HOST |
Database hostname | "localhost" |
DATABASE_PORT |
Database port | 5432 |
DATABASE_USERNAME |
Database username | "dmart" |
DATABASE_PASSWORD |
Database password | "" |
DATABASE_NAME |
Database name | "dmart" |
DATABASE_POOL_SIZE |
Connection pool size. The pool opens at most this plus DATABASE_MAX_OVERFLOW connections. |
10 |
DATABASE_MAX_OVERFLOW |
Connections allowed beyond DATABASE_POOL_SIZE |
10 |
DATABASE_POOL_TIMEOUT |
Seconds to wait for a connection (Npgsql Timeout) |
30 |
DATABASE_POOL_RECYCLE |
Seconds an idle pooled connection is kept before it is closed (Npgsql Connection Idle Lifetime) |
1800 |
DATABASE_KEEPALIVE |
Seconds of socket inactivity before a keepalive probe is sent. 0 disables. | 30 |
DATABASE_MAX_AUTO_PREPARE |
How many of the most-reused statements Npgsql prepares per connection. 0 disables. Ignored with POSTGRES_CONNECTION. |
200 |
Time zone. DMART stores timestamps as the app host's local wall-clock time, with no time zone (see Data Model). It pins each PostgreSQL session's time zone to the app host's, so the database's own clock agrees, unless the connection string sets Timezone= itself.
Session name. DMART names its PostgreSQL sessions dmart@<host>, after the app host's name. It shows as application_name in pg_stat_activity and as %a in the database's log, so two DMART nodes, or DMART and another service signing in as the same role, can be told apart. A POSTGRES_CONNECTION that sets Application Name keeps its own.
Spaces Folder (import/export only)
The on-disk spaces/ + .dm/meta.*.json layout is a transfer/backup format used only by import, export and seed — never as a live store. SPACES_FOLDER defaults empty. When set, it is where dmart seed copies the bundled sample spaces, and the root of an optional append-only audit trail: each space's events go to <SPACES_FOLDER>/<space>/.dm/events.jsonl (rolled over at EVENTS_LOG_MAX_BYTES), which events queries read. Empty, no audit trail is written.
Security & Authentication
Authentication is JWT Bearer (HS256, symmetric key from JWT_SECRET) with Argon2id password hashing. With ENABLE_MCP=true DMART also runs an OAuth 2.1 authorization server for MCP clients, and with OIDC_ISSUER set, an OpenID Connect provider that signs its tokens with its own RSA key. DMART's own tokens are always HS256; that is not configurable.
JWT & Sessions
| Setting | Description | Default |
|---|---|---|
JWT_SECRET |
Symmetric signing key: at least 32 bytes and 10 distinct characters (e.g. openssl rand -hex 32). Required — the server refuses to start on the placeholder value. |
"change-me…" |
JWT_ISSUER |
Expected iss claim. When it is an absolute http(s) URL, it is also the public base of the MCP OAuth discovery URLs, so behind a path prefix set it to the public URL (https://host.example/dmart). Changing it invalidates every token already issued. |
"dmart" |
JWT_AUDIENCE |
Expected aud claim |
"dmart" |
JWT_ACCESS_EXPIRES |
Access-token lifetime (seconds) | 2592000 (30d) |
JWT_REFRESH_DAYS |
Refresh-token lifetime (days). Only /oauth/token issues refresh tokens; /user/login gives one long-lived access token. |
30 |
JWT_REQUIRE_TOKEN_USE |
Reject JWTs lacking the token_use claim (access vs refresh separation) |
false |
MAX_SESSIONS_PER_USER |
Max concurrent sessions per user; past it the oldest sessions end | 5 |
SESSION_INACTIVITY_TTL |
Idle seconds before a JWT is rejected and its session deleted (0 = disabled) | 0 |
SESSION_MAX_LIFETIME_SECONDS |
Hard cap on the refresh-token chain from original login (0 = disabled) | 0 |
LOGOUT_ON_PWD_CHANGE |
Invalidate all sessions on password change | true |
CSRF_PROTECT_COOKIE_AUTH |
Gate cookie-borne auth against cross-site requests (bearer-header callers unaffected) | true |
Brute-force & Rate Limiting
| Setting | Description | Default |
|---|---|---|
MAX_FAILED_LOGIN_ATTEMPTS |
Wrong passwords and codes before an account is auto-locked (0 = never lock) | 5 |
LOCKOUT_COOLDOWN_SECONDS |
Seconds with no sign-in attempt before a locked account unlocks (0 = locked until an admin unlocks) | 900 |
AUTH_RATE_LIMIT_PER_MINUTE |
Per-address cap per 60s (HTTP 429 beyond it) on every route that takes a credential or sends a code — sign-in, codes, password reset, registration, social sign-in, /oauth/*, the OIDC sign-in form — plus /public/submit and anonymous uploads. The LDAP face allows as many failed binds per address. At least 1. |
10 |
PUBLIC_RATE_LIMIT_PER_MINUTE |
Per-address cap on all /public calls per 60s. Behind a proxy it needs TRUSTED_PROXIES, or every visitor shares one bucket. 0 disables. |
600 |
MAX_OTP_VERIFY_ATTEMPTS |
Guesses allowed against one OTP code, live or expired. Past them, every guess at it, the right code included, answers as a wrong code. 0 means 5: no code takes unlimited guesses. | 5 |
LOCK_PERIOD |
Seconds a PUT /managed/lock stays held before another user can take it |
300 |
REPAIR_LEGACY_LOCKOUTS_ON_START |
At startup, release the account locks an older release wrote as is_active=false. A no-op once repaired. |
true |
These limits are server-wide. They apply the same way to web, mobile and admin accounts; bot accounts are never locked by MAX_FAILED_LOGIN_ATTEMPTS, though their failures are still counted.
One sign-in attempt
flowchart TD
start(["Sign-in attempt"]) --> atmax{"Locked?"}
atmax -- yes --> quiet{"Cool-down<br/>over?"}
quiet -- no --> touch["Restart the cool-down"]
touch --> locked(["USER_ACCOUNT_LOCKED"])
quiet -- yes --> clear["Unlock:<br/>attempt_count = 0"]
clear --> cred
atmax -- no --> cred{"Password<br/>or code right?"}
cred -- yes --> ok(["Signed in,<br/>attempt_count = 0"])
cred -- no --> inc["attempt_count + 1"]
inc --> reached{"Limit<br/>reached?"}
reached -- yes --> locked
reached -- no --> wrong(["Wrong password<br/>or code"])
Locked means attempt_count has reached MAX_FAILED_LOGIN_ATTEMPTS. Cool-down over means no attempt for more than LOCKOUT_COOLDOWN_SECONDS. A wrong code, or the right one sent after it expired, also uses up one of that code's own MAX_OTP_VERIFY_ATTEMPTS. Wrong passwords at the login, OIDC and MCP sign-in forms, wrong or expired codes at login and password reset, a wrong current password on a profile change or at /user/validate_password, a wrong current password when an account changes its own email or phone, and failed LDAP binds all add to the same counter.
The account over time
stateDiagram-v2
state "No failures" as Clean
state "Some failures" as Counting
state "Locked" as Locked
[*] --> Clean
Clean --> Counting : wrong
Counting --> Counting : wrong
Counting --> Clean : right
Counting --> Locked : wrong, limit reached
Locked --> Locked : any attempt restarts the cool-down
Locked --> Clean : cool-down passes, or admin unlocks
The cool-down counts from the last attempt, not from when the lock started, so every attempt while locked starts it again, even one with the correct password. With the defaults:
| Time | What happens | Result |
|---|---|---|
| 10:00–10:02 | 5 wrong passwords | The 5th locks the account |
| 10:10 | The correct password | Refused: still locked, and the 15 minutes now run to 10:25 |
| 10:26 | The correct password | Unlocked and signed in |
A lock does not sign the user out: access tokens already issued keep working until they expire, but refreshing them at /oauth/token is refused. An admin unlocks an account early with a user update carrying unlock: true, which is what the "clear on save" box in CXB and the catalog sends. An attempt_count sent in an update is ignored, so a client that posts a user record back can neither clear a lock nor lower the counter. Nobody unlocks their own account this way, and unlocking an admin account takes a global admin or the admin that owns it.
Registration, OTP & Profiles
| Setting | Description | Default |
|---|---|---|
WEB_DEVICE_OTP |
A trusted browser for accounts of type web, for tightly secured instances. On, a web account signs in with its password, or through Google, Facebook or Apple, only from the browser it last signed in to with a one-time code, and only once its email or phone is verified. See Trusted Browser. |
false |
IS_REGISTRABLE |
Allow self-registration via POST /user/create, and the accounts ENABLE_OTP_IMPLICIT_REGISTRATION creates |
false |
IS_OTP_FOR_CREATE_REQUIRED |
Require a verified OTP for account creation, so each address a registrant gives is proved by a code. The server refuses to start with it off while IS_REGISTRABLE=true: anyone could then claim someone else's address before its owner registers. |
true |
OTP_TOKEN_TTL |
One-time-password time-to-live (seconds). An expired code is never redeemed: sent after it, the code that was issued answers OTP_EXPIRED (308), and any other code answers as a wrong one. |
300 |
OTP_LENGTH |
Digits in a one-time code, 4–9. Each digit fewer makes a code ten times easier to guess, so startup warns below 6. | 6 |
ALLOW_OTP_RESEND_AFTER |
Minimum seconds between codes sent to one destination, counted separately for password reset and for every other purpose. A request inside it answers success and sends nothing. Replaces the retired ALLOW_PASSWORD_RESET_RESEND_AFTER. |
60 |
MAX_OTP_REQUESTS_PER_DAY |
Cap on codes sent to one destination in a rolling 24 hours, counted the same two ways (0 = no cap) | 10 |
OTP_HISTORY_RETENTION_DAYS |
Days used and expired codes are kept before they are purged. 0 = never purged. | 2 |
REGISTRATION_ENABLED_CHANNELS |
Channels self-registration accepts: email and/or msisdn. Empty closes self-registration. |
"email,msisdn" |
REGISTRATION_EMAIL_DOMAINS |
Comma-separated email domains a user may give their own account: when registering (/user/create, and the account ENABLE_OTP_IMPLICIT_REGISTRATION creates) and when changing their own email (/user/verify-contact). a.com,b.org allows x@a.com and x@b.org, not x@mail.a.com: list each subdomain. A registration, or a code for a new address, outside them is refused with the list of allowed domains, and no code is sent; a code sign-in's implicit registration is refused silently, as the rest of its gate is. Admins creating or editing users are not limited, and an email an account already has keeps working, for confirming it and for sign-in codes. Empty allows any domain. |
"" |
OTP_SHORTNAME_CHANNELS |
Whether /user/otp-request, the sign-in by code and /user/password-reset-confirm take a shortname, and where its code goes: msisdn and/or email, in order of preference. The account's first verified contact among them gets the code, else its first contact among them. none refuses a shortname on all three, so callers name the phone or email: shortnames are public, and resolving one lets anyone who knows a username have codes sent to that person and use up that address's resend cooldown and daily cap. An admin's sign-in (password, then password and code) is not affected, and its code keeps the default order. A reset by hosted mailbox follows the setting, and the default order under none. An unknown or repeated channel, or an empty value, stops the server from starting. |
msisdn,email |
EMAIL_CONTACT_ONLY_ROLES |
Comma-separated roles whose accounts sign in by phone alone. On an account that holds one, has a phone and no password, and is not of type admin, the email is for contact only: no sign-in or reset code goes to it, and no social sign-in reaches the account through it. Adding or changing that email then takes only a code sent to the new address. Changing the phone still takes a code to the current phone, and a code to the email does not count for it. Someone who loses the phone cannot recover the account through the email, so check that nothing signs these accounts in by email first. Empty changes nothing. |
"" |
ENABLE_OTP_IMPLICIT_REGISTRATION |
Let a code sign-in with an unknown email or phone create the account instead of failing, under the same gates as /user/create (IS_REGISTRABLE and the channel) |
false |
REGEX_CONFIG_PATH |
JSON file overriding the email and msisdn patterns, the password rule (password, with the password_hint a refused password is told) and the username rule (username, with its username_hint); see the rules file below. Empty = ~/.dmart/regex.json. The built-in password rule is 8–64 characters with a digit and an uppercase letter. |
"" |
USER_CREATE_DEFAULT_ROLE |
Single role assigned to every self-created user (self-service create ignores roles in the body) | "" (none) |
USER_CREATE_DEFAULT_GROUP |
Single group assigned to every self-created user | "" (none) |
USER_PROFILE_PAYLOAD_PROTECTED_FIELDS |
CSV of payload fields users cannot update via POST /user/profile |
"" |
USER_PAYLOAD_SCHEMA |
The payload schema (in management/schema) a self-registered account gets, at /user/create and when a code sign-in creates the account (ENABLE_OTP_IMPLICIT_REGISTRATION). It is the only schema a user's own request may name: at sign-up, and on the profile while the record has none. Empty: users name no schema, and only an admin gives a user one. A value that is not a valid shortname stops the server from starting. See User Profile Protection. |
"" |
ALLOWED_SUBMIT_MODELS |
CSV of space.schema pairs allowed for /public/submit and anonymous uploads, matched exactly, case included (empty = none) |
"" |
USER_DELETION_MODE |
soft keeps a deleted user's row, marked deleted and stripped of contacts, password, mailbox, aliases and services: the account leaves user listings and their totals, and stays in exports. hard deletes it with the entries and attachments the user owns (an entry with every attachment beneath it, whoever owns that), their history and the account's own, its sessions and locks; the history the user wrote on records that stay is kept, and the spaces, roles, groups, permissions and users it owned pass to dmart, as does a folder of its that holds another owner's entry. An admin's hard delete of a user who has created records needs force: true. POST /user/delete, a user deleting themself, takes only the records the user may delete, each judged as their own delete request would be; if any they own are left, the account is soft-deleted instead, and the answer says how many (records_kept). See Deleting a user. |
"soft" |
The rules file. The JSON file at REGEX_CONFIG_PATH may hold email, msisdn, password, password_hint, username and username_hint:
{
"email": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\z",
"password": "^.{12,64}\\z",
"password_hint": "Use at least 12 characters.",
"username": "^[a-z][a-z0-9_]{2,31}\\z",
"username_hint": "3-32 lowercase letters, digits and _, starting with a letter."
}
- A missing file, key or empty value keeps the built-in rule. A rule that does not compile is logged, and the built-in one applies.
passwordapplies to every new password (registration, profile, reset, an admin's create), never to stored ones.usernameapplies to a username a person chooses for a new user: a registrant'sshortnameat/user/create, an admin's create, anddmart import-ldif. It can only narrow DMART's own shortname rule (letters, digits and_, up to 64 characters), since a name must match both. A name the server makes is not held to it. Where DMART's usernames become Matrix IDs, use a Matrix-safe rule such as the one above.- The hints are what a refused password or username is told, and sign-up forms show them, so set each one whenever you change its rule.
Admin Accounts
Accounts of type admin sign in with their password and a one-time code, both checked: once the password checks out, the server sends the code. An admin resets its password by code only while force_password_change is set on the account, which only a global admin, or the active admin that created the account, may set.
Management's users, roles, permissions and groups are for accounts of type admin and the built-in dmart. Another account's grants on them count only through the two exemptions below (see The Management Space). At every start the log names, as MANAGEMENT_ADMIN_ONLY, each role whose grants there the rule limits, and dmart check management hard counts the accounts that hold each.
| Setting | Description | Default |
|---|---|---|
ADMIN_IP_ALLOWLIST |
IPs and CIDR ranges admin accounts may sign in and work from, checked at sign-in and on every request. Empty = no address limit. Behind a proxy it needs TRUSTED_PROXIES. An entry that does not parse stops startup. |
"" |
ADMIN_USER_TYPE |
Type the built-in dmart account is held to at every start: web or admin. As admin it signs in with its password and a code, so it needs an email or phone the server can send to; becoming one ends its sessions. Setting web again and restarting is the way back in when codes stop arriving. As web it is held to WEB_DEVICE_OTP, so it needs a verified email or phone before that is turned on. |
"web" |
MANAGEMENT_EXEMPT_ACCOUNTS |
Comma-separated accounts, such as a bot, whose grants on management's users, roles, permissions and groups count although they are not of type admin. Naming anonymous stops startup. |
"" |
MANAGEMENT_EXEMPT_ROLES |
Comma-separated roles whose grants on management's users, roles, permissions and groups count for every account that holds them. Naming world, logged_in or the USER_CREATE_DEFAULT_ROLE role stops startup: what every caller holds never reaches the management space. |
"" |
Password Hashing
These set how new hashes are made. A stored hash keeps its own parameters, so changing them never invalidates a password.
| Setting | Description | Default |
|---|---|---|
PASSWORD_HASH_MEMORY_KB / PASSWORD_HASH_ITERATIONS / PASSWORD_HASH_PARALLELISM |
Argon2id cost (OWASP's recommended settings) | 19456 / 2 / 1 |
PASSWORD_HASH_MEMORY_BUDGET_MB |
Ceiling on the hashing memory in use at once; hashes past it wait. 0 = half the memory available to the process. | 0 |
PASSWORD_HASH_QUEUE_TIMEOUT_SECONDS |
How long a hash waits for budget before the request gets a 503 with Retry-After |
30 |
PASSWORD_REHASH_ON_LOGIN |
After a successful login, rehash a password stored with other parameters | true |
MAX_PASSWORD_RECORDS_PER_REQUEST |
Most records carrying a password in one /managed/request. 0 disables. |
50 |
Admin Bootstrap
| Setting | Description | Default |
|---|---|---|
ADMIN_EMAIL |
Email seeded for the dmart admin on first boot only |
"" |
ADMIN_PASSWORD |
First-boot admin password. Intentionally omitted from config.env; the recommended flow is dmart passwd dmart once the server has first started, which prompts for the password. Honoured via Dmart__AdminPassword if provided. |
"" (unset) |
Email & Notifications
Email and SMS each have an on/off switch, IS_SMTP_ENABLED and IS_SMPP_ENABLED. Left unset, a channel is on exactly when it has a real gateway. A channel that is off sends nothing, mocked or not, and logs no code, while the request still succeeds, so a code sent over it reaches nobody. Startup names a gateway or a mock that an off channel ignores.
The message text. A code goes out as otp_message, by SMS and as an email's body, with {code} replaced by the code and {otp_ttl} by OTP_TOKEN_TTL in minutes. An email's subject is otp_email_subject, which never carries the code. Both come from the built-in language files (english, arabic and kurdish), in the account's language, falling back to English. To change them, put the keys in ~/.dmart/languages/<english|arabic|kurdish>.json: each key there overrides the built-in one, and the rest stay. The files are read at start.
SMTP
| Setting | Description | Default |
|---|---|---|
IS_SMTP_ENABLED |
Email on (true) or off (false). Unset, email is on exactly when MAIL_HOST is set. |
"" (follows MAIL_HOST) |
MAIL_HOST |
SMTP server host. With email switched on and no host, nothing is sent and a code is logged only at debug level. |
"" |
MAIL_PORT |
SMTP port | 587 |
MAIL_USERNAME |
SMTP username | "" |
MAIL_PASSWORD |
SMTP password | "" |
MAIL_USE_TLS |
Use TLS for the SMTP connection | true |
MAIL_FROM_ADDRESS |
From email address. Required, and must be a valid address, while email is really sent (on, not mocked, with a MAIL_HOST): the server refuses to start without it. |
"" |
MAIL_FROM_NAME |
From name | "" |
SMTP_TIMEOUT |
Seconds one email send (connect, TLS, sign-in, transfer) may take | 15 |
MOCK_SMTP_API |
Send no email (dev/test) while email is on. Each code is still random, and is written to the server log at warning level. A mock is not a mail server, so without a MAIL_HOST it needs IS_SMTP_ENABLED=true. |
false |
SMS Gateway
| Setting | Description | Default |
|---|---|---|
IS_SMPP_ENABLED |
SMS on (true) or off (false). Unset, SMS is on exactly when SEND_SMS_OTP_API or SEND_SMS_API is set. |
"" (follows the endpoints) |
SEND_SMS_OTP_API |
POST endpoint for OTP SMS delivery. With SMS switched on and no endpoint, nothing is sent and a code is logged only at debug level. |
"" |
SEND_SMS_API |
POST endpoint for general SMS delivery | "" |
SMS_SENDER |
Optional sender ID / from-name inlined into the SMS request body | "" |
SMPP_AUTH_KEY |
Value of the auth-key header sent to the SMS gateway. Blank, no SMS is sent. |
"" |
SMS_TIMEOUT |
Seconds a call to either SMS endpoint may take before the send counts as failed | 15 |
MOCK_SMPP_API |
Send no SMS (dev/test) while SMS is on. Each code is still random. The server log gets a warning line naming the number, which carries the message, code included, only with MOCK_SMPP_LOG_OTP. A mock is not a gateway, so without an SMS endpoint it needs IS_SMPP_ENABLED=true. |
false |
MOCK_SMPP_LOG_OTP |
Write each mocked SMS's text, and so its code, into that log line instead of [redacted]. Whoever reads the log then has the codes. |
false |
MOCK_OTP_FIXED |
Make every code sent over a mocked channel the fixed MOCK_OTP_CODE. Test servers only: anyone who knows an account's email or phone can then sign in as it. The server refuses to start with it on and no code, and warns at every start while it is in force. |
false |
MOCK_OTP_CODE |
The fixed code, OTP_LENGTH digits long; used only with MOCK_OTP_FIXED=true |
"" |
Logging
| Setting | Description | Default |
|---|---|---|
LOG_FORMAT |
"text" (human-readable) or "json" (structured JSON lines). Unset, it is json under systemd. With LOG_FILE set, output is always JSON. |
"text" |
LOG_LEVEL |
trace, debug, information, warning, error, critical, none |
"information" |
LOG_FILE |
Log file path. Empty = stdout only (container/journald friendly). Set, the file gets every log line, and each request's access line carries its request and response bodies (JSON, up to 32 KB each, as LOG_BODIES says) with secrets masked. |
"" |
LOG_BODIES |
Which bodies LOG_FILE's access lines keep: all, requests (request bodies only) or none. A body left out reads {"_omitted": "LOG_BODIES"}; the headers, status, duration and user stay. For a log shipped to a log store, none keeps query answers and records out of it. Any other value stops the server from starting. |
"all" |
LOG_MAX_BYTES |
Max file size before rotation. 0 disables rotation. | 1073741824 (1 GB) |
LOG_BACKUP_COUNT |
Rotated archive retention (< 0 unlimited, 0 truncate, > 0 keep N) | -1 |
The access line. Each request logs one line carrying its X-Correlation-ID, the header the response returns. In JSON, props.duration is in milliseconds. Secrets are masked in bodies, headers and query strings: a body field is masked when its name is a known secret (code, reset_code and verification_code among them, though not the number in a response's error object, which is dmart's error code and stays readable), ends in password, secret, _hash, _token, _otp or _key, or is one a history diff masks (see history), such as passwordConfirm, pwd, pin, otpCode or apiKey, unless it ends like a name that only talks about a secret: password_hint, token_type and otp_length stay readable, as do others ending in _hint, _type, _length, _rules and the like. A header is masked when it is authorization, cookie, location or another listed one, or its name contains key, auth, token, secret, password, passwd, cookie, session, signature or credential anywhere (x-keyauth included). A string in a body that is a URL (https://…?token=…) keeps its address, and each of its query and fragment parameters is masked by the query string's rule, and also when it is sig, signature, key, auth or session, so a payment or signed link is logged without its token. A masked value reads ******. The response bodies of GET /user/profile and of the directory feed are never captured.
Personal data. /user/otp-request logs the full phone number or email whenever it answers success without sending a code: an unknown or unusable account, a request inside the resend wait, the daily cap. It needs no sign-in, so anyone can make it write contacts to the log. A LOG_FILE is created with the process's default permissions, often readable by every local user. Treat the log as personal data: restrict who reads it, and keep it for a short time.
Third-Party Integrations
OAuth / Social Login
Leaving a provider’s CLIENT_ID blank disables it cleanly — its endpoints return a "provider not configured" error instead of attempting an outbound call. Social sign-in never creates an account: it reaches an existing one — by its <provider>_<id> shortname, by a provider id linked at an earlier sign-in, or the first time by a verified email — and is refused when none matches.
| Setting | Description | Default |
|---|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_OAUTH_CALLBACK |
Google OAuth credentials + redirect URI. The callback is the absolute URL of /user/google/callback, registered with Google as an authorized redirect URI; without it the web sign-in answers "not configured". |
"" |
FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET / FACEBOOK_OAUTH_CALLBACK |
Facebook OAuth credentials + redirect URI. The callback is the absolute URL of /user/facebook/callback, registered with Facebook as a valid OAuth redirect URI; without it the web sign-in answers "not configured". |
"" |
FACEBOOK_GRAPH_VERSION |
Graph API version every Facebook call and the login dialog use. Meta retires each version two years after its release. | "v26.0" |
FACEBOOK_LINKS_BY_EMAIL |
Let the email Facebook returns link the DMART account that carries it. Off by default, since Facebook marks no address as verified the way Google and Apple do: Facebook then reaches only an account already linked to it, or its facebook_<id> shortname. |
false |
APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_P8_PRIVATE_KEY / APPLE_OAUTH_CALLBACK |
Apple Sign In. The mobile id-token flow needs only APPLE_CLIENT_ID; the web callback needs all five. The client secret is a short-lived ES256 JWT that DMART mints on the fly from the .p8 key. |
"" |
OAUTH_TIMEOUT |
Seconds each outbound call to a provider may take. A sign-in makes up to three. | 10 |
Embeddings (semantic search, opt-in)
When the pgvector extension is installed and EMBEDDING_API_URL is set, DMART embeds every entry on create/update and exposes POST /managed/semantic-search plus the dmart_semantic_search MCP tool. The endpoint is OpenAI-compatible.
| Setting | Description | Default |
|---|---|---|
EMBEDDING_API_URL |
Embeddings endpoint. Empty keeps semantic search off. | "" |
EMBEDDING_API_KEY |
Bearer token for the embeddings endpoint | "" |
EMBEDDING_MODEL |
Model name sent in the request body | "text-embedding-3-small" |
EMBEDDING_TIMEOUT |
Seconds one embedding call may take. A semantic search that runs out fails; a write still succeeds, leaving the entry unembedded. | 10 |
Directory: LDAP, Replicas and OpenID Connect
DMART can serve its users as a read-only LDAP directory, keep a replica of another instance's users, and act as an OpenID Connect provider. All three are off by default.
| Setting | Description | Default |
|---|---|---|
LDAP_PORT / LDAPS_PORT |
Plain LDAP (with StartTLS once a certificate is set) and implicit-TLS ports. 0 = off. | 0 / 0 |
LDAP_HOST |
Address the LDAP listeners bind to | "127.0.0.1" |
LDAP_TLS_CERT_FILE / LDAP_TLS_KEY_FILE |
PEM certificate chain and key; re-read when they change on disk | "" |
LDAP_TRUSTED_PEERS |
Addresses of the deployment's own directory clients: exempt from the failed-bind limit, and may bind over cleartext | "127.0.0.0/8,::1" |
LDAP_BASE_DN |
Base of the tree: users under ou=people, groups under ou=groups |
"dc=dmart" |
LDAP_SERVICE_ACCOUNTS |
Bot users that may bind as cn=<name>,ou=services and read the whole directory |
"" |
LDAP_EXTRA_USER_OBJECT_CLASSES |
objectClass values added to every user entry |
"" |
LDAP_SIZE_LIMIT / LDAP_MAX_SCAN |
Most entries an unpaged search returns / most rows a search on an unindexed attribute examines | 500 / 100000 |
USER_SERVICES |
Services a user can be granted (e.g. mail,matrix). Empty accepts any lowercase name. |
"" |
USER_MAIL_DOMAINS |
Mail domains a user's mailbox and aliases must be in. Empty accepts any. | "" |
USER_SERVICE_GRANTERS |
service:role pairs naming who may grant each service. A service with no pair is global-admin only. |
"" |
DIRECTORY_FEED_READERS |
On a primary: bot users that may read /managed/directory-feed, password hashes included. Empty turns the feed off. |
"" |
DIRECTORY_REPLICA_OF |
On a replica: the primary's base URL. Empty = not a replica. | "" |
DIRECTORY_REPLICA_SHORTNAME / DIRECTORY_REPLICA_PASSWORD |
The bot account the replica signs in to the primary as | "" |
DIRECTORY_REPLICA_INTERVAL_SECONDS |
Seconds between polls of the primary | 30 |
DIRECTORY_REPLICA_MAX_STALENESS_HOURS |
Hours a replica keeps answering binds after its last good sync. 0 = no limit. | 24 |
OIDC_ISSUER |
Public base URL of the OpenID Connect provider, https unless it is a loopback address. Empty = off. |
"" |
OIDC_SIGNING_KEY_FILE |
PEM RSA key that signs ID and access tokens; created on first start when missing | "" |
OIDC_CLIENTS_FILE |
JSON file of relying parties (see below); re-read when it changes | "" |
OIDC_TOKEN_SECONDS |
Lifetime of ID and access tokens, 60–86400 | 3600 |
A user's mailbox, mail_aliases and services are described under Mailbox, Aliases and Services.
The LDAP face
- Read-only. Every write, and the Password Modify operation, is refused with
unwillingToPerform; users change through DMART. Only LDAPv3 simple binds are accepted, not SASL. - The tree. Users are
uid=<shortname>,ou=people,<base>, matched whatever the case. Groups arecn=<group>,ou=groups,<base>(groupOfNames). Service accounts bind ascn=<name>,ou=services,<base>. A user entry carriescn,sn,givenName,displayName,mail,mailAlias,mobile,authorizedService,entryUUIDandmemberOf, among others. - Who reads what. An anonymous bind reads only the root DSE and the schema. A user bound with their password reads their own entry. Only the
LDAP_SERVICE_ACCOUNTS, which must be bots binding fromLDAP_TRUSTED_PEERS, read the whole directory. - TLS. Once a certificate is set, a password bind over a connection still in cleartext gets
confidentialityRequired(13), except from the trusted peers. - Connections. At most 32 are open from one address outside the trusted peers, and 512 in all. One that has not bound idles out after 30 seconds, and a bound one after 15 minutes. A connection bound with a password is checked again every 5 minutes, and drops to anonymous after a password change or reset, the end of the account's sessions, or its deactivation, so a long-lived client must be ready to bind again.
- Failed binds. An address outside the trusted peers gets
AUTH_RATE_LIMIT_PER_MINUTEfailed binds a minute. While binds still being checked hold all of those that remain, a new bind waits up to 10 seconds. Once the minute's failures are spent, every bind getsbusy(51) until the minute ends. A failed bind counts toward the account's lockout, except one that repeats one of the account's last two wrong passwords within the hour, which is how a device with a stale saved password looks. - Configuration.
LDAP_HOSTmust be an IP address. List the deployment's own directory clients, such as Postfix, Dovecot, webmail, Gitea and Dex, inLDAP_TRUSTED_PEERS: they bind for every user from one address.
Directory replicas
- Before the first sync, a replica answers every LDAP lookup and bind with
unavailable(52), so a mail server defers mail rather than bouncing it./health/readyanswers 503 until then, and reportsdirectory_replica_synced_atonce ready. - Once its copy is older than
DIRECTORY_REPLICA_MAX_STALENESS_HOURS, binds answerunavailable, and lookups keep working. - Lockout counters stay local to the replica, and a bind there does not upgrade an imported
{SSHA}hash. The primary upgrades it at the owner's next sign-in on the primary, and the replica picks that up. - Users edited on the replica are overwritten from the primary. Keep the replica's own HTTP API on loopback.
OpenID Connect clients
OIDC_CLIENTS_FILE holds an object with a clients list:
{"clients": [
{"client_id": "gitea", "client_secret": "<32 or more random characters>", "name": "Gitea",
"redirect_uris": ["https://git.example.com/user/oauth2/dmart/callback"], "services": ["gitea"]},
{"client_id": "cli", "name": "A native app",
"redirect_uris": ["http://127.0.0.1:8765/callback"]}
]}
- Keys are
client_id,client_secret,name,redirect_urisandservices. Any other key fails the load. - A secret has at least 16 characters. A client without one is public, and must use PKCE with
S256. - Redirect URIs match exactly, and must be https, or http on a loopback address, with no fragment.
servicesgates who may sign in: a user must hold one of them. Empty admits any active user.- A bad file. A reload that fails keeps the previous list and logs why; at startup, a bad file stops the server.
With OIDC_ISSUER set, startup also requires OIDC_SIGNING_KEY_FILE, whose directory must exist (the key is created on first start), and an OIDC_CLIENTS_FILE that exists.
Channel Authentication
| Setting | Description | Default |
|---|---|---|
ENABLE_CHANNEL_AUTH |
Gate requests on the x-channel-key header |
false |
CHANNELS_CONFIG_PATH |
Path to the channels JSON file (empty = ~/.dmart/channels.json) |
"" |
The channels file is a JSON array:
[
{"name": "mobile_app", "keys": ["<random key>"], "allowed_api_patterns": ["^/user/", "^/public/"]}
]
- Patterns are regular expressions, matched anywhere in the path and ignoring case. Each is tried on the path as sent and without its trailing slash; for a request with no key, also with doubled slashes collapsed. A match is cut off after 100 ms and counts as no match. A pattern that does not compile is logged and dropped.
- With
ENABLE_CHANNEL_AUTH=true:- a request with no
x-channel-keyis refused with 403 when any channel's pattern matches its path, and passes otherwise; - a key that no channel lists is refused with 403;
- a known key passes only on a path one of its channel's patterns matches.
- a request with no
/healthis never gated.- A missing or invalid file means no channels: requests without a key pass, and every request with one is refused.
Miscellaneous
| Setting | Description | Default |
|---|---|---|
ENFORCE_FOLDER_CONTENT_POLICY |
Enforce a folder’s content-policy arrays on create/update/move. False = dry-run (violations warn-logged but allowed). | true |
ENABLE_INNER_JOIN_PUSHDOWN |
Push eligible inner joins into SQL as correlated EXISTS semi-joins. False forces the in-memory fallback. | true |
ENABLE_MCP |
Expose the Model Context Protocol surface: /mcp, the OAuth 2.1 authorization server (/oauth/*) and its /.well-known/oauth-* discovery documents. False leaves all of them unmapped (INVALID_ROUTE, HTTP 422). |
false |
IMPORT_MAX_ENTRIES |
Reject zip imports declaring more than this many entries (decompression-bomb guard). 0 disables. | 500000 |
IMPORT_MAX_UNCOMPRESSED_BYTES |
Reject zip imports whose uncompressed size exceeds this, declared or as actually unpacked. 0 disables. | 2147483648 (2 GiB) |
PARQUET_ROW_GROUP_ROWS |
Rows per row group in a Parquet export | 50000 |
PARQUET_ATTACHMENT_PAGE_SIZE / PARQUET_HISTORY_PAGE_SIZE |
Attachments / history rows fetched per page during a Parquet export | 500 / 10000 |
PARQUET_BULK_BATCH_SIZE |
Rows per bulk batch when restoring a Parquet export | 10000 |