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, ursula falls back to the default preset, which starts a single-node in-memory runtime without Raft, and nothing survives a restart. That's what Install uses. Every other preset, and a config file loaded without --preset default, runs Raft on the disk WAL described under Raft.

CLI flags

FlagDefaultNotes
--config FILEsearches ./ursula.toml, /etc/ursula/ursula.toml, then user config pathsTOML config file
--preset PRESETdefault when no config file is found, otherwise noneResource preset: default, dev, tiny, small, standard, or large
--node-id IDconfig file or presetOverrides 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:

PresetIntended useMain defaults
default, devLocal developmentSingle-node in-memory runtime without Raft
tinyMemory-bound tests or very small nodes64 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 8 MiB hot/admission caps, 64 MiB HTTP in-flight body budget
smallSmall nodes or cost-sensitive tests128 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 16 MiB hot/admission caps, 64 MiB HTTP in-flight body budget
standardProduction baseline256 groups, 256 MiB cold cache, 8 MiB cold flushes, 4 cold writes, 64 MiB hot/admission caps, 256 MiB HTTP in-flight body budget
largeLarger nodes with more cache and write headroom512 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 keyDefaultPurpose
server.listen127.0.0.1:4437Client/API listener
server.cluster_listennoneOptional separate Raft/cluster listener
runtime.cpu_affinityautomaticOn Linux, true forces pinning and false disables it; omitted pins only when cpuset size equals core_count
runtime.core_countavailable_parallelismOwner worker threads; Linux automatically pins only when the allowed cpuset matches this count. Journal writers and the main Tokio runtime use additional threads
runtime.live_read_max_waiters_per_core65536SSE waiter cap per core. 0 disables the limit
observability.tokio_consolefalseEnables 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. A client that talks to a node directly follows it, so it must be reachable by such clients as well as by peers. ursula gateway follows a redirect to an upstream directly when its --upstream equals the peer URL, and searches its upstreams for the leader otherwise.

Raft

Config keyDefaultPurpose
raft.node_idnoneThis node's stable ID, usually overridden per host with --node-id
raft.append_transport_budget_bytes134217728Node-wide charged append bytes per direction, shared across cores and peers; excludes tonic/socket buffers
raft.group_countcore_count × 16Total Raft groups. Higher values improve stream-to-group hash spread
raft.wal.pathnoneDirectory of the Raft WAL. Required when raft.peers is set. Raft logs live under PATH/raft-log, and removing the directory wipes state cleanly. Without it, a single node runs its WAL in a temporary directory that is removed on clean shutdown
raft.wal.fsyncneverWhen journal appends reach stable storage. never acknowledges ordinary data appends from the page cache. Membership batches are always fsynced before acknowledgment. always acknowledges a batch after fsync. Votes, each group's log state and the node's run state are always written with fsync
raft.wal.segment_size64MiBSize at which each core's journal starts a new segment. Purge deletes whole segments, so this is also how much disk space comes back at a time. At least 4KiB
raft.wal.cache_size256MiBRecent Raft entries kept in memory across the node, split evenly between its groups. Older entries, such as a lagging follower's catch-up, are read from disk
raft.wal.min_available_size512MiBReject writes and mark /__ursula/ready unavailable below this much free WAL-filesystem space. 0 disables the guard
raft.wal.resume_available_size1GiBFree space required before disk-pressure admission clears. Must exceed the minimum
raft.peersemptyStatic gRPC peer list, with one [[raft.peers]] entry per voter, including this node
raft.init_membership_per_groupfalsePer-group membership bootstrap. One voter per group initializes it once every configured voter reports the group empty and no leader. A replica that ever held its group never initializes it again, so keep it true across restarts. The Helm chart sets true

The Raft WAL is always the per-core disk journal. A node with raft.peers set refuses to start without raft.wal.path. A single node without a path creates a fresh ursula-wal-* directory under the system temporary directory, logs its location at startup, and removes it on a clean shutdown (SIGTERM or Ctrl-C). A failed start, or a shutdown that cannot close the WAL, leaves the directory in place. Its streams survive no restart. Set raft.wal.path to keep them.

