Configuration
Everything configurable in Ursula lives on this page. Ursula is configured by a TOML config file, an optional resource preset, and a small set of CLI overrides, merged in this order:
built-in defaults < preset defaults < config file < CLI overrides
The normal startup command is:
ursula --config /etc/ursula/ursula.toml --node-id 1
With no config file at all, ursula starts a single-node in-memory runtime. That's what Install uses.
CLI flags
| Flag | Default | Notes |
|---|---|---|
--config FILE | searches ./ursula.toml, /etc/ursula/ursula.toml, then user config paths | TOML config file |
--preset PRESET | default when no config file is found, otherwise none | Resource preset: default, dev, tiny, small, standard, or large |
--node-id ID | config file or preset | Overrides raft.node_id |
CLI --node-id intentionally overrides any raft.node_id value in the config file so the same file can be mounted on every node. Helm derives it from the StatefulSet ordinal.
Resource presets
--preset supplies a base configuration before the config file is merged, and config-file values win. Pick by node size:
| Preset | Intended use | Main defaults |
|---|---|---|
default, dev | Local development | Single-node in-memory runtime |
tiny | Memory-bound tests or very small nodes | 64 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 8 MiB hot/admission caps, 64 MiB HTTP in-flight body budget |
small | Small nodes or cost-sensitive tests | 128 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 16 MiB hot/admission caps, 64 MiB HTTP in-flight body budget |
standard | Production baseline | 256 groups, 256 MiB cold cache, 8 MiB cold flushes, 4 cold writes, 64 MiB hot/admission caps, 256 MiB HTTP in-flight body budget |
large | Larger nodes with more cache and write headroom | 512 groups, 512 MiB cold cache, 16 MiB cold flushes, 8 cold writes, 128 MiB hot/admission caps, 512 MiB HTTP in-flight body budget |
Server and runtime
| Config key | Default | Purpose |
|---|---|---|
server.listen | 127.0.0.1:4437 | Client/API listener |
server.cluster_listen | none | Optional separate Raft/cluster listener |
runtime.core_count | available_parallelism | Worker threads, each pinned to its mailbox event loop |
runtime.live_read_max_waiters_per_core | 65536 | SSE waiter cap per core. 0 disables the limit |
observability.tokio_console | false | Enables Tokio console when the binary is built with the feature |
Without server.cluster_listen, server.listen serves both client/API and Raft/cluster routes, and each [[raft.peers]].url should point at that address. With server.cluster_listen set, server.listen is client/API-only and peer URLs must point at the cluster listener instead. Note that the same peer URL is currently also used for HTTP leader redirects, so it must be reachable by clients or the gateway as well as by peers.
Raft
| Config key | Default | Purpose |
|---|---|---|
raft.node_id | none | This node's stable ID, usually overridden per host with --node-id |
raft.group_count | core_count × 16 | Total Raft groups. Higher values improve stream-to-group hash spread |
raft.wal.backend | memory | memory (volatile, survives no restart) or disk (durable, required for clusters) |
raft.wal.path | none | Required for backend = "disk". Raft logs live under PATH/raft-log, and removing the directory wipes state cleanly |
raft.peers | empty | Static gRPC peer list, with one [[raft.peers]] entry per voter, including this node |
raft.init_membership_per_group | false | One-time per-group membership bootstrap. Set true on the very first start of a fresh cluster, then flip back to false |
Pick one WAL backend per cluster and use it on every peer.
Cold storage
Ursula keeps recent data in an in-memory hot ring on every replica and flushes older segments and snapshot blobs to a cold backend. Multi-node clusters should use S3 or an S3-compatible object store so every replica can read chunks flushed by any leader. All replicas must point at the same shared bucket.
[storage.cold]
backend = "s3"
root = "ursula-prod-20260518"
[storage.cold.s3]
bucket = "my-ursula-bucket"
region = "us-east-1"
# endpoint = "http://127.0.0.1:9000"
# access_key_id = "AKIA..."
# secret_access_key = "..."
# session_token = "..."
# server_side_encryption = "aes256" # default; "aws-kms" or "none"
# kms_key_id = "arn:aws:kms:..." # customer managed key for "aws-kms"
When access_key_id and secret_access_key are omitted, Ursula uses the standard AWS SDK credential chain (instance profile, environment, profile, and so on). Prefer that over static keys. Set endpoint for S3-compatible stores such as MinIO, R2, or TOS.
Every cold-tier object write requests server-side encryption by default (server_side_encryption = "aes256", i.e. SSE-S3) — free on AWS S3, and Raft snapshot uploads inherit the same setting. aws-kms switches to SSE-KMS, using the AWS managed key unless kms_key_id names a customer managed key. MinIO honors SSE headers only when a KMS/KES is configured; for a MinIO deployment without one, set server_side_encryption = "none" explicitly or writes fail with an SSE error.
| Config key | Default | Purpose |
|---|---|---|
storage.cold.backend | none | none, memory, or s3 |
storage.cold.root | none | Prefix prepended to every cold key. Use a date-stamped value for benchmark runs so cleanup can't touch production data |
storage.cold.s3.bucket | none | Required when backend = "s3" |
storage.cold.s3.server_side_encryption | aes256 | SSE mode for every object write: aes256 (SSE-S3), aws-kms, or none |
storage.cold.s3.kms_key_id | none | Customer managed KMS key; only valid with aws-kms |
storage.cold.flush_interval | 1s | Background flush worker tick interval |
storage.cold.flush_size | 8MiB | Target bytes flushed per group per tick |
storage.cold.flush_max_concurrency | 4 | Parallel cold writes in flight |
storage.cold.max_hot_size_per_group | 64MiB | Backpressure ceiling: when a group's hot bytes exceed this, new writes get HTTP 503 until the flush catches up. Explicit 0 disables the cap |
Flush defaults are conservative, so tune them under load. A typical benchmark profile drops the interval to 200ms, raises concurrency to 32, and bumps the per-group ceiling. Advanced deployments can split the flush threshold and batch size with storage.cold.flush_min_hot_size and storage.cold.flush_max_size, but the single storage.cold.flush_size knob is the normal path. Once a group's accumulated hot payload reaches the threshold, Ursula packs eligible slices from that group into one immutable object up to the configured maximum; large single-stream slices keep the same direct-object layout.
Raft snapshotting does not force a partial cold flush. Payload below the cold threshold stays in the bounded hot tail and is included in the next group snapshot, so a short snapshot interval does not turn small tails into small cold objects.
Helm and the EC2 scripts map their deployment inputs onto these same keys, so this table applies to every deployment method.
Complete examples
Disk-backed single node (streams survive a restart):
[server]
listen = "127.0.0.1:4437"
[raft]
node_id = 1
group_count = 16
[raft.wal]
backend = "disk"
path = "./data"
ursula --config ./ursula.toml
Three-node durable cluster (same file on each host, override the node ID per host):
[server]
listen = "0.0.0.0:4437"
[runtime]
core_count = 16
[raft]
node_id = 1
group_count = 256
init_membership_per_group = true
[raft.wal]
backend = "disk"
path = "/var/lib/ursula"
[storage.cold]
backend = "s3"
root = "ursula-prod-20260518"
[storage.cold.s3]
bucket = "my-ursula-bucket"
region = "us-east-1"
[[raft.peers]]
node_id = 1
url = "http://10.0.0.1:4437"
[[raft.peers]]
node_id = 2
url = "http://10.0.0.2:4437"
[[raft.peers]]
node_id = 3
url = "http://10.0.0.3:4437"
ursula --config /etc/ursula/ursula.toml --node-id 1
See Deploy a Cluster for the full deployment walk-through.