# Configuration (/en/docs/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.

## Loading the Config File [#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`:

```rust
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 [#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 [#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] [#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`):

```toml
[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] [#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.

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

### \[server] [#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.

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

### \[frontend] [#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.

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

### \[jwt] [#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.

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

Example key generation (Linux/macOS):

```bash
openssl rand -base64 48
```

### \[observability] [#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 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.

```toml
[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] [#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](/docs/realm-custom-domain) 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:

```bash
openssl rand -hex 32
```

```toml
[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-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.
