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-plane listener: the stream API, metrics, readiness and usage
server.cluster_listennoneSeparate cluster-plane listener for Raft gRPC between voters. Unset, the cluster plane shares server.listen. Set, every [[raft.peers]] entry needs a client_url
server.admin_listen127.0.0.1:4438Admin-plane listener for the mutating operator routes, plus metrics, readiness and usage. Keep it on loopback and reach it through a tunnel
server.cluster_idnoneThe deployment's id, sent as Ursula-Cluster-Id on every HEAD: 1 to 64 characters from A-Z, a-z, 0-9, ., _ and -. Required when raft.peers is set, the same on every node and different for every deployment a client can reach. A single node without it generates u followed by 32 hex digits at its first start and keeps it in its WAL. A single node without raft.wal.path gets a new one on every start. The WAL records the id, and a node whose configured id differs refuses to start. Peers check it: every Raft gRPC request carries the sender's id, and a node refuses a peer with another id (see Peers of another cluster)
server.http_inflight_body_size256MiB (64MiB in the tiny and small presets)Write body bytes the node holds at once. A write that would exceed it answers 503 with INGRESS_BODY_BYTES_LIMIT_REACHED. A snapshot commit's copy of parts is charged against it too, from 32 MiB to 56 MiB depending on the budget, and starts only while 32 MiB stays free beside it. With a cold backend it must be at least 64MiB, or the node refuses to start
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

A node refuses a request body over 32 MiB with 413 and names that limit in Ursula-Max-Request-Bytes. The limit is the protocol's and is fixed. The gateway's --max-request-body-bytes (the chart's gateway.maxRequestBodyBytes) is at most 32 MiB, and a gateway or a chart set above it refuses to start, since the node refuses such a body before reading it and the gateway would answer 502 instead of a typed 413. With a cold backend, a larger snapshot goes up in parts instead.

Without server.cluster_listen, server.listen serves both the client and the cluster plane, and each [[raft.peers]].url points at that address. Leader redirects then name the same URL, so clients must be able to reach it too.

With server.cluster_listen set, server.listen serves only the client plane. Each [[raft.peers]].url points at the peer's cluster listener, and each [[raft.peers]].client_url at its client listener. A follower redirects a client to the leader's client_url, so only peers need to reach the cluster listener. A node refuses to start when a peer lacks client_url. ursula gateway follows a redirect to an upstream directly when its --upstream equals the leader's client_url, and searches its upstreams for the leader otherwise. See Security for what each plane serves.

[server]
listen = "0.0.0.0:4437"
cluster_listen = "0.0.0.0:4439"

[[raft.peers]]
node_id = 1
url = "http://10.0.0.1:4439"
client_url = "http://10.0.0.1:4437"

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. Each entry has node_id, url (the address peers dial for the cluster plane) and client_url (the client-plane address leader redirects name, required with server.cluster_listen and url by default). It is the only source of peer addresses: keep it the same on every node, and change it there to move a node (Changing a peer address)
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. A node without raft.peers keeps its snapshots inline
storage.snapshot.s3_timeout10sPer-attempt timeout of an S3 snapshot-store request. The snapshot store has its own timeout and retries, apart from storage.cold.s3.timeout and max_retries. The timeout covers one attempt, and a PUT sends the whole object in one attempt, so it must also cover a healthy GET or PUT of the largest snapshot. A follower that installs a snapshot sends up to four requests one after another within raft.install_snapshot_timeout: a GET of the leader's object, a PUT of its copy, a second PUT under a new name when the first one's name is taken, and a HEAD that checks the copy. Each request can take every attempt to its timeout and every backoff before a retry, 22 seconds with the defaults, so the copy takes at most 88 seconds of the default 120. On a cluster node with S3 snapshots, the server refuses to start when the four requests can take longer than raft.install_snapshot_timeout, with SnapshotCopyOutlastsInstall. A single node keeps its snapshots inline and is not checked. Must be non-zero
storage.snapshot.s3_max_retries1Retries of an S3 snapshot-store request. The first retry waits up to 2 seconds, the second up to 4, each later one twice as long up to 120 seconds. Counts toward the same bound as storage.snapshot.s3_timeout
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 a cluster node refuses writes with a body until its log is below 1.5 times the budget. A node without raft.peers never refuses them for its log
raft.snapshot_backstop_logs100000Far entry backstop of the snapshot driver. A group snapshots once it applied this many entries since its last snapshot, whatever its log bytes. Entry count bounds a group's log index in memory and its replay at restart, so lower it for groups of many small entries. It is the same on a node without raft.peers as on a cluster node. 0.8 kept a single node's groups within 5,000 entries, and 5000 keeps that bound
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. 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. It also caps the snapshot GC passes one driver tick runs
raft.deleted_data_max_agethe larger of 1h and eight times raft.install_snapshot_timeoutHow long a replica keeps a deleted, purged or expired stream in its current Raft snapshot and its Raft log after it applies the removal. The snapshot driver snapshots a group a quarter of this after its oldest removal that no snapshot covers, sooner when the removed bytes reach the group's snapshot threshold, and then purges the group's log through that snapshot. See Deleted data for what the bound covers. With an S3 snapshot store, snapshot GC deletes the snapshot object a replica replaced once no send of it is in flight, within one raft.install_snapshot_timeout and a drive interval. A set value must be at least eight times raft.install_snapshot_timeout (16 minutes at the default), or the server refuses to start, which leaves that room with a margin of several installs

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.

The snapshot driver takes every Raft snapshot and always runs. It ticks every storage.snapshot.drive_interval, 5s by default with S3 snapshots and 1s with inline ones, and the server refuses to start with 0s. With S3 snapshots, a health gate checks the store on each of its ticks. 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 on a single port per node (same file on each host, override the node ID per host). Deploy a Cluster shows the same cluster with the cluster plane on its own port, which keeps Raft off the client port:

[server]
listen = "0.0.0.0:4437"
cluster_id = "prod-us-east-1"

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