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:
- Read the
HERALD_CONFIGenvironment variable. If set, use its value as the file path. - 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
| Variable | Default | Required | Description |
|---|---|---|---|
HERALD_CONFIG | config/config.toml | No | Path to the config file |
RUST_LOG | Set by server.log_level | No | tracing 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.
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
url | string | — | — | Yes | PostgreSQL connection string |
max_connections | u32 | 100 | 50 | No | Maximum number of connections in the pool |
acquire_timeout_secs | u64 | 30 | 10 | No | Timeout (seconds) waiting to acquire a connection from the pool |
idle_timeout_secs | u64 | 600 | 300 | No | How long an idle connection stays alive (seconds) |
max_lifetime_secs | u64 | 1800 | 1800 | No | Maximum lifetime of a single connection (seconds) |
connect_timeout_secs | u64 | 10 | 5 | No | TCP 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]
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
url | string | redis://127.0.0.1:6379 | redis://redis:6379 | No | Redis 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.
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
bind_address | string | 0.0.0.0:3000 | 0.0.0.0:3000 | No | Listen address, format ip:port |
log_level | string | info | info | No | Log level (trace/debug/info/warn/error) |
app_env | string | production | production | No | Runtime 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.
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
url | string | http://localhost:5173 | http://localhost:3000 | No | Frontend application URL, used for CORS allowlisting |
static_dir | string | none (no serving) | /app/frontend/dist | No | Path 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.
| Parameter | Type | Default (Docker) | Required | Description |
|---|---|---|---|---|
secret | string | — | Yes | JWT 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.
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
service_name | string | herald-api | unset | No | Service name reported to OTLP |
otlp_endpoint | string | http://localhost:4318 | unset | No | OTLP/HTTP endpoint; 4318 is the standard OTLP HTTP port |
metrics_export_interval_secs | u64 | 5 | unset | No | Metrics export interval (seconds) |
traces_enabled | bool | false | unset | No | Whether trace export is enabled |
sqlx_slow_statement_ms | u64 | 200 | unset | No | SQL 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 tootlp_endpoint. An OTLP collector must be reachable at that endpoint for the metrics to be collected. - Traces are off by default:
traces_enabled = falseis deliberate, to keep trace-export back-pressure off the request path. Set it totrueto 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 = 200production.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.
| Parameter | Type | Code Default | Docker Config Value | Required | Description |
|---|---|---|---|---|---|
ask_key | string | "" (empty) | unset | Yes (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_target | string | "" (empty) | unset | No | Herald-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.