Herald

Configuration

Herald manages all runtime configuration through a single TOML file. The file is read once at startup; there is no hot-reload -- changes require a process restart.

Herald manages all runtime configuration through a single TOML file. The file is read once at startup; there is no hot-reload -- changes require a process restart.

Loading the Config File

At startup, Herald determines the config file path in this order:

  1. Read the HERALD_CONFIG environment variable. If set, use its value as the file path.
  2. If not set, fall back to config/config.toml.

The entry point is in backend/app/src/main.rs:

let config_path = env::var("HERALD_CONFIG").unwrap_or("config/config.toml".to_owned());
let config = ApiConfig::load(&config_path)?;

The path can be relative or absolute. Relative paths are resolved against the process working directory, not the directory containing the binary.

Environment Variables

VariableDefaultRequiredDescription
HERALD_CONFIGconfig/config.tomlNoPath to the config file
RUST_LOGSet by server.log_levelNotracing log level; overrides log_level in the config file

RUST_LOG is read at startup via tracing_subscriber::EnvFilter::try_from_default_env(). When set, it takes precedence over the log_level field in the config file. When unset, the config file value is used.

Config File Sections

The Docker image includes a production config (backend/config/production.toml). The "Code Default" column shows what the code uses when a field is omitted from the config file. The "Docker Config Value" column shows the explicit value set in production.toml. Override as needed for local development.

[database]

PostgreSQL connection pool settings. url is the only required field; everything else has a built-in default.

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
urlstringYesPostgreSQL connection string
max_connectionsu3210050NoMaximum number of connections in the pool
acquire_timeout_secsu643010NoTimeout (seconds) waiting to acquire a connection from the pool
idle_timeout_secsu64600300NoHow long an idle connection stays alive (seconds)
max_lifetime_secsu6418001800NoMaximum lifetime of a single connection (seconds)
connect_timeout_secsu64105NoTCP connection establishment timeout (seconds)

Connection string format: postgresql://user:password@host:port/database

In Docker, the host is the container name (e.g., db or herald-postgres):

[database]
url = "postgresql://herald:herald@db:5432/herald"

Tune max_connections based on actual concurrency in production. The pool is implemented through SeaORM's ConnectOptions.

[redis]

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
urlstringredis://127.0.0.1:6379redis://redis:6379NoRedis connection URL

Redis is used for permission-check caching and session storage. In Docker, the host is the container name.

[redis]
url = "redis://redis:6379"

[server]

HTTP server and logging settings.

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
bind_addressstring0.0.0.0:30000.0.0.0:3000NoListen address, format ip:port
log_levelstringinfoinfoNoLog level (trace/debug/info/warn/error)
app_envstringproductionproductionNoRuntime environment identifier

app_env is currently a label only -- the codebase does not branch on it. Using 0.0.0.0 for bind_address means listening on all network interfaces.

[server]
bind_address = "0.0.0.0:3000"
log_level = "info"
app_env = "production"

[frontend]

Settings for the frontend application, primarily affecting CORS and static file serving.

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
urlstringhttp://localhost:5173http://localhost:3000NoFrontend application URL, used for CORS allowlisting
static_dirstringnone (no serving)/app/frontend/distNoPath to a static files directory; when set, the backend serves the SPA

In Docker, url defaults to http://localhost:3000 because the backend serves both API and frontend static files on the same port. Change it to the actual domain (e.g., https://your-domain.com) when deploying with a domain name.

static_dir in the Docker image defaults to /app/frontend/dist, where frontend assets are bundled at build time.

[frontend]
url = "https://your-domain.com"
static_dir = "/app/frontend/dist"

[jwt]

JWT secret key configuration, used to generate access tokens in the device code authorization flow (RFC 8628) and third-party OAuth login flows.

ParameterTypeDefault (Docker)RequiredDescription
secretstringYesJWT signing secret key

The Docker image includes a placeholder value change-me-in-production. You must override this with a secure random string at deploy time.

[jwt]
secret = "your-random-base64-secret-key-here"

Example key generation (Linux/macOS):

openssl rand -base64 48

[observability]

OpenTelemetry observability settings. The whole section is optional: a config file without [observability] still parses and resolves to the code defaults below -- metrics on, traces off.

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
service_namestringherald-apiunsetNoService name reported to OTLP
otlp_endpointstringhttp://localhost:4318unsetNoOTLP/HTTP endpoint; 4318 is the standard OTLP HTTP port
metrics_export_interval_secsu645unsetNoMetrics export interval (seconds)
traces_enabledboolfalseunsetNoWhether trace export is enabled
sqlx_slow_statement_msu64200unsetNoSQL slow-query log threshold (milliseconds); statements above it are logged

Two things to note:

  • Metrics are always on: regardless of traces_enabled, metrics (the OTLP meter provider plus the RED metrics middleware) are always exported to otlp_endpoint. An OTLP collector must be reachable at that endpoint for the metrics to be collected.
  • Traces are off by default: traces_enabled = false is deliberate, to keep trace-export back-pressure off the request path. Set it to true to splice into the tracing registry; traces are flushed on graceful shutdown.

sqlx_slow_statement_ms controls sqlx's slow-statement logging: SQL whose execution time exceeds the threshold is logged, useful for tracking down slow queries.

[observability]
service_name = "herald-api"
otlp_endpoint = "http://localhost:4318"
metrics_export_interval_secs = 5
traces_enabled = false
sqlx_slow_statement_ms = 200

production.toml does not set this section, so Docker runs on the code baseline above. To point at your own collector locally, change otlp_endpoint.

[custom_domain]

Global settings for the per-Realm custom-domain feature. ask_key is a shared secret that gates the reverse-proxy TLS authorization endpoint; cname_target is the Herald-owned hostname tenants must CNAME their custom login domain to. See the custom-domain guide for what these drive.

ParameterTypeCode DefaultDocker Config ValueRequiredDescription
ask_keystring"" (empty)unsetYes (production)Shared secret for the reverse-proxy On-Demand TLS authorization query (X-Herald-Ask-Key header). A non-empty value is required for the server to boot — the startup guard rejects an empty key.
cname_targetstring"" (empty)unsetNoHerald-owned hostname shown to Realm Admins as the CNAME target (e.g. custom.herald.com). Surfaced as cnameTarget in the custom-domain config response.

Both fields are optional at parse time (#[serde(default)]), but ask_key is enforced non-empty at startup on the server-build path. Generate a random secret:

openssl rand -hex 32
[custom_domain]
ask_key = "your-random-shared-secret"
cname_target = "custom.herald.com"

If you are not using custom domains, set ask_key to any non-empty placeholder string to satisfy the boot guard; the authorization endpoint will still reject unregistered hosts.

RBAC Configuration

RBAC policies are not stored in the main config file. They live in the role_policies database table. During initialization, RealmInitializationService creates default roles and permissions for each realm. Permission checks are handled by RedisPermissionChecker, which checks Redis cache first and falls back to PostgreSQL on cache miss.

The permission model uses resource:action pairs (e.g., product:read, device:manage), scoped by realm_id and client_id. Actions have a hierarchy: manage covers view, create, and manage itself; create and view only cover themselves. Custom actions (e.g., admin) only match themselves and don't participate in the hierarchy.

On this page