With fsync = "never", a process crash loses nothing because the page cache survives it, and a graceful shutdown fsyncs every journal before it exits. A host crash or power loss can drop the unsynced tail of that replica's journal. The replica restarts from the frames that still verify and reports that it is recovering. Each of its groups stays behind a recovery gate: it does not campaign or vote until it has applied a fresh barrier from the group's leader, so it never helps elect a leader that lacks a write it acknowledged. While at most a minority of a group's voters lost their tail, the other voters hold every acknowledged write and the group keeps serving. If a majority lost it at once, the group has no leader and refuses writes until an operator accepts the loss (see Operations).

With fsync = "always", a host crash loses no acknowledged write. Every batch waits for an fsync, which costs write latency. A host crash can still leave a complete invalid frame after the last fsync in a journal's newest segment. Recovery cannot tell it from media corruption, so it gates that core's groups as above. A group gated on a majority of its voters, or a single-voter group, then waits for an operator although nothing was lost (see Operations).

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 keyDefaultPurpose
storage.cold.backendnonenone, memory, or s3
storage.cold.rootnonePrefix prepended to every cold key. Use a date-stamped value for benchmark runs so cleanup can't touch production data
storage.cold.s3.bucketnoneRequired when backend = "s3"
storage.cold.s3.server_side_encryptionaes256SSE mode for every object write: aes256 (SSE-S3), aws-kms, or none
storage.cold.s3.kms_key_idnoneCustomer managed KMS key; only valid with aws-kms
storage.cold.s3.probe_timeout5sDeadline of the snapshot-store health probe, retries included. A probe whose first attempt fails fast still has time for one retry, while an attempt that hangs runs into this deadline. Must be non-zero
storage.cold.s3.unhealthy_ticks3Bad health ticks, with no good tick between them, before the node sheds its leaderships. Must be non-zero
storage.cold.s3.heal_ticks2Good health ticks in a row before a node that shed may lead again. Must be non-zero
storage.cold.flush_interval1sBackground flush worker tick interval
storage.cold.flush_size8MiBTarget bytes flushed per group per tick
storage.cold.flush_max_concurrency4Parallel cold writes in flight
storage.cold.max_hot_size_per_group64MiBBackpressure ceiling: when a group's hot bytes exceed this, new writes get HTTP 503 until the flush catches up. Explicit 0 disables the cap
storage.cold.flush_max_hot_age5mA stream's hot tail older than this is flushed whole even when its group is below the flush threshold. 0 disables
storage.cold.compaction_enabledtrueBackground compaction: merges small chunks that flushes recorded as compaction debt (no bucket listing) and rewrites packed slices once a stream holds 64 of them
storage.snapshot.backendautoauto (S3 snapshots when storage.cold.backend = "s3", inline otherwise), inline, or s3
raft.snapshot_log_budget1GiBNode Raft-log budget of the snapshot driver. A group snapshots once its log since the last snapshot reaches the larger of this budget over twice the group count (at most 16MiB) and twice its last snapshot's size. Above three quarters of the budget a pressure pass snapshots the groups that hold the most log first, one at a time, until the node would fall to half the budget. Past the budget it runs more builds at once, in even steps up to raft.snapshot_build_max_concurrency before twice the budget. With the default of 2 that is two builds past the budget. With 4 it is two past the budget, three past 1.33 times it and four past 1.67 times it. Over twice the budget the node refuses writes with a body until its log is below 1.5 times the budget
raft.snapshot_build_max_concurrency2Snapshot builds the node runs at once. Each build holds its group's captured state (the hot window, at most storage.cold.max_hot_size_per_group, and the streams' metadata) and, while it uploads, its compressed snapshot, about as large again, and it sends its snapshot-store requests one after another. The snapshot driver starts a build only while fewer run than the node's log calls for: one below raft.snapshot_log_budget and more past it. Builds it started past the budget finish after the log falls below it. A snapshot an operator triggers while every build slot is taken is deferred. With the driver off (storage.snapshot.drive_interval = 0), OpenRaft's own policy starts the builds, up to this many at once, where the default of 1 before 0.7.4 ran one. Set 1 to keep one build at a time, as before 0.7.4. Raise it, to 4 for example, when the node keeps reaching twice the budget with every build in use (raft_snapshot_builds_in_flight_max at this value) and its memory holds that many builds of its largest groups. The server refuses to start with 0 or more than 16
raft.snapshot_pressure_max_groups_per_tick16Groups one snapshot driver pass starts while the node's log is below raft.snapshot_log_budget. Past the budget a pass starts this many for each build it keeps in flight

