Open with AI
Configuration
This document is the complete reference for the Issuerd server configuration
file (issuerd.toml). It is written for system operators and administrators
who deploy and run Issuerd. Every key, default, and behavior described here is
implemented in crates/issuerd-server/src/config.rs and the daemon startup path
(src/main.rs, crates/issuerd-server/src/state.rs). For first-time setup see
getting-started.md; for realm/client content as code see
provisioning.md.
Contents
Section titled “Contents”- How configuration is loaded
- Minimal configurations
- Root keys
- [storage]
- [tls]
- [logging]
- [web_ui]
- [proxy]
- [cors]
- [cluster]
- [cache]
- [themes]
- [smtp]
- Complete annotated example
- Generating the example config
How configuration is loaded
Section titled “How configuration is loaded”Configuration is assembled from three layers, later layers winning:
-
Built-in defaults — every key has one (see the reference tables below). With no file and no environment, the daemon boots on in-memory storage, plain HTTP on
0.0.0.0:8080. -
Config file — passed with
-c/--config:Terminal window issuerd daemon -c /etc/issuerd/issuerd.tomlThe default path is
issuerd.toml, resolved against the daemon’s working directory. The same flag exists onissuerd provision(which reads the config only for the storage connection andissuer_url). The file format is inferred from the extension:.toml,.yaml/.yml, or.json(an unknown or missing extension is parsed as TOML). The examples in this document use TOML. -
Environment overrides — every variable prefixed with
ISSUERD_is merged on top of the file.__(double underscore) is the nesting separator and variable names are lowercased before mapping:Environment variable Overrides ISSUERD_PORT=9090portISSUERD_BIND=127.0.0.1bindISSUERD_ISSUER_URL=https://idp.example.comissuer_urlISSUERD_REDIS=redis://redis:6379redisISSUERD_CLUSTER__ENABLED=truecluster.enabledISSUERD_CLUSTER__JWKS_REFRESH_INTERVAL_SECS=15cluster.jwks_refresh_interval_secsISSUERD_CACHE__READ_CACHE_TTL_SECS=0cache.read_cache_ttl_secsISSUERD_SMTP__ENABLED=true/ISSUERD_SMTP__HOST=mail.example.comsmtp.enabled/smtp.hostISSUERD_PROXY__TRUST_X_FORWARDED_FOR=falseproxy.trust_x_forwarded_forThe shipped cluster stack uses this mechanism for per-node identity (
ISSUERD_CLUSTER__NODE_IDindocker-compose.cluster.yml), so the same file can be mounted into every node.
Warning: list-valued keys (
cors.allowed_origins,proxy.trusted_proxies,cluster.redis_nodes) cannot be expressed reliably as a single environment variable — set them in the config file (the cluster guide documentscluster.redis_nodesas TOML-only, see CLUSTERING.md).
Behavioral notes:
- Missing file is not an error. If the
-cpath does not exist, it is silently skipped and the daemon runs on defaults + environment. A typo in the path therefore boots a default server instead of failing — check theloaded configurationstartup log line when in doubt. - Invalid content fails the boot. A file that parses but has the wrong
type for a key (e.g.
port = "abc") aborts startup withconfig load failed: .... - Unknown keys are ignored. Misspelled keys do not cause an error — they silently have no effect.
- Environment overrides apply to every subcommand that loads configuration
(
daemon,provision).
Minimal configurations
Section titled “Minimal configurations”Development / evaluation (in-memory, zero dependencies):
# Everything else takes defaults: in-memory storage, plain HTTP on# 0.0.0.0:8080, no Redis, no TLS.issuer_url = "http://localhost:8080"The root issuerd.toml in the repository is a working example of this rig.
With in-memory (or JSON-file) storage the master realm is bootstrapped
automatically on first start (admin user admin/admin); see
getting-started.md.
Production (PostgreSQL + Redis, TLS terminated at a proxy):
bind = "0.0.0.0"port = 8080issuer_url = "https://idp.example.com" # public URL, as clients reach the proxy
redis = "redis://redis:6379"
[storage.postgres]url = "postgres://issuerd:SECRET@postgres:5432/issuerd"
[proxy]trusted_proxies = ["10.0.0.0/8"] # your load balancer / ingress subnetModeled on examples/issuerd.demo.toml (the demo stack’s production-shaped
config). With PostgreSQL, automatic master-realm bootstrap does not run —
define master in a provision file (see examples/provision.demo.yaml and
provisioning.md) or create it via the Admin API.
Root keys
Section titled “Root keys”| Key | Type | Default | Description |
|---|---|---|---|
bind |
string (IP literal) | "0.0.0.0" |
Interface to bind. Parsed as an IP literal, so IPv6 ("::", "::1") works. A name or invalid literal fails the boot. |
port |
integer | 8080 |
TCP port for HTTP (or HTTPS when [tls] is set). |
issuer_url |
string (URL) | "http://localhost:8080" |
Public base URL of this server. See the deep-dive below. |
redis |
string (URL), optional | unset | Single Redis node, e.g. "redis://localhost:6379" (rediss:// for TLS). Unset ⇒ in-process ephemeral cache. Overridden by cluster.redis_nodes when that list is non-empty. |
provision |
path, optional | unset | Provision file (YAML/TOML/JSON by extension) applied exactly once at startup — a marker is claimed in storage before applying, so restarts and concurrent first boots skip it. Relative paths resolve against the working directory. A missing/unreadable file logs an error and boot continues. Format: provisioning.md. |
About issuer_url
Section titled “About issuer_url”This is the most consequential value in the file:
- It must be an absolute
http(s)URL — anything else aborts the boot withissuer_url must be an absolute http(s) URL. - It is baked into every token’s
issclaim and into the OIDC discovery document as{issuer_url}/realms/{realm-name}(the realm name, never the internal UUID). Clients validateissstrictly, soissuer_urlmust be exactly how browsers and applications reach the server — scheme, host, and port included. If you terminate TLS at a proxy,issuer_urlkeeps the publichttpsscheme even though the daemon itself serves plain HTTP. - In a cluster it must be the load balancer’s URL and identical on every node (see CLUSTERING.md).
- Changing it later effectively changes every realm’s issuer: outstanding tokens, sessions, and stored client configurations that reference the old issuer stop validating, and users must re-authenticate. Treat it as permanent once realms are in use.
[storage]
Section titled “[storage]”The persistent store for realms, users, clients, sessions, signing keys, and events. Exactly one variant is active; the default is in-memory (no section at all).
| Variant | TOML shape | Durability | Use for |
|---|---|---|---|
| In-memory (default) | (omit the section) | None — empty on every boot | Development, tests, demos |
| PostgreSQL | [storage.postgres] + url |
Durable, shared between nodes | Production, clusters |
| JSON file | [storage.json_file] + path |
Durable, single node | Manual testing, small single-node rigs |
# PostgreSQL — recommended for production.[storage.postgres]url = "postgres://issuerd:issuerd_secret@localhost:5432/issuerd"- Schema migrations are applied automatically at boot (and by the
issuerd provisionsubcommand), so upgrades only require starting the new binary. See backup-and-upgrade.md. - This is the only backend that supports multi-node clusters (see
[cluster]) — in a cluster, token issuance is shared between
nodes via the shared
signing_keystable.
# JSON file — full-state snapshot persistence.[storage.json_file]path = "issuerd-data.json"- The entire state is loaded from the file at boot and rewritten in full on every write, serialized through a global lock — correct but slow, so it is meant for manual testing and single-node rigs, not production traffic.
- Like in-memory, it triggers the automatic master-realm bootstrap on first start.
Note: with in-memory storage, JWT signing keys are generated per boot, so all outstanding tokens are invalidated by a restart. The JSON-file backend persists them in its snapshot; with PostgreSQL they live in the shared
signing_keystable — both survive restarts and redeploys.
Optional. When present, the daemon itself serves HTTPS via rustls (TLS 1.2+); when absent, it serves plain HTTP.
| Key | Type | Description |
|---|---|---|
cert_path |
path (PEM) | Certificate chain file. |
key_path |
path (PEM) | Private key file. |
[tls]cert_path = "certs/idp.example.com.crt"key_path = "certs/idp.example.com.key"Both files are read at startup; unreadable or unparsable PEM fails the boot. Paths are resolved against the working directory.
In most production deployments it is simpler to terminate TLS at a reverse
proxy or load balancer and run Issuerd on plain HTTP behind it. If you do,
remember that issuer_url still uses the public https scheme, and configure
[proxy] so the daemon sees real client IPs. See
deployment.md and security.md.
[logging]
Section titled “[logging]”| Key | Type | Default |
|---|---|---|
format |
string | "pretty" |
level |
string | "info" |
level is one of error, warn (alias warning), info, debug,
trace. format is
pretty (human-readable console text) or json (one JSON object per event,
for log shippers). Unparsable values fall back to the defaults with a WARN at
startup.
The effective log level is resolved with this precedence:
RUST_LOG— when set, it wins completely. Any standardtracingEnvFilter directive string is accepted, e.g.RUST_LOG=issuerd_server=debug,issuerd_storage=warnfor per-crate control.-v/-qCLI flags (global, repeatable): default isinfo;-v→debug;-vv(or more) →trace;-q→warn;-qq(or more) →error.Terminal window issuerd -v daemon -c issuerd.toml[logging].levelfrom the config file.
The flag/config level is applied to Issuerd’s own crates (issuerd,
issuerd_server, issuerd_core, issuerd_auth_flow, issuerd_protocol, issuerd_token,
issuerd_storage, issuerd_cluster, issuerd_admin_api, issuerd_federation); use RUST_LOG
to also tune dependencies such as sqlx or tower.
Notes on output:
formatapplies to the console output only. On Linux under systemd (whenJOURNAL_STREAMis set) logs go to journald in its own structured format instead; if journald initialization fails, the daemon prints a notice to stderr and falls back to console formatting.- ANSI colors are emitted only when stdout is a terminal, so piped and container logs stay free of escape codes. (On Windows the console ANSI support is enabled explicitly at startup.) The JSON formatter never emits ANSI.
- Records from dependencies that log through the
logfacade (ldap3, redis, reqwest, rustls, sqlx, …) are bridged into tracing and filtered by the same directives.
See monitoring.md for metrics and health endpoints.
[web_ui]
Section titled “[web_ui]”| Key | Type | Default |
|---|---|---|
enabled |
boolean | true |
Warning: this key is currently parsed but not consulted. The consoles are served whenever the SPA assets were embedded into the binary at build time — release builds embed them — and there is no runtime switch to turn them off.
The served surfaces are the admin console at /admin/console and the account
console at /realms/{realm}/account. If the assets were not embedded (a
debug build without webclientsrc/dist), those paths answer 404; the Admin
API under /admin/... and all protocol endpoints are unaffected either way.
[proxy]
Section titled “[proxy]”Controls how the daemon determines the real client IP when it sits behind a reverse proxy or load balancer.
| Key | Type | Default | Description |
|---|---|---|---|
trusted_proxies |
list of strings | [] |
IPs or CIDR ranges of your proxies, e.g. ["10.0.0.1", "172.32.0.0/24"]. Forwarded headers are honored only when the direct peer matches an entry. |
trust_x_forwarded_for |
boolean | true |
Honor X-Forwarded-For from trusted peers. Within the header, the rightmost entry that is not itself a trusted proxy wins (entries to its right were appended by proxies you trust). |
trust_x_real_ip |
boolean | true |
Honor X-Real-IP from trusted peers (used when XFF yields nothing). |
[proxy]trusted_proxies = ["172.32.0.0/24"] # subnet of your LB / ingresstrust_x_forwarded_for = truetrust_x_real_ip = trueWith the default empty trusted_proxies, the trust flags are inert: every
forwarded header is ignored and the direct peer’s address is used — the safe
behavior when no proxy is in front.
Warning: getting this wrong breaks brute-force protection. Login-failure counters are keyed by client IP (
login-failure:{realm}:{username}:{ip}). Iftrusted_proxiesis empty behind an LB, every user appears to come from the LB’s address, so per-IP lockouts aggregate all users into one bucket; if you trusted headers from untrusted peers, attackers could spoof their IP to dodge lockout. List exactly the proxy subnets. See security.md and the LB requirements in CLUSTERING.md.
[cors]
Section titled “[cors]”| Key | Type | Default |
|---|---|---|
allowed_origins |
list of strings | [] |
Cross-origin browser access is fully locked down by default: with an empty list the server emits no CORS headers, so browsers block all cross-origin calls. The embedded admin and account consoles are same-origin and need no CORS; non-browser clients (service-to-service) are unaffected.
Add exact origins — scheme + host + port, no wildcards — only for browser applications hosted on other origins:
[cors]allowed_origins = ["https://app.example.com", "https://spa.internal:3443"]Allowed methods are GET, POST, PUT, PATCH, DELETE, OPTIONS and allowed
headers are Authorization and Content-Type. Entries that are not valid
header values are skipped with a startup warning. Cross-origin applications
also need the origin in their client registration (web_origins); see
client-integration.md.
[cluster]
Section titled “[cluster]”Opt-in multi-node mode. The full guide, including the load balancer contract and the Docker Compose demo, is CLUSTERING.md; this section is the key reference.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Enforce the multi-node contract at boot (below). |
node_id |
string, optional | $HOSTNAME, else a random id |
Stable node identity, used for log correlation only. |
redis_nodes |
list of strings | [] |
Redis Cluster node URLs, e.g. ["redis://r1:6379", "redis://r2:6379"]. When non-empty, overrides the single-node redis URL. |
jwks_refresh_interval_secs |
integer | 30 |
How often the node re-reads the shared signing-key set from storage and reloads its keystore + JWKS snapshot, so keys rotated by peers propagate without a restart. |
[cluster]enabled = truenode_id = "node-1"redis_nodes = []jwks_refresh_interval_secs = 30Boot-time contract: with enabled = true, boot fails unless storage
is PostgreSQL and a Redis cache is configured (redis or
cluster.redis_nodes), with the error
cluster.enabled requires PostgreSQL storage and a Redis cache (set `redis` or `cluster.redis_nodes`).
This refuses
split-brain configurations where, for example, an authorization code issued on
node A could not be redeemed on node B. The JWKS refresh task only runs with
PostgreSQL storage — the other backends are single-node by definition and have
nothing to poll.
[cache]
Section titled “[cache]”One knob governs every read-model cache on the token hot paths.
| Key | Type | Default | Description |
|---|---|---|---|
read_cache_ttl_secs |
integer | 60 |
TTL in seconds of every read-model cache entry. 0 disables all read-model caches (every lookup hits storage — pre-cache behavior). |
The read model keeps per-request work off the database: session-validity
snapshots behind userinfo/introspect, the realm-by-name resolution cache,
the claims read model (per-user claims bundles, the realm role/scope catalog,
client bundles and default scope assignments), and the rendered
userinfo/discovery response caches.
Mutations through the Admin API and the server’s own write paths invalidate
precisely — single-entity changes delete their keys, realm-wide definition
changes bump an epoch — so committed writes are visible immediately. A write
that bypasses both (e.g. a direct database edit) stays hidden for at most
read_cache_ttl_secs: the consciously accepted bounded-staleness window, the
same class as Keycloak’s Infinispan propagation. Set 0 for pure-DB behavior
(useful in tests, or when debugging unexpected staleness).
[themes]
Section titled “[themes]”| Key | Type | Default |
|---|---|---|
dir |
path | "themes" |
Root directory of login-theme assets, one subdirectory per theme. Relative
paths resolve against the daemon’s working directory. The repository ships the
built-in default theme at themes/issuerd.
- A realm’s
login_themesetting (realm configuration in the admin console / Admin API — see administration.md) selects the directory; assets are served at/realms/{realm}/theme/{path}. - Fallback is per file: anything a custom theme does not provide is served
from the built-in
issuerdtheme, so a custom theme only ships the files it overrides (e.g. justlogin.css). - A theme may also carry
messages_{locale}.jsonbundles that merge over the built-in login/email translations.
[smtp]
Section titled “[smtp]”Email is used for address verification, password reset, execute-actions emails, and the optional email-code login flow.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch. |
host |
string | "127.0.0.1" |
SMTP server hostname/IP. |
port |
integer | 25 |
SMTP server port. |
from |
string | "issuerd@localhost" |
Envelope/header sender address. |
from_display |
string, optional | unset | Sender display name. |
reply_to |
string, optional | unset | Reply-To address. |
starttls |
boolean | false |
Upgrade the connection with STARTTLS. |
ssl |
boolean | false |
Implicit TLS (SMTPS) from connect. |
username / password |
string, optional | unset | SMTP AUTH credentials. |
[smtp]enabled = truehost = "mail.example.com"port = 587from_display = "Example ID"starttls = trueusername = "issuerd"password = "SECRET" # prefer ISSUERD_SMTP__PASSWORD over committing thisDisabled-by-default semantics: with enabled = false the server wires a
no-op sender, and any flow that needs to send mail fails loudly with
SMTP is not configured: set [smtp] enabled = true ... rather than silently
dropping the message. Do not turn on realm email features (email verification,
reset credentials, email-code login) before SMTP works, or users will hit that
error at login/registration.
Per-realm overrides. Following the Keycloak model, a realm can override
every SMTP value except enabled (which stays a global gate) through realm
attributes named smtpServer.*, set via the Admin API / console (see
administration.md):
| Realm attribute | Overrides config key |
|---|---|
smtpServer.host |
host |
smtpServer.port |
port |
smtpServer.from |
from |
smtpServer.fromDisplayName |
from_display |
smtpServer.replyTo |
reply_to |
smtpServer.starttls |
starttls ("true" / anything-else) |
smtpServer.ssl |
ssl |
smtpServer.user |
username |
smtpServer.password |
password |
Note the attribute names are camelCase and the credential attribute is user,
not username. Realms without these attributes use the global config.
Complete annotated example
Section titled “Complete annotated example”This is examples/issuerd.example.toml with every key explained. It is a
reference, not a runnable default — it assumes PostgreSQL, Redis, and
certificate files exist. Generate a fresh copy with the CLI (next section).
# ---- Root keys -------------------------------------------------------------bind = "0.0.0.0" # listen interface (IP literal; "::" for IPv6 any)port = 8080 # listen portissuer_url = "http://localhost:8080" # PUBLIC base URL; baked into iss — see "About issuer_url"redis = "redis://localhost:6379" # ephemeral state: auth codes, pending flows, revocation, login-failure countersprovision = "provision.yaml" # applied exactly once at first startup; remove to start empty
# ---- TLS -------------------------------------------------------------------# Omit the whole section for plain HTTP (e.g. behind a TLS-terminating proxy).[tls]cert_path = "certs/issuerd.test.internal.crt" # PEM certificate chainkey_path = "certs/issuerd.test.internal.key" # PEM private key
# ---- Storage ---------------------------------------------------------------# Omit [storage] entirely for the in-memory default (dev only, empty per boot).# Alternative: [storage.json_file] path = "issuerd-data.json" (single-node).[storage.postgres]url = "postgres://issuerd:issuerd_secret@localhost:5433/issuerd"# Migrations run automatically at boot. With PostgreSQL, create the master# realm via the provision file — automatic bootstrap only covers in-memory/json.
# ---- Logging ---------------------------------------------------------------# Honored at startup; RUST_LOG and -v/-q flags take precedence (see [logging]).[logging]format = "pretty"level = "info"
# ---- Web UI ----------------------------------------------------------------# Parsed but currently NOT honored — consoles are served when embedded at build.[web_ui]enabled = true
# ---- Reverse proxy ---------------------------------------------------------[proxy]trusted_proxies = [] # LB/ingress IPs or CIDRs, e.g. ["10.0.0.0/8"]; required to honor the headers belowtrust_x_forwarded_for = truetrust_x_real_ip = true
# ---- CORS ------------------------------------------------------------------[cors]allowed_origins = [] # empty = browsers blocked cross-origin; add "https://app.example.com" style origins
# ---- Cluster ---------------------------------------------------------------[cluster]enabled = false # true REQUIRES [storage.postgres] + redis (boot fails otherwise)redis_nodes = [] # Redis Cluster URLs; non-empty overrides the `redis` key abovejwks_refresh_interval_secs = 30 # signing-key set polling interval (PostgreSQL only)# node_id = "node-1" # optional; defaults to $HOSTNAME; log correlation only
# ---- SMTP ------------------------------------------------------------------# Disabled by default; email flows fail loudly until enabled and reachable.[smtp]enabled = falsehost = "127.0.0.1"port = 1025 # 25 default; 1025 here matches a local Mailpit-style sinkfrom_display = "Issuerd"starttls = falsessl = false# reply_to = "[email protected]"# username = "issuerd"# password = "SECRET"
# ---- Cache ------------------------------------------------------------------[cache]read_cache_ttl_secs = 60 # read-model cache TTL; 0 disables all read-model caches (pure-DB behavior)
# ---- Themes -----------------------------------------------------------------[themes]dir = "themes" # one subdirectory per login themeGenerating the example config
Section titled “Generating the example config”The binary writes a fully populated example itself — the committed
examples/issuerd.example.toml is produced the same way, and a unit test in
crates/issuerd-server/src/config.rs verifies it stays loadable:
# TOML (extension chooses the format: .toml, .yaml/.yml, .json)issuerd example server-config -o my.toml
# YAMLissuerd example server-config -o my.yaml
# The provision file has a generator too (see provisioning.md)issuerd example provision-config -o provision.yamlThe generated file enables TLS and PostgreSQL with the same placeholder values
shown above — treat it as a checklist to edit, not as-is configuration. The
-o default is examples/issuerd.example.toml (the repo’s committed
reference), so pass an explicit output path.