Pre-1.0 hoardDB is pre-1.0. Expect breaking changes.
Configuration
All configuration is done via environment variables or a YAML config file named with --config (see YAML Config File). Environment variables take precedence over the file, and the file over the built-in defaults.
When a variable replaces a value the file sets, and the two differ, the server logs one warning on stderr at start for each such key. It names the key, both values and the variable to unset, for example metrics.listen is set in the config file to 127.0.0.1:9090, but HOARDDB_METRICS_LISTEN=0.0.0.0:9090 overrides it. The variable still wins. The official Docker image sets HOARDDB_DATA_DIR, HOARDDB_LISTEN, HOARDDB_TCP_LISTEN and HOARDDB_METRICS_LISTEN itself, so a mounted file that sets those keys needs -e HOARDDB_METRICS_LISTEN= (an empty variable is ignored) to take effect.
Default settings lists every setting with the value it has when you set nothing.
A variable that holds a number or a switch must parse. A number is a plain integer with no unit, space or fraction (HOARDDB_MEMTABLE_MB=64, not 64MB or 64.5); a switch is true, false, 1, 0, yes or no. The switches are HOARDDB_METRICS_ENABLED, HOARDDB_AUDIT_ENABLED, HOARDDB_PPROF_ENABLED, HOARDDB_AUTO_REBALANCE, HOARDDB_AUTH_BOOTSTRAP_ROOT, HOARDDB_REQUIRE_ROOT_PASSWORD and HOARDDB_UNSAFE_ACK_BEFORE_SYNC; an earlier version read anything other than true, 1 or yes as false without a word, so TRUE was off. Any other value stops the server at start with a message that names the variable, the value and the fix, and lists every bad variable at once. It is never replaced by the default.
Environment Variables
Core
| Variable | Default | Description |
|---|---|---|
HOARDDB_TCP_LISTEN | 127.0.0.1:7433 | TCP+TLS driver listener address. The default is this machine only; set 0.0.0.0:7433 (or one interface’s address) to accept clients from other machines. |
HOARDDB_LISTEN | 127.0.0.1:4433 | This node’s address in the ring: the address its peers are told to reach it on. The internode port is this port plus one (4434). Set it to the node’s own address, such as 192.0.2.1:4433, on every node of a cluster that spans machines. A node whose seeds are on another machine but which is still on loopback is refused at start. See Security. |
HOARDDB_TCP_ADVERTISE | empty | The host:port a member of the entry-ring cluster tells drivers to connect to (yaml tcp.advertise); it takes effect once the node is in a ring cluster (cluster.enabled), and a node that is not logs a warning and ignores it. Set it when HOARDDB_TCP_LISTEN is a wildcard or loopback address. It must be on the same host as the node’s cluster address (cluster.advertise, else cluster.listen), and a different port is fine: a node refuses to start with any other host. Empty: the node publishes HOARDDB_TCP_LISTEN when another machine could dial it, and nothing when it is a wildcard or loopback address; a member that publishes nothing has no driver address, and a driver that routes by the cluster’s topology skips it. |
HOARDDB_TCP_INTERNODE_LISTEN | derived from HOARDDB_LISTEN | The internode listener address, when it must differ from HOARDDB_LISTEN’s port plus one. |
HOARDDB_DATA_DIR | ./data | Data directory, relative to the working directory. |
HOARDDB_BLOB_DIR | <HOARDDB_DATA_DIR>/blobs | Where blob buckets keep their payload files. Set it to put large payloads on a volume of their own, for example HOARDDB_BLOB_DIR=/mnt/blobs. It belongs to one node: the server refuses to start on a directory another running node holds, and refuses one inside the data directory’s store, keys, wal or cluster. A database cannot be named after the directory (blobs by default). When the directory is on a different filesystem from the data directory, the disk ratio and the join guard cover both (a blob_disk resource beside disk). |
HOARDDB_MAX_BLOB_BYTES | 16777216 (16 MiB) | The largest blob, in bytes, this node accepts from a client. Raise it to store bigger blobs, up to 33488896 (31.9 MiB), the largest blob that fits one 32 MiB frame with its envelope and its copy for a replica. A value below 1 or above that stops the server from starting, with an error naming the key and the range. A larger blob is refused with the limit in the message; keep larger files on your own storage instead, see Large file stores. A write that reaches a replica is not checked against the replica’s own value, so keep the setting the same across a cluster. YAML key: storage.max_blob_bytes. |
HOARDDB_MAX_DOCUMENT_BYTES | 25165824 (24 MiB) | The largest single document, in encoded bytes, this node accepts from a client. It counts one document’s own bytes, not its bucket and not the elements of its store fields, which are stored separately. Lower it to as little as 4096, or raise it to at most 31457280 (30 MiB), the largest document that fits a write frame, its copy for a replica and a dump. A value outside that range stops the server from starting, with an error naming the key and the range. A larger document is refused with the limit in the message; a list that grows belongs in a nested store field, and a large value in a blob bucket or behind a pointer document, see Large file stores. A write that reaches a replica is not checked against the replica’s own value, so keep the setting the same across a cluster. YAML key: storage.max_document_bytes. |
HOARDDB_NODE_ID | hostname | Unique node identifier. The host name default is never matched to a HOARDDB_SEED_NODES entry by id, because cloned hosts share one: a node listening on 0.0.0.0 and listed as <node-id>@<address> must set it. A node whose id is listed at another node’s address stops at start. |
HOARDDB_SEED_NODES | (none) | Comma-separated host:port list for cluster formation |
TLS
| Variable | Default | Description |
|---|---|---|
HOARDDB_TLS_MODE | auto | auto is the only mode in this version: every node serves the certificate it generated itself. Any other value, including custom, stops the server at start-up with an error (see Custom certificates are not available yet) |
HOARDDB_TLS_CERT | (none) | Not available in this version; setting it stops the server at start-up with an error |
HOARDDB_TLS_KEY | (none) | Not available in this version; setting it stops the server at start-up with an error |
HOARDDB_TLS_CA | (none) | Not available in this version; setting it stops the server at start-up with an error |
Authentication
| Variable | Default | Description |
|---|---|---|
HOARDDB_ROOT_USER | admin | Root username for first start. |
HOARDDB_ROOT_PASSWORD | asked on a terminal on first start | Root password, used only to create the root user on the first start. If unset, a first start from a terminal asks for one (twice), and a first start with no terminal refuses, writes nothing and says to set it. Only its hash is kept, in <HOARDDB_DATA_DIR>/users.json; the password itself is never written to disk. The environment value is wiped after use. Later changes go through hoarddb-server -reset-password. A container always needs it (the image sets HOARDDB_REQUIRE_ROOT_PASSWORD=true). |
HOARDDB_ROOT_PASSWORD_FILE | (none) | Path to a file holding the root password, read instead of putting it in the environment (an environment variable is visible in docker inspect and /proc; a file is readable only by whoever can read the file). Keep it at 0600; a broader mode is warned about, an empty or unreadable file stops the server at start-up. If both this and HOARDDB_ROOT_PASSWORD are set the file wins, and the server logs a warning when they differ. Like HOARDDB_ROOT_PASSWORD, it only seeds the root user on the first start. |
HOARDDB_REQUIRE_ROOT_PASSWORD | false | With true, a first start that is not given HOARDDB_ROOT_PASSWORD or HOARDDB_ROOT_PASSWORD_FILE refuses to start, naming the variable, instead of asking for one on a terminal. The recommended production setting, so an unattended node never invents its own root password. Restarts are unaffected: once the root user exists, nothing is asked for. YAML: auth.require_root_password. |
Cluster mode
With HOARDDB_CLUSTER_ENABLED=true the node starts as an entry-ring cluster member: it binds one extra listener, the peer listener, and keeps its cluster state in <data dir>/ring. A node with none of these keys set runs as it always has and binds no peer listener.
The peer listener binds HOARDDB_CLUSTER_LISTEN, which defaults to HOARDDB_LISTEN (port 4433). It may not share a port with the driver listener (HOARDDB_TCP_LISTEN) or the node-to-node listener of the earlier hash ring, which stays on HOARDDB_LISTEN plus one (4434): the start is refused, naming cluster.listen. Every peer connection is mutual TLS and is accepted only from a key you pinned: there is no trust on first use, and a connection to a node is accepted only if that node presents the key pinned under its id. A node never dials an address that no member and no configured server names: join registers the address first.
In cluster mode HOARDDB_SEED_NODES drives the entry-ring cluster only: the earlier hash ring is not started, so HOARDDB_REPLICATION_FACTOR and the seed list no longer start it. HOARDDB_NODE_ID may not contain a colon.
Which node starts the cluster. One rule: a node starts a new cluster when HOARDDB_SEED_NODES is empty or names only itself, and joins when it names any other node. So the first node lists nobody (or only itself), and every later node lists the nodes that already exist, not itself. Do not give every node the same list: all of them would wait to be joined and none would start the cluster. A node that waits to be joined logs a warning, and entry-ring node open shows join instead of bootstrap.
Pinning. Give each node, in HOARDDB_SEED_FINGERPRINTS, one <node-id>=SHA256:<64 hex> entry per node it lists (each node prints its own fingerprint on its start-up line); the node adds its own key itself. List the nodes in HOARDDB_SEED_NODES as <node-id>@<address>. The address form <host:port>=SHA256:<64 hex> also works for a seed listed at that address. A seed with no pinned fingerprint stops the start and names the entry to add on both nodes; one key under two node ids, and an entry with an empty id, are refused. A seed that names this node’s own id at an address other than its advertised one is refused, naming both. The start-up line entry-ring node open shows the node id, its advertised address, bootstrap or join, and its fingerprint.
Adding a node: pin, restart, join. A node joins in three steps, in this order, because every node trusts only the keys it was told about and reads that list when it starts.
- Start the new node (
node-b) with the first node in its seed list and the first node’s key pinned:HOARDDB_SEED_NODES=node-a@192.0.2.1:4433andHOARDDB_SEED_FINGERPRINTS=node-a=SHA256:<64 hex>. It waits to be joined and prints its own fingerprint on its start-up line. - Pin the new node’s key on every node that is already in the cluster,
node-b=SHA256:<64 hex>inHOARDDB_SEED_FINGERPRINTS, and restart each of them (a node reads the list at start; a reload without a restart does not exist yet). - Run
joinon any member. The statement is forwarded to the cluster’s leader, which asks the new node who it is over its pinned key, adds it, and activates it:
join node-b@192.0.2.2:4433
join node-w@192.0.2.3:4433
join node-c@192.0.2.4:4433
Added node-b@192.0.2.2:4433 as a member: joining, then active when it holds the slots it will own (epoch 2)
Added node-w@192.0.2.3:4433 as a witness: it votes and holds no documents (epoch 2)
Added node-c@192.0.2.4:4433 as a member: joining (not a voter: the cluster already has its 3 voters) (epoch 3)
A node that votes is promoted by the join while the cluster has a free voter slot (cluster.voters, 3 by default, witnesses included); a node that finds none joins without a vote, and promote <node> gives it one after a slot is freed. A witness is a node started with HOARDDB_CLUSTER_WITNESS=true; the join takes that from the node itself. The address you type must be the one the node advertises (HOARDDB_CLUSTER_ADVERTISE, else HOARDDB_CLUSTER_LISTEN); if it is not, the refusal prints the one to use. Running the same join again answers the same line.
Form the cluster before you create data. In this release join is refused while any database exists, because a node that joins a cluster with data cannot yet be filled with the slots it would own. Start the members on fresh data directories, join them, then create database. The membership statements leave, remove-node and replace-node arrive in a later release. A joined node publishes the client address it is configured with (HOARDDB_TCP_ADVERTISE, else a listen address another machine can dial); a node with none publishes no driver address, and a driver that routes by the cluster’s topology skips it. Set HOARDDB_TCP_ADVERTISE on a node before you join it if drivers should reach it directly.
HOARDDB_CLUSTER_ALLOW_MEMBERSHIP is the lock on these statements: false refuses every membership statement on this node, and on the leader on behalf of the members that forward to it.
| Variable | Default | Description |
|---|---|---|
HOARDDB_CLUSTER_ENABLED | false | Starts the node as an entry-ring cluster member. It needs server.node_id set to a name no other node uses, and encryption.mode: off; a start that breaks either is refused, naming the key to change. YAML: cluster.enabled. |
HOARDDB_CLUSTER_LISTEN | (HOARDDB_LISTEN) | The address the peer listener binds, as host:port. Empty uses HOARDDB_LISTEN. YAML: cluster.listen. |
HOARDDB_CLUSTER_ADVERTISE | (HOARDDB_CLUSTER_LISTEN) | The address peers are told to dial, as host:port. Required with cluster mode when the listen address is a wildcard (0.0.0.0 or ::), because no other machine can dial a wildcard; set it to this node’s own address, such as 192.0.2.1:4443. YAML: cluster.advertise. |
HOARDDB_CLUSTER_WITNESS | false | Joins the node as a witness: it votes in elections and holds no data. YAML: cluster.witness. |
HOARDDB_CLUSTER_ALLOW_MEMBERSHIP | true in cluster mode | false refuses join, promote, leave, remove-node and replace-node on this node and, when it leads, for the members that forward to it; a change window is the use. true with HOARDDB_CLUSTER_ENABLED false is refused at start. YAML: cluster.allow_membership. |
Replication
| Variable | Default | Description |
|---|---|---|
HOARDDB_REPLICATION_FACTOR | 1 | Replication factor (number of nodes holding each bucket); default is majority write concern when > 1 |
HOARDDB_SEED_NODES | (none) | Comma-separated seed addresses (host:port, or node-id@host:port). With HOARDDB_CLUSTER_ENABLED=true it lists the nodes of the entry-ring cluster, as node-id@host:port, and each other node needs a pinned fingerprint (see Cluster mode). Without cluster mode, list each node once, and give every node the same list; with it, see Cluster mode (the first node lists nobody else). A list that names one node id at two addresses, or one address under two node ids, stops the server at start, naming both; a node whose list places a node id at another address than a member’s list is never confirmed by that member and serves nothing, naming the id and both addresses. |
HOARDDB_SEED_FINGERPRINTS | (none) | Required whenever HOARDDB_SEED_NODES is set. Comma-separated trust anchors: SHA256:<64 hex> (trusted for any seed) or host:port=SHA256:<64 hex> (trusted only for that address). With HOARDDB_CLUSTER_ENABLED=true, write <node-id>=SHA256:<64 hex> for every node you list (see Cluster mode). Without it the server refuses to start. |
HOARDDB_WRITE_CONCERN | per RF | 1 (or one), majority or all; default is majority for RF>1, one otherwise. Any other value stops the server at start and names these |
HOARDDB_TTL_SWEEP_SECONDS | 60 | Seconds between TTL sweeps (YAML server.ttl_sweep_seconds). A document in a bucket with a TTL index can be read for up to one interval after it expires, plus the time a backlog takes to drain. 0 disables the sweeper: nothing expires. See indexes.md. |
HOARDDB_TTL_SWEEP_BUDGET_SECONDS | 5 | Longest one TTL sweep runs (YAML server.ttl_sweep_budget_seconds). A sweep that ends on its budget with expired documents left starts the next one at once. 0 or less uses the default. |
HOARDDB_REPLICATION_RETENTION_CHECK_SECONDS | 60 | Seconds between the entry-ring node’s retention checks (YAML replication.retention_check_seconds, 1 to 3600). Each tick also sweeps the dedupe entries that have left replication.dedupe_window, on the storage.reclaim_keys_per_second budget every background sweep shares. A value outside the range stops the server at start, naming the key. |
HOARDDB_WAL_RETENTION_INTERVAL | 60 | Seconds between WAL retention passes: each prunes segments every other member has confirmed and every local store has been synced past, so a lagging or stopped member always stays repairable from the log. 0 disables the loop (log grows for the life of the node). |
HOARDDB_DISK_HIGH_WATER | 85 | Used-disk percent (0-100) at or above which this node copies in no bucket it does not already hold and votes against a new cluster member: an mmap-backed write into a full disk crashes the node (SIGBUS). A bucket it refuses stays on its other copies, and is unwritable if this node is its owner, until space is freed. 0 disables the guard. |
HOARDDB_AUTO_REBALANCE | true | Whether a membership change immediately moves data so every bucket’s ring owner and replicas hold a copy. false makes the operator drive rebalancing explicitly (migrate per bucket); migration is idempotent. With false, a join pauses schema changes until the operator’s migrates have copied every moved bucket; nodes lists the commands. |
Write concern and retention apply to replicated (multi-node) deployments; a single node serves with no WAL and no replication. See replication.md.
A cluster with exactly two members can permanently lose the ability to change membership at all if either node dies (a majority of 2 is 2) — this is logged loudly at startup, but there is no automatic recovery from it. Use an odd member count of three or more in production.
Seed pinning (required for every join)
hoardDB uses self-signed certificates, so a node cannot learn a seed’s identity
from a CA. Every join must be told what it is connecting to: seed_fingerprints
(HOARDDB_SEED_FINGERPRINTS) lists the expected certificates. It is required
whenever seed nodes are configured — a server with seeds and no fingerprints
refuses to start, with an error naming the variable. Without an anchor, any host
could impersonate the cluster and read the cluster token off the internode
stream.
Two forms are accepted in the comma-separated list:
SHA256:<64 hex>— trusted for any seed address.host:port=SHA256:<64 hex>— trusted only for that address. The mapped form is recommended because a bare fingerprint would also be accepted if the same key ever appeared at another seed’s address.
Every node prints its fingerprint at startup; the log line carries a
fingerprint field:
grep fingerprint <seed startup log>
On one host, leave ring ports at least two apart (for example 4433, 4435, 4437). A node dials the
ring port plus one, so a mapped entry for host:4433 also covers host:4434; if another entry is
for host:4434, it silently replaces the first, and the dial to the node on 4433 fails with a
fingerprint mismatch. Nodes on different hosts do not collide.
A peer verified once is remembered in {data_dir}/cluster/known_servers (keyed
by its internode address), so later dials do not need the configured list. The
node creates {data_dir}/cluster owner-only and refuses to start when that
directory or file is writable by another user or owned by one (the error names
the chmod or chown to run), which matters for a volume prepared by another
tool or user. A
certificate that changes for an already-known peer is a hard failure, never a
silent re-trust. Legitimate rotation is explicit:
- Update
seed_fingerprintswith the newhost:port=SHA256:<hex>value (or remove that peer’s line from{data_dir}/cluster/known_serverson the dialing node), then restart the node. - The mismatch error names both fingerprints and both possible causes, so a real rotation is never “fixed” by turning the check off.
A new node added after the cluster is running must also be pinned by the nodes
that dial it; because its key is generated on first start, add its
host:port=SHA256:<hex> entry to the other nodes’ seed_fingerprints (or trust
its entry in known_servers) before adding it. A single node with no seed nodes
never dials a peer and is unaffected.
Storage (BadgerDB tuning)
| Variable | Default | Description |
|---|---|---|
HOARDDB_BADGER_BLOCK_CACHE_MB | 128 | BadgerDB block cache, in MiB. A node keeps one BadgerDB instance that every bucket shares, so this is a node-wide cost and does not multiply by bucket count. Raise it when the working set outgrows it and reads start to touch the disk. |
HOARDDB_BADGER_INDEX_CACHE_MB | 64 | BadgerDB index cache, in MiB. Node-wide, like the block cache. |
HOARDDB_BADGER_NUM_COMPACTORS | 4 | BadgerDB background compaction goroutines for the node. Node-wide; lower it to bound background CPU. |
HOARDDB_BADGER_TABLE_SYNC | msync | How the store makes each new table file durable: msync or stepwise. stepwise can lower write tail latency, but has been tested only on ext4 with a journal (XFS is expected to be safe but is not yet tested); see “New table files and your filesystem” below. Any other value stops the server from starting, with an error naming the key and both values. YAML key: storage.badger_table_sync. |
Storage memtable
| Variable | YAML key | Default | Range |
|---|---|---|---|
HOARDDB_MEMTABLE_MB | storage.memtable_mb | chosen at first start from memory (32, 64 or 128) | 32 to 1024 (MiB) |
The memtable is the write buffer in front of the node’s one store; every bucket shares it, so this is a per-node setting and does not multiply by bucket count. A value outside the range, or one that is not a whole number, stops the server at start with a message that names the value, the bound and the fix; it is not silently adjusted.
How big is my memtable, and why
A new node chooses its memtable once, when it creates its store, from the memory it is allowed (its cgroup limit, or the machine’s RAM): 32 MiB below 3.5 GiB, 64 MiB from 3.5 GiB, 128 MiB from 7 GiB. It keeps that size from then on, even if the machine is later resized, so a node that crashed is never asked to replay its log into a smaller memtable. The choice is saved in
<data dir>/store/memtable-choice.json; copy it with the store. A store created by an earlier version keeps the size it last ran at.hoardDB statusshows the size and why, and the server logs it at every start, with the memory it was chosen from and what write bursts can peak at:memtable: 64 MiB, chosen now for this new store from this node's memory limit of 3.8 GiB (the host's MemTotal; no cgroup memory limit is set). Write bursts can peak near 0.72 GB of memory at this size; the largest atomic batch is 10 MB. The choice is saved in /var/lib/hoarddb/store/memtable-choice.json and kept on every restart, even if this host's memory changes. To use another size, set storage.memtable_mb (32 to 1024) in the config file.A size that is large for the node’s memory (write bursts predicted over a third of it) is logged as a warning that says how much memory the node needs.
Set
storage.memtable_mbto choose it yourself. 256 MiB and above are only ever set this way. Prefer the config file toHOARDDB_MEMTABLE_MB: a variable lost on a restart takes the node back to its chosen size. Removing the setting goes back to the chosen size too.A node that joins a cluster must run the cluster’s size.
joinrefuses a node at another size and says what to set; if its machine is too small for the cluster’s size, give it more memory before it joins. Members already running at different sizes log an error naming each other, andstatusshowsDIFFERS. The difference is not cosmetic: a member at a smaller size cannot apply the larger members’ biggest batches. Its replication stops at the first such batch, and the owner’s log names the member, the batch and the size it needs, until you set that member to the cluster’s size and restart it. Nothing is lost while it waits; the owner keeps the batch and re-sends it. A smaller member killed (kill -9, power loss) while it refuses such a batch still stalls on it after its restart, and once resized and restarted it holds the batch once, in a FIFO, LIFO or heap bucket as in any other. Give every node the samestorage.memtable_mb, set explicitly in each node’s config file.If the choice file is missing from a store that already exists, the node keeps the size the store last ran at, so that it never shrinks, and records that size as the store’s choice, even if a setting asked for it at the last run. The start-up line says so.
What it trades.
- Write stalls. A bigger memtable flushes less often, so a burst of writes stalls less. A small one flushes more often and stalls longer under a heavy write load. hoardDB publishes no figure for either until it has been certified.
- The largest atomic batch. One batch (an insert-many, or one batch of
hoarddb ingest) is applied all-or-nothing, and the store commits at most 15% of the memtable at once: 5 MB at the 32 MiB floor, 10 MB at 64 MiB, about 20 MB at 128 MiB (what a host with 7 GiB or more chooses). Because the size depends on the host, only batches up to 5 MB are atomic on every host. The floor is where that stays at the 5 MBhoarddb ingestsizes its batches for. A batch over the limit is refused whole, with the limit in the message. - Memory. The node holds up to three memtables. An idle node uses the same
resident memory at any size (133 MB on the measuring host), and a memtable
takes memory only as it fills: at 128 MiB a node killed with its memtable 90%
full held about 1 GB, against 0.4 GB at 32 MiB, and under the sustained write
load above peaked near 6.8 GB, against 3.7 GB. Plan for several gigabytes on a
node that takes heavy writes, and keep an eye on the
memoryratio in capacity.
Lowering it after a crash: “Memtable size mismatch”. The size is fixed when the
server starts. If the node was killed (kill -9, power loss, the out-of-memory
killer) while running at a larger size, the write-ahead log it left cannot be
replayed into a smaller memtable. The server refuses to start in that case. This
can also happen when you remove an explicit setting after a crash: the node goes
back to its chosen size. It then says:
Memtable size mismatch: this node now runs at its chosen 64 MiB because
storage.memtable_mb is not set and HOARDDB_MEMTABLE_MB is not set, but its node
store directory /var/lib/hoarddb/store was last running with 256 MiB and was not
stopped cleanly: 1 write-ahead log file(s) of unflushed writes remain (000001.mem),
and they do not fit in a 64 MiB memtable. Nothing is lost. To fix it, set
HOARDDB_MEMTABLE_MB=256 (or storage.memtable_mb: 256 in the config file), start the
node once and wait until it is serving, then stop it cleanly (SIGTERM or Ctrl-C,
not kill -9) and remove the setting: the node goes back to its chosen 64 MiB.
When a setting asks for the smaller size, the message names it:
Memtable size mismatch: this node is set to 128 MiB by HOARDDB_MEMTABLE_MB, but its
node store directory /var/lib/hoarddb/store was last running with 256 MiB and was
not stopped cleanly: 1 write-ahead log file(s) of unflushed writes remain
(000001.mem), and they do not fit in a 128 MiB memtable. Nothing is lost. To fix
it, set storage.memtable_mb (or HOARDDB_MEMTABLE_MB) to 256, start the node once
and wait until it is serving, then stop it cleanly (SIGTERM or Ctrl-C, not kill
-9). After that you can lower it to 128.
Without this check the store would exit with Arena too small, which names
neither the setting nor the way out; if you meet that message, this is the cause,
and the fix is the same. A node that stopped cleanly can change to any size in the
range, and raising the size is always allowed.
storage:
memtable_mb: 256
Storage durability
| Variable | YAML key | Default |
|---|---|---|
HOARDDB_UNSAFE_ACK_BEFORE_SYNC | storage.unsafe_ack_before_sync | false |
A node answers a write only once it is on disk, so a write it acknowledged survives a crash of the process, a kernel crash and a power cut. A node in a cluster with other members writes each entry to its write-ahead log and syncs the log before it applies the entry. A write the node does not log is synced in the store before the node answers it: every write of a single node with no seed nodes and a replication factor of 1, the zero-config default, which keeps no log, and every write of a node that keeps a log but is still the only member of its ring, such as the first seed before its peers join. Writes that arrive together share one sync, so many clients writing at once cost far fewer syncs than writes; a client writing one document at a time waits for one disk flush per write. A batch insert is one sync for the whole batch, so loading data in batches is the fast way to write on a single node.
unsafe_ack_before_sync: true gives that up. The node then answers before
the sync, and a power cut or a kernel crash can lose writes it has already
acknowledged; a crash of the server process alone does not lose them. It
exists for benchmarks that measure everything but the disk. Never set it on a
node whose data you need. The server logs a warning at every start while it is
set. It changes nothing for a write the node logs, because the write-ahead log
is synced before that write is applied either way; on a node with a log it
applies only while the node is the only member of its ring.
Write-ahead log segments
| Variable | YAML key | Default |
|---|---|---|
HOARDDB_WAL_PREALLOCATE | storage.wal_preallocate | auto |
Two logs read this one setting: the entry-ring node’s log, which every server opens, and the legacy node’s log, which exists only with a replication factor above 1 or seed nodes. A legacy node with neither has no log of its own, and if you set the key the server says it has no effect on the legacy node (the entry-ring node logs its own line).
auto(the default) turnsonfor ext2, ext3 and ext4 only and leaves it off on every other filesystem, XFS included, until it has been measured there. To go back to the earlier behaviour, setHOARDDB_WAL_PREALLOCATE=off.offstarts each 64 MiB log segment empty and lets it grow, as every earlier build did.onwrites zeros through each segment before the log uses it and keeps the next segment ready, so a flush of the log writes data only. It can lower the latency of replicated writes. It holds 128 MiB of disk ahead of the log (the active segment and the spare) and writes about as many bytes of zeros as of log entries.
Any other value (true, ON, or wal_preallocate: "" in the YAML file) stops the
server from starting, with an error that names the key, the variable and the three values. (An empty
HOARDDB_WAL_PREALLOCATE= is ignored and gives the default.) A change
needs a restart. The setting does not change the log’s format: every node with a
log writes segment format 2, and an earlier build refuses to start on it (see
Compatibility).
What the 128 MiB moves. With preallocation in effect (the default on ext2/3/4, or on), an idle node already holds about 128 MiB
of log, so hoarddb_node_data_bytes{part="wal"} reads that much at rest, and
the disk high-water mark (server.disk_high_water_pct) is reached 128 MiB sooner.
A node that cannot make its next segment because the disk has no room refuses
writes, and status, HEALTH_CHECK and /ready say so with the fix: free
space, or turn preallocation off. Each start begins a new log segment, which
retention removes later.
What each start logs (INFO, with the fields mode, configured,
filesystem and reason):
write-ahead log segments are preallocated (storage.wal_preallocate=on)
write-ahead log segments are preallocated (storage.wal_preallocate=auto: ext2/3/4)
write-ahead log segments are not preallocated (storage.wal_preallocate=off)
write-ahead log segments are not preallocated (storage.wal_preallocate=auto: XFS is not measured yet)
storage.wal_preallocate=on has no effect on the legacy node: it keeps no write-ahead log (replication_factor 1, no seed nodes); the entry-ring node logs its own mode
entry-ring: write-ahead log segments are preallocated (storage.wal_preallocate=auto: ext2/3/4)
status shows the same on its WAL: row, for example WAL: preallocated (auto: ext2/3/4), spare ready, and HEALTH_CHECK carries it as the wal field.
Ten node-wide series, hoarddb_wal_preallocated, hoarddb_wal_flush_seconds,
hoarddb_wal_rotations_total, hoarddb_wal_rotation_waits_total,
hoarddb_wal_rotation_wait_seconds_total, hoarddb_wal_zero_fill_bytes_total,
hoarddb_wal_zero_fill_seconds_total, hoarddb_wal_spare_fill_failures_total,
hoarddb_wal_spare_ready and hoarddb_wal_bytes_appended_total, are exported when
metrics are on, and only by a node that keeps a log. None carries a bucket or
database label. hoarddb_wal_bytes_appended_total is the framed bytes of every
entry the log wrote since the node started and starts again at a restart, so read
its rate over a run rather than a total.
Where to put the log. A separate device can be mounted at
<data_dir>/wal; hoardDB has no setting to move the log elsewhere. A mount there
is outside the disk ratio that the capacity probe and the status disk row
measure. A container whose data directory is in the image’s writable layer is on
overlayfs, so auto resolves to off; mount a volume to get the gain.
A rotation can wait. When the log fills a segment faster than a fill of the
next one, the rotation waits for the fill and every write and every
replication read waits with it, for the rest of the fill. That is counted in
hoarddb_wal_rotation_waits_total and hoarddb_wal_rotation_wait_seconds_total.
New table files and your filesystem
As writes arrive, the store writes them out to new table files in the background, and merges older files into new ones. Each new file must be on disk before the store’s file list (its manifest) names it, or a power cut can leave the store unable to open.
By default (storage.badger_table_sync: msync) the server syncs each new file
on its own before the manifest names it. That is durable on any filesystem. On
ext4 each of those syncs waits for a filesystem journal commit, and writes
queue behind it, so the slowest writes are slower than they need to be while
a file is being synced.
stepwise is an opt-in alternative that can lower write tail latency on
ext4. The server writes each new file to disk in 4 MiB pieces, waits until all
of it is there, and leaves the file’s size and location on disk to the one
sync that records it in the manifest. That is safe only where the filesystem
commits those changes in the order they were made:
- Tested and safe: ext4 with its journal (how
mkfs.ext4makes it). - Expected to be safe, not yet tested: XFS.
- Not safe: btrfs, ZFS, and ext4 without a journal. A power cut can leave the manifest naming a file whose contents never reached the disk, and the store then refuses to open. Nothing warns you before the power cut.
- Unknown: ext4 with the
fast_commitfeature. Usemsyncthere.
The server cannot tell these apart, which is why stepwise is never the
default. To see your filesystem and its options, run
findmnt -no FSTYPE,OPTIONS -T <data dir>, and tune2fs -l <device> for an
ext4 device’s features (look for has_journal and fast_commit).
To turn it on, set it in the configuration file:
storage:
badger_table_sync: stepwise
or in the environment, which wins over the file:
HOARDDB_BADGER_TABLE_SYNC=stepwise hoarddb-server
The setting is read at start, so restart the server for a change to apply.
It affects only table files written after the restart; files already on disk
are not rewritten. Switching back to msync is the same: change the setting
and restart.
Where the kernel or a container’s security profile refuses the call
stepwise uses (sync_file_range), the server syncs each new file the
msync way on its own, with nothing to set. If a table write fails with an
error that names storage.badger_table_sync, set it to msync (or remove it)
and restart.
Disk write volume depends on the kernel and filesystem
The store’s write-ahead log is a memory-mapped file, and the sync behind each
acknowledged write asks the kernel to write the log’s changed pages to disk.
How much the kernel writes for one sync depends on how it caches the file:
newer kernels keep a file’s pages in larger units (folios) sized by the
device’s readahead window, and a sync writes a whole unit back even when only
a few KB of it changed. It was measured on ext4 with Linux 7.0 and a
readahead window of 8 MB (read_ahead_kb 8192), and is expected on other
kernels that use large folios for file mappings; ext4 with Linux 6.12 and a
128 KB readahead window was measured unaffected.
hoardDB tells the kernel its log files are not read in sequence, which keeps
those units at one page, so a sync writes about what the write changed: on
the affected host, seeding a 230 MB dataset now writes under 1 GB to disk,
where an earlier version wrote 67 to 80 GB. Nothing needs to be configured.
To see what a node writes, compare write_bytes in /proc/<server pid>/io
with the data you sent; if it is hundreds of times the data, report it with
the output of uname -r and cat /sys/block/<device>/queue/read_ahead_kb.
The hint is lifted while the server reads the log through at start (after a crash, the log holds writes not yet in the store’s tables), so that replay keeps the kernel’s readahead.
Go memory limit
With no GOMEMLIMIT in its environment, the server gives the Go runtime a soft
memory limit of 75% of the node’s memory limit (the same limit the memtable is
chosen from) and says so in its first log line, hoardDB server starting, as
go_memory_limit. Under a write burst the garbage collector then works harder
instead of letting the heap grow to several times what it holds. Set
GOMEMLIMIT (for example GOMEMLIMIT=3GiB) to choose another value; yours is
always used.
Secondary indexes (declared on a bucket) open their own BadgerDB instance per indexed field and always use the defaults above, regardless of these settings — an index is normally much smaller than its bucket’s primary data.
Networking
| Variable | Default | Description |
|---|---|---|
HOARDDB_MAX_CONNS | 1000 | Max concurrent client connections |
Queries
| Variable | Default | Range | What it bounds |
|---|---|---|---|
HOARDDB_QUERY_CONCURRENCY (query.concurrency) | twice the CPU count | 4 to 256 | How many SCAN, SEARCH, BTREE_RANGE, COUNT and DISTINCT run at once on the node. A query beyond it waits for a slot; it is never refused for one. Point reads and writes take no slot |
HOARDDB_QUERY_MAX_RESULT_BYTES (query.max_result_bytes) | 16777216 (16 MiB) | 1 MiB to 24 MiB; a smaller value is raised to 1 MiB, with a warning | What one query may hold in memory while it builds its answer. A query over it is answered RESULT_TOO_LARGE, and the connection stays up |
Together they bound the memory queries hold on a node: at most the concurrency times the ceiling. A query whose client disconnects stops, and gives its slot back.
Backup and restore
| Variable | Default | Description |
|---|---|---|
HOARDDB_DUMP_PIN_CEILING_SECONDS | 300 | Maximum time a dump of a FIFO/LIFO bucket may hold that bucket’s compaction pinned before the dump fails with a named error instead of pinning indefinitely. See Backup and restore. |
Logging
| Variable | Default | Description |
|---|---|---|
HOARDDB_LOG_LEVEL | info | debug, info, warn, error |
HOARDDB_LOG_FORMAT | json | json or text |
HOARDDB_LOG_OUTPUT | stdout | stdout, stderr, or file path |
Metrics
| Variable | Default | Description |
|---|---|---|
HOARDDB_METRICS_ENABLED | false | Enable Prometheus /metrics endpoint |
HOARDDB_METRICS_LISTEN | 127.0.0.1:9090 | Metrics HTTP listen address. Loopback by default |
With metrics off, the node’s capacity warnings still go to its log and to
hoardDB status; see Is this node full?.
Audit
| Variable | Default | Description |
|---|---|---|
HOARDDB_AUDIT_ENABLED | false | Enable audit logging |
HOARDDB_AUDIT_OUTPUT | /var/log/hoarddb/audit.jsonl | Audit log file path |
Encryption variables
| Variable | Default | Description |
|---|---|---|
HOARDDB_ENCRYPTION_KEYSTORE | off | Where the key-encryption key comes from: off, keyfile, command or systemd-creds. See Encryption at rest |
HOARDDB_ENCRYPTION_KEY_FILE | (none) | Path to the key file, keystore=keyfile only. Must be 0600 and outside the data directory |
HOARDDB_ENCRYPTION_MODE | off | off, on or require. on and require need a keystore; they differ only in a cluster, see Encryption at rest |
key_command (the command keystore’s argument list) is set in the YAML file only
(--config); it has no environment variable in this version.
Debug
| Variable | Default | Description |
|---|---|---|
HOARDDB_PPROF_ENABLED | false | Enable pprof endpoint on :6060 |
YAML Config File
For non-containerized deployments, name a YAML file with --config:
hoarddb-server --config /etc/hoarddb/config.yaml
HOARDDB_CONFIG=/etc/hoarddb/config.yaml does the same; when both are given, the flag wins. The server reads only the file you name: there is no search path, and no file at all is the normal zero-config start. The server logs the file it read.
Settings are layered, lowest to highest:
- the built-in defaults;
- the YAML file;
HOARDDB_*environment variables, which override the file one key at a time.
So a file can hold the settings a host shares, and one environment variable can still change a single value for a single start.
The server refuses to start, with a message that names the problem, when:
- the file named by
--configorHOARDDB_CONFIGdoes not exist or cannot be read; - the file is not valid YAML;
- the file holds a key the server does not define. The message names the key, its line and the section, and suggests the nearest valid key when there is one. A key that is ignored without a word is how a typo turns into a setting you believe is applied; here
data_dr:stops the start instead of leaving the data in./data.
key_command (the command keystore’s argument list, see Encryption at rest) can only be set in this file.
A complete example. Every key below is a key the server defines, and a test parses this block with unknown keys refused, so it cannot drift from the server:
# /etc/hoarddb/config.yaml
server:
listen: "node1:4433" # this node's address in the ring, written exactly as the seeds below list it (each node sets its own); node-to-node traffic uses the next port (4434). HOARDDB_LISTEN defaults to loopback, this machine only. 0.0.0.0 here needs node_id and id@address seeds, as below
node_id: "node1"
max_conns: 1000
replication_factor: 3
tcp:
listen: "0.0.0.0:7433" # driver and CLI address (TCP+TLS); the default is 127.0.0.1:7433, this machine only
# advertise: "node1:7433" # what drivers are told to dial in a cluster; same host as server.listen, any port
storage:
data_dir: "/var/lib/hoarddb"
# blob_dir: "/mnt/blobs" # blob payloads; default <data_dir>/blobs
# max_blob_bytes: 16777216 # largest blob accepted; default 16 MiB, at most 33488896
# max_document_bytes: 25165824 # largest document accepted; default 24 MiB, 4096 to 31457280
# memtable_mb: 256 # see "How big is my memtable, and why"
# unsafe_ack_before_sync: true # benchmarks only: can lose acknowledged writes, see "Storage durability"
# badger_table_sync: stepwise # opt-in, tested on ext4 with a journal only (XFS not yet tested); see "New table files and your filesystem"
# wal_preallocate: auto # auto (default; on for ext2/3/4 only), on, or off; see "Write-ahead log segments"
hash_ring:
# As written this is the earlier hash ring: every node lists all three. With cluster.enabled the
# first node (node1) lists only itself or nobody, and node2 and node3 list the nodes that
# already exist; see "Cluster mode".
seed_nodes:
- "node1@node1:4433"
- "node2@node2:4433"
- "node3@node3:4433"
# Required when seed_nodes is set. Mapped form is recommended.
seed_fingerprints:
- "node1:4433=SHA256:<64 hex>"
- "node2:4433=SHA256:<64 hex>"
- "node3:4433=SHA256:<64 hex>"
tls:
mode: auto # the only mode in this version; any other value stops the server
auth:
token_ttl_seconds: 900
token_max_lifetime_hours: 12
password_min_length: 12
require_root_password: true # a first start needs HOARDDB_ROOT_PASSWORD; recommended in production
argon2_memory: 65536 # KiB, so 64 MiB
argon2_iterations: 3
argon2_parallelism: 4
argon2_salt_length: 16 # bytes
argon2_hash_length: 32 # bytes
logging:
level: info # debug, info, warn, error
format: json # json or text
output: stdout # stdout, stderr, or a file path
metrics:
enabled: true
listen: "127.0.0.1:9090" # the default; use a monitoring-network address to scrape from another host
audit:
enabled: true
output: "/var/log/hoarddb/audit.jsonl"
encryption:
keystore: keyfile # off, keyfile, command or systemd-creds
key_file: /etc/hoarddb/kek # keystore=keyfile only; 0600, outside data_dir
mode: require # off, on or require
# key_command: ["/usr/local/bin/print-kek"] # keystore=command only; YAML only
TLS Modes
What a node serves in this version
Every node generates an Ed25519 keypair and a self-signed certificate on first start and serves it:
{data_dir}/keys/server.key
{data_dir}/keys/server.crt
Clients use trust-on-first-use (TOFU) — fingerprint is pinned on first connect. Identical to SSH.
Custom certificates are not available yet
There is no CA verification and no way to substitute a certificate in this version. So that nobody
believes otherwise, the server refuses to start when HOARDDB_TLS_MODE is anything but auto, or when
HOARDDB_TLS_CERT, HOARDDB_TLS_KEY or HOARDDB_TLS_CA is set. The same applies to the tls: keys of
the YAML file. The error names the setting:
tls.mode (HOARDDB_TLS_MODE) is "custom", but custom certificates are not available in this version: ...
remove the setting (or set HOARDDB_TLS_MODE=auto) to start
Remove the setting to start. If you need to know what a node presents, read its fingerprint from the
start-up log and pin it with HOARDDB_SEED_FINGERPRINTS, as described under “Seed pinning” above.
Transport Details
- Protocol: TCP+TLS 1.3 (mandatory, no downgrade)
- Cipher: ChaCha20-Poly1305 (preferred) or AES-256-GCM
- Key Exchange: X25519
- Keys: Ed25519 (faster than RSA, smaller keys)
Replication Factor
HOARDDB_REPLICATION_FACTOR is the node’s default: the number of copies of
every bucket that does not say otherwise. It is set once, before the server
starts:
export HOARDDB_REPLICATION_FACTOR=3
or in the YAML file, as server.replication_factor. Every node in a cluster
must be given the same value. A value above one is also the switch that makes
the node a cluster member with a write-ahead log, as is setting
HOARDDB_SEED_NODES; a server started with neither is standalone and keeps one
copy of everything. A bucket created on a standalone server keeps its identity
when the server is restarted as a cluster member, so its copies on the nodes
that join are recognised as the same bucket.
| RF | Behavior |
|---|---|
| 1 | No replication (default, dev only) |
| 2 | 1 leader + 1 follower |
| 3 | 1 leader + 2 followers (recommended for production) |
Changing the default takes a restart, and it changes placement for every bucket that follows it.
A bucket can declare its own factor when it is created:
create bucket Sessions { type: hash, replication_factor: 3 }
- The factor is stored with the bucket. It survives a restart and a migration to
another node, and the bucket is placed on exactly that many nodes, whatever
the node default is. A bucket that declares none follows the default, and
show bucketsprints which:rf 3for a factor the bucket declared,rf 1 (inherited)for the node’s. On a node whose copy has left the bucket’s replica set and is kept until the set holds what it holds, the column says(leaving). On the bucket’s owner it also counts the copies in sync and says what is missing:rf 3 (3/3), orrf 3 (1/3, copying to node-b (4h))while a copy is being made, with how long ago it was asked for. A node that holds a copy but is not the owner printsrf 3 (owner elsewhere);describe bucketalways gives the owner’s count. - Write concern keeps its meaning per bucket:
allis the bucket’s factor andmajorityis its majority. A write that names no concern takesmajorityfor a bucket whose factor is above one andoneotherwise. - The create is refused, with what to do instead, when the cluster cannot grant the factor: more copies than the cluster has members, or any factor above one on a standalone server. A standalone server has no cluster and no write-ahead log, so it keeps one copy of everything; replication.md describes how a cluster is formed. A standalone server that is started on a data directory whose buckets declare more than it can hold keeps their factors, serves one copy of each, and says so once in its log.
- The factor can be raised after the bucket is created, with
alter bucket Sessions { replication_factor: 5 }. The bucket then declares its factor, even if it inherited the node’s, and a later change of the node default no longer moves it. The new copies are made by the members that gain them; Replication describes the window and HQL: alter bucket the answers and refusals. - A dump records the factor a bucket declared, and a restore creates the bucket with it. A bucket that declared none inherits the node’s default, in the dump and after the restore. A restore into an existing bucket leaves its factor alone: a merge restores documents, not placement. See A bucket’s replication factor.
show bucketsandshow databaseslist the whole cluster from any node, including buckets the node you asked holds no copy of: a bucket declared below the number of nodes is held by fewer than all of them, and every node still lists it. The node asks every member for its part; if a member does not answer, the listing is refused with the member’s name and you retry once it is back, or remove it.use <database>is not refused: with a member silent the CLI selects the database anyway and prints a note naming the member, because it could not confirm the database exists there; if the name is a typo, the first statement that uses it ends in the not-found that names the fix.
Replication itself is on or off for the whole server, not per bucket: a per-bucket factor chooses how many copies a bucket gets on a cluster; it does not turn replication on.
There is no per-database replication factor in this form. create database MyApp with replication_factor 3; is refused, and it used to be worse: earlier versions
accepted that command, reported success, and discarded the clause, so the
database was created with the node-wide factor while the operator believed a
replica set existed. Put the factor on the buckets that need it. (Ring mode takes a factor on the
database instead: create database app { replication_factor: n }. It is not
usable end to end yet, see Limitations.)
See replication.md for what the factor buys you and what it does not (it never promotes a node automatically: losing one needs an operator).
Logging
Levels
| Level | When | Example |
|---|---|---|
debug | Development only | “Parsed command: insert into User” |
info | Normal operations | “Node node1 joined hash ring” |
warn | Degraded but functioning | “Replication lag > 5s on node2” |
error | Requires attention | “Failed to write segment: disk full” |
Format
json— Machine-readable, structured fields (production)text— Human-readable terminal output (development)
Correlation IDs
Every request gets a UUID v7 correlation ID, propagated through all internal calls and included in every log line. Enables tracing a single request across nodes:
Client → node1 (correlation_id: 0192a3...) → node2 (replication)
Metrics
Prometheus endpoint at http://<listen>:9090/metrics (when enabled).
Key Metrics
| Metric | Type | Description |
|---|---|---|
hoarddb_requests_total | counter | Total requests by method/status |
hoarddb_request_duration_seconds | histogram | Request latency by method |
hoarddb_bucket_operations_total | counter | Total operations by type |
hoarddb_query_in_flight | gauge | Queries holding an execution slot (HOARDDB_QUERY_CONCURRENCY) |
hoarddb_query_admission_wait_seconds | histogram | How long queries waited for a slot, by pool |
hoarddb_query_result_too_large_total | counter | Queries refused with RESULT_TOO_LARGE, by method |
hoarddb_query_cancelled_total | counter | Queries stopped because their client disconnected |
hoarddb_request_faults_recovered_total | counter | Request handler faults the server recovered from, by port (driver or internode): the request failed and its connection closed, and the server kept serving. Any increase is a server bug; the error-level log line with the same fault id has the detail to report |
hoarddb_node_capacity_ratio | gauge | Each resource’s reading over its live limit, by resource (fds, mappings, memory, disk, and blob_disk when the blob directory is on its own filesystem); absent while a resource cannot be measured |
hoarddb_node_capacity_threshold_ratio | gauge | Where each resource turns red, by resource |
hoarddb_node_capacity_sample_errors_total | counter | Samples in which a resource could not be read, by resource |
hoarddb_node_capacity_last_sample_timestamp_seconds | gauge | When the last capacity sample completed |
hoarddb_node_buckets | gauge | Buckets this node serves |
hoarddb_node_memtable_bytes | gauge | The node store’s memtable size for this run; one series per node |
hoarddb_node_encryption_sealed | gauge | 1 when the node’s store was found sealed at rest when it started, 0 otherwise; one series per node |
hoarddb_node_encryption_info | gauge | Always 1; the labels are the configured mode and keystore; one series per node |
hoarddb_node_memory_mappings, hoarddb_node_memory_mappings_limit | gauge | Memory mappings, and vm.max_map_count |
hoarddb_node_memory_anon_bytes, hoarddb_node_memory_limit_bytes | gauge | Anonymous memory (resident plus swapped), and the memory limit it is measured against |
hoarddb_node_disk_used_bytes, hoarddb_node_disk_size_bytes | gauge | The data directory’s filesystem, used and total |
hoarddb_node_blob_disk_used_bytes, hoarddb_node_blob_disk_size_bytes | gauge | The blob directory’s filesystem, used and total; present only when it is not the data directory’s |
hoarddb_node_data_bytes | gauge | Bytes hoardDB itself holds, by part (store_lsm, store_vlog, wal) |
hoarddb_replication_peers_not_in_sync | gauge | Members whose replication stream from this node is stalled, or is still being sent what it missed; 0 is healthy |
hoarddb_replication_lag_entries | gauge | Entries of this node’s log the furthest-behind member has not confirmed; a member sent nothing counts 0 |
hoarddb_replication_lag_seconds | gauge | How long the member that is owed entries has gone without confirming any of them |
hoarddb_cluster_peers_unreachable | gauge | Members whose membership check from this node fails |
hoarddb_replication_entries_stranded_total | counter | Writes this node did not send before it stopped owning their bucket; they are not sent late |
hoarddb_membership_changes_total | counter | Membership changes this node proposed, by outcome (committed, finished_another, already_committed, refused, no_majority, indeterminate); one series per outcome per node |
hoarddb_handoffs_in_progress | gauge | Buckets on this node with a hand-off record; after a join or remove-node it reads 0 on every node once every moved bucket is taken over. Read every node: one with no copy yet, and every node before its placement pass, reads 0 |
hoarddb_handoff_copies_total | counter | Buckets this node copied whole to take them over or to follow their new owner |
hoarddb_handoffs_without_previous_owner_total | counter | Buckets this node took over without their previous owner, after it was removed while down |
hoarddb_handoff_entries_reconciled_total | counter | Writes this node applied from a node that no longer owned their bucket, while the bucket was changing owner |
Which resource is red decides what to do, and for disk, adding a node is not the fix: Is this node full? has the thresholds, the alert rules and the action for each resource. Is the cluster in sync? says what the replication gauges mean and which two to alert on. None of the replication series is labelled: one series per node, nothing per bucket. The membership change counter’s one label is its outcome.
Audit Logging
Append-only log of security-relevant operations. Written to a dedicated file, not stdout.
What Gets Audited
- Authentication: login success/failure
- User management: create, alter, drop user; grant/revoke roles
- Schema changes: create/drop database, define/drop bucket
- Encryption at rest: on an encrypted node, one
key.unwrapentry per start, naming the node master’s key id. A start that fails to unwrap the key is refused, and the failure is still recorded in the audit log as a failedkey.unwrapentry; the server’s log has the error too.
Audit Log Format
{
"time": "2026-09-11T12:00:00Z",
"event": "auth.login",
"user": "alice",
"source_ip": "192.0.2.100",
"result": "success",
"correlation_id": "0192a3b4-c5d6-7890-abcd-ef1234567890"
}
Audit writes are async and non-blocking. If the queue fills (disk full), entries are dropped with a warning.
Encryption at rest
Encryption at rest is per node and off by default, so a node with no configuration starts exactly as before. Turn it on for a new node by choosing a keystore and a mode:
encryption:
keystore: keyfile
key_file: /etc/hoarddb/kek
mode: require
What it gives you, in two sentences: an encrypted node’s bucket contents, and the credentials it
keeps in its data directory, are unreadable without a key that is not in the data directory. The write-ahead log and users.json are also
authenticated, so tampering with them is detected; the node’s store, which holds every bucket’s
documents, indexes, queue entries and blob metadata, is encrypted for confidentiality only,
because its storage engine encrypts without a MAC, so a modification there is not detected.
Encryption belongs to the node
- Every bucket on an encrypted node is encrypted. There is no per-bucket setting. A create that asks for encryption on a node without it is refused.
- A node’s encryption is fixed when its data directory is first started. Starting an
encrypted data directory with
mode: off, or a plaintext one withmode: on, is refused. To change it, back up withhoarddb dump, then restore into a fresh data directory started with the mode you want. - Blob content is encrypted too. Content of 64 KiB and over is written to files that the node
encrypts and authenticates in 64 KiB pieces, under keys kept in
keys/blobs.wrapped; smaller content is stored inside the node’s encrypted store. Back upkeys/blobs.wrappedwith the data directory: the node master you export with-export-keydoes not cover it, and without it the blob files cannot be opened. A node that cannot find it refuses to start rather than create a new one. onandrequirediffer in a cluster, and in what they do with a damaged store. Both need a keystore, both refuse to start over a plaintext data directory, and both refuse what the node cannot encrypt. Each start also checks the store’s files for the marks of a sealed store. Underrequirea store that fails the check (for example a value log written without the store key) stops the start with the file and the way out: restore the node store from a backup, or setencryption.mode: on(HOARDDB_ENCRYPTION_MODE=on) to start anyway. Underonthe node logs one warning, serves, and reports the store as not sealed instatusand on the health endpoint.
The node’s credentials
An encrypted node seals its credential files under the node master, in the same authenticated
envelope as users.json: the cluster token (cluster.token), a standalone node’s signing key
(auth.token.key) and the TLS private key (keys/server.key). The certificate,
keys/server.crt, stays plaintext because it is public.
- A first start writes them sealed. They are never written in the clear, including a token
given in
HOARDDB_CLUSTER_TOKENto a node that is joining. - An existing node seals them on its next start. A node that ran with encryption on before these files were sealed finds them in plaintext and replaces each with its sealed form under the same name, keeping the same token, signing key and certificate. A start stopped part-way leaves each file either plaintext or sealed, never missing, and the next start finishes the job.
- A file the node master does not open stops the start. The error names the file and how to
restore it; the node never generates a new key or token over a sealed file. A sealed file
cannot be copied to another node: give a new node the token
get-tokenprints, inHOARDDB_CLUSTER_TOKEN. get-tokenandrotate-tokenread the configuration’s encryption settings. Run them with the same configuration file orHOARDDB_ENCRYPTION_*variables as the server. Given the keystore, they write the token sealed even if the mode is not set; without it they refuse and change nothing.- A TLS key with no certificate beside it is refused, not replaced: a new key would change the
fingerprint every peer and client pinned. Restore
keys/server.crt, or movekeys/server.keyaside to generate a new identity.keys/server.crtis written owner-only (0600), like every file the server creates. - A credential file that is neither sealed nor a valid plaintext file stops the start, naming the file; it is not sealed again as it is.
- Sealing is one-way: do not start a sealed data directory with an earlier build. A build from
before credential files were sealed cannot read the sealed TLS key, and it replaces the sealed
cluster.tokenwith a newly generated plaintext one, which no other member holds. To go back, restore a backup taken before the upgrade.
Encryption in a cluster
Each node encrypts what it stores under its own key, so a bucket’s data copied to a node that does not encrypt would be written there in the clear. An encrypted node therefore gives a bucket’s data only to a node that says its store is encrypted:
- It refuses to send it. Creating a bucket or writing to one whose replicas include such a node,
migrate, the rebalancing after a node joins or is removed, and read repair are refused. The error names the node and says what to do. Nothing is written on either node when a write is refused, and data queued for a replica that goes down is kept, not sent, if the node comes back unencrypted. A write the owner had already stored when it learnt that a replica is not encrypted is answered with that refusal,write_outcome: appliedand acopies_confirmedthat does not count the replica: the write is kept. Replication to that replica then stalls, with the same error as its reason, and the owner keeps its write-ahead log for it; the replica receives the write, and everything after it, once it says its store is encrypted. Removing it withremove-nodereleases the log. - It refuses to answer with it. A read, a dump or a write for one of its buckets that reaches it through an unencrypted node is refused, and the error names the encrypted node to connect the client to instead. Connect clients to encrypted nodes.
- It asks, and does not remember its way past the answer. A node learns whether a peer is encrypted from that peer, asks again every time it reconnects to it, and keeps the answer across its own restarts only to decide what to refuse early — never as a reason to send.
- The error says which of three things is known. A node that said its store is not encrypted
has to be started from a fresh data directory with encryption on, or removed with
remove-node. A node that runs an earlier version does not say either way: upgrading it is the fix, and nothing about its data needs to change. A node that has never answered may simply be down: start it. - Upgrade every encrypted node before you admit an unencrypted one. These refusals exist only on
the new build. An encrypted node on an earlier build does not check its peers and keeps replicating
to a node that does not encrypt, so a bucket’s data is written there in the clear, and nothing
refuses it. Upgrade the whole cluster first (stop every node, as under
Limitations), and set
encryption.mode: requireon the upgraded nodes so thatjoinrefuses an unencrypted node. requirealso refuses the node itself. Arequirenode will not add an unencrypted node to the cluster, will not vote for one, and will not take its membership from one. Anonnode also refuses to add, or vote for, a node that says its store is unencrypted: the join would never finish, because no data is ever placed on that node, and schema changes would stay paused. The refusal says how to fix it: start the node from a fresh data directory with encryption on, or run a cluster without encryption at rest. Anonnode also refuses a node it cannot ask — one that is not running yet, or does not answer in time: start it and runjoinagain. A member that is already unencrypted (one that came back with a fresh, unencrypted store) is admitted with one warning per node, and every bucket the ring places on it stops accepting writes through the encrypted nodes until it is encrypted or removed. Userequireon every node unless you are converting a cluster.- What still reaches every node, encrypted or not: the names of databases and buckets (a database is created on every node, and a bucket drop is sent to the node holding it) and the user accounts, with their roles and password hashes, which every node needs to authenticate clients. On an encrypted node those are stored encrypted; on an unencrypted one they are not.
- The check guards against a misconfigured node, not a dishonest one. It is each node’s own statement, exchanged between nodes that already hold the cluster token.
The key-encryption key and the keystore
The node generates its own node master key on its first encrypted start and keeps it in the
data directory only in wrapped form (keys/node-master.key.wrapped), encrypted under a 32-byte
key-encryption key (KEK) that you provide and that is never in the data directory. The keystore
is where that KEK comes from:
keyfile— a file holding exactly 32 raw bytes or 64 hex characters (one trailing newline is allowed). The server refuses a key file that is group- or world-readable, or that sits inside the data directory:(umask 077 && head -c 32 /dev/urandom > /etc/hoarddb/kek)command— a program whose standard output is the key, in the same two forms. It is run without a shell, with an empty environment and a 10-second timeout, and its output is never logged. Set it as a YAML list; anyone on the host can read a running program’s arguments, so never put a secret in them:encryption: keystore: command key_command: ["/usr/local/bin/hoarddb-kek"] mode: requiresystemd-creds— the key is read from$CREDENTIALS_DIRECTORY/hoarddb-dek, which systemd provides to a unit that loads the credential. Encrypt it with the TPM;--with-key=autofalls back to a host key on a machine without one, which is weak against a stolen disk:head -c 32 /dev/urandom | systemd-creds encrypt --name=hoarddb-dek --with-key=tpm2 - /etc/credstore.encrypted/hoarddb-dekand in the unit:
[Service] LoadCredential=hoarddb-dek:/etc/credstore.encrypted/hoarddb-dek LimitCORE=0Commands run with the server stopped (for example
hoarddb-server -reset-password) do not see the unit’s credentials. Run them throughsystemd-runwith the same credential:systemd-run --wait --pipe -p LoadCredential=hoarddb-dek:/etc/credstore.encrypted/hoarddb-dek hoarddb-server -reset-password admin
Every start logs the encryption mode, and an encrypted node also logs its keystore and key ids. The first encrypted start prints them once, in full, with the paths of the two wrapped key files.
Checking a running node
Ask the node instead of reading its configuration. In the CLI:
[h]oardDB> encryption
prints whether the node’s store is sealed, its mode and keystore, the key ids, and the files it
writes in the clear whatever the mode. describe bucket, db.<bucket>.stats() and show buckets
print Encrypted: yes or Encrypted: no; it is one fact about the node, the same for every
bucket. On /metrics, hoarddb_node_encryption_sealed is 1 on a sealed node.
“Sealed” is not read from encryption.mode. The node inspects its store’s files once, right after
the store opens (every table carries a key id, the key registry refuses an empty key, the log files
name their key), so a store that is not actually sealed is never reported as sealed. A node
configured to seal whose files do not show it prints Sealed: NO and the reason. Key ids are
fingerprints and are safe to share; none of these views carries a key.
Losing the key
Losing the keystore is unrecoverable data loss. Without the KEK, no bucket, user or log entry on the node can be read again, and turning encryption off does not bring the data back as plaintext. Back up the KEK separately from the data directory: a backup that holds both is a backup anyone can read.
The node master can be exported and imported, with the server stopped, so that a copy of it exists that does not depend on the KEK:
hoarddb-server -export-key /safe/place/node-master.key
hoarddb-server -import-key /safe/place/node-master.key
Both read the same configuration as the server (the keystore must be set up so the node can be
opened). The sealed credential files are sealed under the node master, so an imported node master
opens them as it opens the rest. -export-key writes the raw 32-byte node master, unencrypted, to a file that must be
outside the data directory, with mode 0600; it never creates a master, and it fails if the data
directory has none. Treat that file as you treat the KEK, and encrypt it (with gpg or age)
before it leaves the host. -import-key wraps the file under the keystore you have configured
now, so it also moves a node onto a different KEK. It refuses a file that does not open the
data directory’s existing sealed keys, and refuses to replace a node master that is already there
unless you add -force.
Without an export, keep a copy of keys/node-master.key.wrapped with your data directory backups
and the KEK in a separate place. That restores the node only while that KEK exists.
There is no key rotation command yet. Nothing in this version re-encrypts a node under a new node master, and the node’s other keys (the store key, the write-ahead log’s keys) are not rotated. Replacing the KEK by exporting and importing, as above, is the only change of key you can make.
When the wrapped node master is missing
If keys/node-master.key.wrapped is gone from a data directory that still holds sealed data, the
server refuses to start. It does not create a new node master, because a new one could not open
what is already there, and it writes nothing. The error names the missing file and the sealed
files it found. There are two ways out:
Restore
keys/node-master.key.wrappedfrom a backup of this data directory (it opens with the KEK you have configured).Import the exported node master while the server is stopped:
hoarddb-server -import-key /safe/place/node-master.keyThen start again.
If you meant to start a new, empty node, point data_dir at an empty directory instead. With no
sealed data in the directory, a missing node master is a first start and one is generated.
What encryption at rest does not cover
- A
dumpis plaintext, whatever the mode. It is written the same way from an encrypted node and from a plaintext one. Protect the file the way you protect the data. drop bucketis not a cryptographic erase. Every bucket on a node shares one key, and a dropped bucket’s data stays in the encrypted store until it is compacted away.- Someone who can run code as the server’s user can ask the same keystore for the key. This protects a copied disk or data directory, not a running, compromised host.
- It does not replace full-disk encryption, which also covers swap, journals and freed blocks.
Run with encrypted swap or none, and with core dumps off (
LimitCORE=0): the server’s memory holds the keys.
File permissions and the umask
hoarddb-server makes everything it creates owner-only. At start-up it adds 077 to the
process’s umask, so a file is 0600 and a directory 0700 whatever the umask the shell or the
service manager gave it. Before this, under the common umask 0002, the data directory was 0775
and the store’s own files (*.vlog, *.mem, DISCARD, LOCK) were 0664, readable by anyone in
the server user’s group. The server logs one line when it changes the umask, with the old and new
values.
- It only tightens. A process already running under
0277keeps0277. - It does not change what exists. Files and directories from an earlier start keep their modes
until you run
chmod -R go-rwxon the data directory; Badger creates new table files under the new umask as it compacts. - It is process-wide. The umask belongs to the whole process, so a program the server starts
(the
commandkeystore’s program) runs under it too. There is no setting to turn it off. - What it breaks: anything that read the data directory through group membership, such as a
backup agent running as another user in the server’s group, can no longer read it. Back up with
hoarddb dump, or give the backup agent the server’s user.
Prompt Themes
The CLI prompt can be themed from a file. The default is the logo palette;
a theme in ~/.hoarddb/theme.yml overrides it.
version: 1
name: my-theme
styles:
brand: {fg: "#f1efe8", bg: null} # "hoard" and the [ ] around the mark
accent: {fg: "#a99cf5", bg: null} # the mark's h and "DB"
tile: {fg: "#f1efe8", bg: "#3c3489"} # the logo block, for a format that uses it
database: {fg: "#6f63d6", bg: null} # [dbname]
error: {fg: "#f07178", bg: null} # the `CLI error:` prefix
prompt_format: "{brand:[}{accent:h}{brand:]}{brand:oard}{accent:DB}{database: [%database]}> "
prompt_format_nodb: "{brand:[}{accent:h}{brand:]}{brand:oard}{accent:DB}> "
The default prompt is one line of foreground colour: the [ ] and hoard in
the off-white brand, the h and DB in the lightened indigo accent, so the
mark reads as part of the wordmark. No span paints a background, which keeps it
legible whatever the terminal background is — the logo indigo #3c3489 is
1.78:1 as text on black and would be unusable, so no text span uses it. The
tile style still carries that logo block for a prompt_format that wants it,
written {tile:[}{tile:h}{tile:]}.
The directory is 0700, the file 0644, created only if the directory does not
exist. Precedence, lowest to highest: the built-in default, the file, the
HOARDDB_THEME environment variable (a path), and --theme.
A partial file is a recolouring, not a replacement. Everything it does not
name comes from the built-in theme: set only styles.accent.fg to change the
mark’s colour and keep the rest of the prompt, styles and both format strings as
they are. This is the usual file — most people want one colour changed. Run
theme to see what resolved, including which file it came from.
--theme plain (or HOARDDB_THEME=plain) disables colour entirely; docs and
screenshots pin it so output does not depend on whose home directory ran it.
The format grammar: %database is the current database (empty when none is
selected), {style:text} spans colour their text, %%, {{ and }} are the
escaped literals, and a span whose expansion is empty is omitted — which keeps
the [ ] around an absent database from appearing.
Colour is on only when all of these hold: stdout is a terminal, NO_COLOR
is unset, and TERM is set and not dumb. A piped session therefore carries no escape
bytes. A broken theme file — invalid YAML, a typo in a key, a colour that is
not #rrggbb, an unknown style name, an unclosed brace — prints one warning
naming the file and falls back to the default theme; it never stops the REPL.
The terminal’s default background is queried once per session (OSC 11), and a
tile style swaps its two colours when the terminal answers light — so a
theme file that paints the logo block stays legible either way. The default
prompt paints no block, so the answer changes nothing for it.
HOARDDB_BACKGROUND=dark|light pins the answer for terminals that never reply.
Run theme in the REPL to see the resolved name, file, colour state and
background.
The default palette is built for a dark terminal. brand is 18.25:1 on
black but 1.15:1 on white, so on a light terminal the default prompt is hard to
read. That is a property of the palette, not of this release: only tile was
ever adapted. If your terminal has a light background, either use --theme plain, or write a theme.yml with dark foregrounds — brand: "#1c1b18" and
accent: "#4a3fa8" are 17.22:1 and 8.18:1 on white, both AAA. That pair is for
a light terminal only: no single foreground is legible on black and white,
which is why the terminal’s background is queried rather than guessed.
Source: docs/user/configuration.md in the repository.