Hot bytes here are payload bytes: the hot window keeps no per-message bookkeeping, so admission and flush thresholds charge exactly the unflushed payload. 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; single-stream slices of 1 MiB or more keep the direct-object layout, and smaller ones are packed even when they are alone.

With S3 snapshots, a health gate checks the store every storage.snapshot.drive_interval (5s by default). Setting drive_interval = "0s" turns off the snapshot driver and this gate with it. Each tick probes the store and reads the node's cold-flush writes. A tick is bad when the probe fails or misses its deadline, or when a write failed within the flush window and no write has succeeded since. The window is as long as the S3 client can take to give up on a write: storage.cold.s3.timeout for each of its max_retries plus one attempts, the backoff between them, and the flush worker's pause between passes. That is 55 seconds with the defaults. A tick in which writes both failed and succeeded neither ends a bad streak nor adds to it. One failed probe is not enough on its own, and once a later write succeeds a failed write weighs only on its own tick. A store that refuses writes but answers the probe is still caught. After storage.cold.s3.unhealthy_ticks bad ticks with no good tick between them the node stops campaigning and hands its leaderships to its peers. With the defaults that is 10 to 20 seconds into an outage when the probes fail, about 10 to 30 seconds when only writes fail (sooner when the store refuses them outright), and about a minute when every write attempt hangs until its timeout. While a tick is bad the node also holds routine snapshots back, though a log-pressure pass still runs. After storage.cold.s3.heal_ticks good ticks in a row it may lead again. For a node shed for failed writes, the good ticks start with the first write that succeeds after the last failed one. A node that has handed off every leadership flushes nothing, so its good ticks start only once its last failed write leaves the window, about a minute after that write with the defaults.

A shedding node hands off at most four leaderships per tick, the groups with the most unflushed bytes first, and only to peers that may campaign. A peer that is draining, waiting at its recovery gate, or shedding for its own snapshot store, cluster egress or WAL disk is passed over. The gate asks each peer for its leadership-shed state and waits governance.leadership_balance.peer_timeout (500ms by default, must be non-zero) for the answer, the same timeout the leadership balancer uses. A peer that does not answer in time is treated as unable to campaign. A handoff is done once another node leads the group, and one that Raft abandons is handed off again on a later tick. A node with N leaderships therefore evacuates in about ceil(N / 4) ticks, for example 22 ticks or 110 seconds for 86 leaderships at the default interval. The cap is per gate. The cold-health gate (governance.cold_health, every 2s by default) drains the same way and independently, so a node that sheds for both moves up to four leaderships per 2 seconds plus four per 5 seconds. The leadership balancer (governance.leadership_balance) also keeps moving leaderships off a node that holds more than its fair share, up to max_per_tick per balancer tick. The cluster-egress gate (governance.cluster_probe) still hands every leadership off at once, because a node that cannot reach a quorum cannot replicate, and it plans again on every tick for the ones still pending. Its questions to the peers travel the path it judges impaired, so a lost answer says little about a peer. It passes over a peer that answered that it may not campaign. A peer that did not answer may take a leadership's first handoff, and the Raft handoff check decides. A leadership whose handoff did not move goes only to a peer that answered that it may campaign, so a peer that never answers costs each leadership at most one stalled handoff per shed. On a tick when a peer answered that it may not campaign, the gate passes over the peers that did not answer too, because the shed may be cluster-wide.

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]
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]
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.