High availability
This page is for operators who run Dakera v0.12.3 on more than one node. It explains how a cluster replicates and elects a leader, the settings it needs, how to add, remove and upgrade nodes, and what to monitor.
A Dakera cluster uses gossip-based membership (SWIM over UDP), lease-based leader election and versioned replication: every node accepts writes and holds a full copy of the data. Nodes find each other through seed addresses; the elected leader runs the background jobs that rewrite stored data. Each node uses its own storage (on S3, its own bucket).
Cluster environment variables
| Variable | Default | Description |
|---|---|---|
DAKERA_ | false | Enable multi-node cluster |
DAKERA_ | generated once | Stable node identifier (e.g. node-1). If unset, generated once and kept in {data root}/ so the node stays the same member across restarts. DAKERA_NODE_ID is the legacy alias |
DAKERA_ | — | v0.12. Shared secret, at least 16 characters, the same on every node. Every node-to-node request under /internal/* must carry it and gossip packets are authenticated with it. Required on every node, fresh install or upgraded, from v0.12.1: a node without it exits with code 78 before serving (on v0.12.0 an upgraded node started without it, unauthenticated) |
DAKERA_ | 15000 | v0.12. Leader lease and the one failure-detection timing; every other cluster timing is derived from it (1000–600000) |
DAKERA_ | 604800 | v0.12. How long a delete tombstone is kept; set it above the longest node outage you tolerate without wiping the node |
DAKERA_ | 60000 | v0.12. A replicated change whose version is further ahead of this node's clock is refused and retried — keep clocks NTP-synchronized |
DAKERA_ | <data root>/ | v0.12. Durable outbox for changes a peer has not acknowledged |
DAKERA_ | — | Comma-separated gossip bootstrap addresses, for example dakera-. DAKERA_ is the legacy alias |
DAKERA_ | 7946 | SWIM gossip port — must be open between nodes |
DAKERA_ | 0. | Gossip bind address (useful on multi-NIC hosts). DAKERA_ is the legacy alias |
DAKERA_ | http:// | The API address peers use to reach this node; its host is also the advertised gossip host when the bind address is 0.0.0.0. Set it on every node |
DAKERA_ | — | Optional Redis shared by the nodes, for example redis://: a shared cache, and one rate limit for the whole cluster instead of one per node |
How the cluster works in v0.12
Any node accepts writes. There is no leader-only write path. A write is committed on the node that took it, then sent to every peer's /internal/sync/* route and awaited (5 s per peer). The client is answered 200 whatever the peers said. A peer that did not acknowledge gets the change later from the node's durable outbox. Every node holds a full copy of the data; the design favors availability (both sides of a network partition keep accepting writes and converge when it heals).
Versions and tombstones
- Every replicated record carries a hybrid logical clock version (
_dakera_hlc, never returned to clients; client metadata may not use that key,400). - Every delete, namespace delete, revoked API key and removed quota leaves a tombstone, kept for
DAKERA_CLUSTER_TOMBSTONE_RETENTION_SECS(default 604800, 7 days). Set it above the longest outage of a node you expect to bring back without wiping it. - A change is applied only if its version is newer than the stored record and any tombstone: last writer wins (highest version; on equal time the counter, then the node id). A write acknowledged to a client is never deleted by a node that never had it.
- Knowledge-graph edges are not versioned and converge to the leader's copy.
Clock drift
A change whose version is more than DAKERA_CLUSTER_MAX_CLOCK_DRIFT_MS (default 60000) ahead of the receiver's clock is refused and retried. A node whose own clock is that far ahead of more than half its peers refuses local writes (503) and reports degraded with clock_ahead_ms in /health until the clocks agree. Keep NTP running on every node.
Reconciliation
A (re)starting node merges completely with a live peer (the leader first) before it answers digests or stands for election; it waits up to two leases for a peer. Every 5 minutes each follower merges with the elected leader in both directions, by version, over every namespace either side holds, including API keys, quotas, namespace configs, sidecars and attachments, and the leader merges with every live follower. Per-namespace digests are cached, so an unchanged cluster costs nothing to compare. Each node persists since when its data is complete (data_since): a node whose data starts later than a record's can never make others delete it.
Leader election
- Lease-based with terms and one vote per term. The quorum is a majority of the whole membership (every member ever seen minus graceful leaves), and a lease is extended only by heartbeats a quorum acknowledged, so a minority partition stops leading within one lease.
- A candidate that has not finished its startup merge, or whose data starts later than the voter's, is refused.
- The leader runs the background jobs that rewrite stored data: decay, auto-pilot, consolidation and the re-embed jobs. TTL expiry runs on every node.
DAKERA_ELECTION_AUTO_ELECT(defaulttrue) is a topology choice: set it tofalseon nodes that must never stand for election.
One timing governs failure detection, DAKERA_ELECTION_LEASE_MS (default 15000, accepted range 1000 to 600000; a value outside it is an invalid value). Every other timing derives from it:
| Timing | Derived value | At the default |
|---|---|---|
| Leader renewal / heartbeat | lease / 5 | 3000 ms |
| Election timeout | lease x 2/3 | 10000 ms |
| Candidacy backoff | lease / 15 | 1000 ms |
| Peer health check interval, and outbox retry | lease / 3 | 5000 ms |
| Peer health timeout | lease x 2/3, at least three gossip periods | 10000 ms |
| Startup reconciliation wait | 2 x lease | 30 s |
A longer lease suits a WAN or a loaded network; a shorter one detects failures faster. Reconciliation runs every 5 minutes regardless.
Durable outbox
Broadcasts a peer has not acknowledged are written to DAKERA_CLUSTER_OUTBOX_DIR (default {data root}/cluster-outbox) before they count as queued, and replayed in order at startup. The outbox holds at most 64 MiB; the oldest entries are evicted when it is full and are then repaired by reconciliation. A change a peer refuses for good (a 4xx other than 408, 429, 401 and 403) is dropped, logged and repaired by reconciliation instead of blocking that peer. A write whose client went away after it was committed is still replicated.
Authentication
Cluster mode requires DAKERA_CLUSTER_SECRET (at least 16 characters, identical on every node) on every node, fresh install or upgraded (v0.12.1), and a stable DAKERA_CLUSTER_NODE_ID on a fresh install. A node without the secret exits with code 78 before it serves anything; dakera --check-config reports a missing or short secret. On v0.12.0 an upgraded node could start without it, which left POST /internal/sync/key/add open: anyone who reached the API port could mint a SuperAdmin key. An upgraded node without a node id generates one, keeps it in {data root}/cluster-node-id, and is degraded in /health (cluster_auth) until it is set. /internal/* requires the secret and answers 404 on a node that is not in cluster mode; gossip packets carry an HMAC-SHA256 tag with a send time and nonce, and replays are dropped. The secret travels in every peer request and traffic is not encrypted: use a private network or TLS between nodes. See Security.
One S3 bucket per node
Cluster nodes must not share one S3 bucket as their store. Every S3 node keeps a heartbeat marker (_dakera_cluster/nodes/<node id>.json, refreshed every 30 s). A node that finds another node's live marker (younger than 120 s) in its bucket refuses to boot on a bucket that began its life on v0.12 (exit 78) and starts with a topology warning and shared_store degraded on a bucket that already held data. Backups are written to the backup store of the node that took them (node_id in GET /admin/backups); a restore or download through another node answers 409 naming the owner, unless the nodes share one backup bucket.
Observing a cluster
GET /admin/cluster/status (global admin) reports cluster_id, state (healthy, degraded, initializing, maintenance), node_count, total_vectors, namespace_count, redis_healthy, the node's election view (state, current_term, leader_id, fencing_token, lease_remaining_ms, member_count, alive_members, quorum) and, in cluster mode, replication:
replication field |
Meaning |
|---|---|
outbox_entries |
Broadcasts queued for peers that have not acknowledged them |
outbox_durable |
The outbox is on disk and survives a restart |
sync_, outbox_, refused_total |
Failed, evicted and permanently refused broadcasts since start |
reconciled_, pushed_ |
Namespaces where reconciliation applied or pushed newer records |
versioning_, tombstones |
Versioning on, and the delete tombstones held |
data_since_ms, startup_, reconcile_ |
Completeness of this node's data and the peer it reconciles against |
clock_ahead_ms |
Present while this node's clock is too far ahead (writes refused) |
Compare the vector counts and replication across nodes to see whether the cluster has converged. Metrics: dakera_cluster_sync_outbox_entries, dakera_cluster_sync_failures_total, dakera_cluster_sync_outbox_dropped_total, dakera_cluster_sync_outbox_persist_failures_total, dakera_cluster_sync_refused_total, dakera_cluster_sync_superseded_total, dakera_cluster_sync_held_entries, dakera_cluster_replication_queue_dropped_total, dakera_cluster_reconcile_total, dakera_cluster_reconcile_namespaces_total, dakera_cluster_reconcile_pushed_namespaces_total, dakera_cluster_tombstones, dakera_cluster_tombstones_collected_total, dakera_cluster_clock_ahead_ms and dakera_cluster_clock_skew_refused_writes_total. /health degraded components that concern clusters: cluster_gossip, cluster_outbox, cluster_auth, cluster_sync_held and shared_store. The v0.12 alert rules cover held changes, dropped outbox entries and clock skew (Operations).
Maintenance mode
POST /admin/cluster/maintenance/enable with {"reason": "...", "node_ids": [...], "reject_requests": true, "duration_minutes": 30} puts the node that receives the request into maintenance mode. It is held in memory by that node alone (not replicated, ended by a restart); send the request to each node that should enter it. With reject_requests every request outside /admin, /v1/admin, /ops, /health, /metrics and /internal is answered 503 with Retry-After; gRPC calls other than Health fail UNAVAILABLE. POST /admin/cluster/maintenance/disable is 409 while maintenance jobs run unless force is set. GET /admin/cluster/maintenance shows the state.
Operating limits
- A node that is lost for good (never left gracefully) keeps counting toward the quorum, and there is no admin endpoint that removes a member. Stop nodes with SIGTERM so they announce a graceful leave.
- Run an odd number of nodes (3 or 5): a majority of 3 is 2, of 5 is 3.
- A cluster is a replicated topology: every node stores a full copy.
Scaling procedures
Adding a node
# 1. Create the node's own S3 bucket, then start the node with seeds pointing at the cluster
docker run -d \
--name dakera-4 \
-e DAKERA_CLUSTER_MODE=true \
-e DAKERA_CLUSTER_NODE_ID=node-4 \
-e DAKERA_CLUSTER_SECRET=$DAKERA_CLUSTER_SECRET \
-e DAKERA_CLUSTER_SEED_NODES=dakera-1:7946,dakera-2:7946 \
-e DAKERA_API_ADVERTISE=http://dakera-4:3000 \
-e DAKERA_S3_BUCKET=dakera-4 \
-e DAKERA_ROOT_API_KEY=$DAKERA_ROOT_API_KEY \
-p 3303:3000 -p 7949:7946/udp \
ghcr.io/dakera-ai/dakera:0.12.3
# 2. Verify the node joined the cluster
curl -H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
http://localhost:3000/admin/cluster/nodes
# 3. The new node merges with the leader before it answers digests or stands for election
curl -H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
http://localhost:3000/admin/cluster/status # replication.startup_reconciled: true
Removing a node
# Stop it gracefully (SIGTERM) so it announces a leave: a node that is lost for good
# keeps counting toward the quorum, and no admin endpoint removes a member.
docker stop dakera-4 && docker rm dakera-4
Rolling upgrade procedure
Upgrade cluster nodes one at a time, so the cluster keeps serving throughout. A cluster that already runs v0.12.0 with DAKERA_CLUSTER_SECRET set upgrades to v0.12.1 this way, keeping the same secret. A v0.12.0 cluster without the secret, or a v0.11.108 cluster, has extra rules:
- Set
DAKERA_CLUSTER_SECRETon every node and upgrade them in one window (v0.12.1). A cluster node without it refuses to start, and nodes with and without it do not see each other (v0.11 nodes send none). Generate one value (openssl rand -hex 32) and set it on every node; rundakera --check-configfirst.- Recommended — one window: stop every node, set the same secret on each, start them all on v0.12.1 or later. The cluster is unavailable for the restart only; no data is lost.
- Rolling: upgrade one node at a time, each started on v0.12.1 or later with the secret. Until the last node is done the cluster is two groups that do not see each other: each elects its own leader, replicates only within itself and keeps serving from its own data, so a client may not see a write made in the other group. Once every node runs v0.12.1 or later with the secret they form one membership, elect one leader and reconcile every node's data by version. Keep this window to minutes.
- Rotating the secret needs the same window: nodes on the old and new values do not see each other until all have the new one.
- An upgraded node without
DAKERA_CLUSTER_NODE_IDgenerates a stable id (cluster_authdegraded); set one on every node. - Changes a v0.11 node has no route for (namespace and runtime configuration, attachments, record writes) wait in the sending node's durable outbox until that node runs v0.12.
- In an encrypted cluster (
DAKERA_ENCRYPTION_KEY) v0.12 nodes hold changes carrying$enc$v2$text for v0.11 peers (cluster_sync_heldin/health) and deliver them once the peer is upgraded; clients reading from a v0.11 node do not see them until then. - Give each node its own S3 bucket.
- To go back, wait until every node's outbox is empty, stop every node and roll back the whole cluster: Rolling back to v0.11. v0.11.108 ignores
DAKERA_CLUSTER_SECRET.
Full procedure: Upgrading to v0.12.
| Step | Command | What happens |
|---|---|---|
| 1. Enter maintenance (optional) | POST / on that node with {"reason":"upgrade","reject_ | The node answers 503 with Retry-After to everything outside /admin, /v1/admin, /ops, /health, /metrics and /internal; health probes keep answering, so a load balancer keeps routing to the node unless you also take it out of rotation. Held in memory by that node alone |
| 2. Upgrade | docker pull && docker restart | Stop the node, start the new image on the same data and environment |
| 3. Health | GET / | Confirm the new version is ready (/health lists config_warnings and degraded components) |
| 4. Converge | GET / | Wait for replication. to drain and startup_ |
| 5. Repeat | — | Move to the next node (a restart ends maintenance mode; POST / ends it explicitly) |
Docker Compose cluster
The dakera-deploy repository has a three-node Compose file (docker/docker-compose.ha.yml, with Traefik, Redis and MinIO). It was written for v0.11: its nodes share one MinIO bucket, set no DAKERA_CLUSTER_SECRET, and set variables v0.12 does not read. A v0.12.1 node refuses each of these (the shared bucket and unknown names on a fresh install; the missing secret on every node). Before you start it with v0.12.1:
- give each node its own bucket (
DAKERA_S3_BUCKET) and create the buckets; - set the same
DAKERA_CLUSTER_SECRET(at least 16 characters) and a distinctDAKERA_CLUSTER_NODE_IDon every node; - run
dakera --check-configwith each node's environment and remove the names it reports as unknown.
# After adapting the file as above
git clone https://github.com/dakera-ai/dakera-deploy
cd dakera-deploy/docker
cp .env.ha.example .env.ha # set your keys and secrets
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d
# Traefik publishes the API on port 3100
curl -H "Authorization: Bearer $DAKERA_ROOT_API_KEY" \
http://localhost:3100/admin/cluster/status
Kubernetes
The published Helm chart (dakera-helm) does not configure cluster mode: dakera.replicaCount scales one Deployment whose replicas share the same storage settings, and it sets no gossip seeds, cluster secret or per-node bucket. For a v0.12.1 cluster on Kubernetes, run one workload per node with its own DAKERA_CLUSTER_NODE_ID, bucket and stable address, the same DAKERA_CLUSTER_SECRET (from a Kubernetes Secret), the settings in the table above, and probes on /health/live and /health/ready.
Monitoring HA health
Key signals in a clustered deployment (all emitted by the server):
| Signal | Alert on | Meaning |
|---|---|---|
dakera_ | growing, or non-zero for long | Broadcasts a peer has not acknowledged |
dakera_ | any increase | The outbox was full and evicted entries (reconciliation repairs them) |
dakera_ | > 0 outside a rolling upgrade | Changes held for peers that still run v0.11 |
dakera_ | > 0 | This node's clock is too far ahead; it refuses writes |
dakera_ | > 0 | cluster_gossip, cluster_outbox, cluster_auth, cluster_, shared_store |
GET / election. | < expected node count | A node has left or failed; compare with election.quorum |
Gossip ports
The defaults suit most deployments. Change them for a port conflict or a multi-NIC host:
| Variable | Default | When to adjust |
|---|---|---|
DAKERA_ | 7946 | Port conflict with another service |
DAKERA_ | 0. | Multi-NIC hosts: bind to one interface |
/internal/*). Allow both between cluster nodes. A longer DAKERA_ELECTION_LEASE_MS suits high-latency networks.Disaster recovery
For cross-region disaster recovery:
- Backup to remote S3 — schedule daily backups to an S3 bucket in a different region via the admin API (see Deployment → Backup & Restore).
- Full cluster restore — provision new nodes, then restore a stored backup with
POST /admin/backups/restoreor upload a downloaded bundle withPOST /admin/backups/upload(S3 storage only; globalsuper_admin) on one node. The restored writes replicate to the other nodes. - Recovery point — whatever was written after the last backup is lost. For a smaller window, back up more often or replicate the node buckets to another region.
Admin endpoints
| Endpoint | Description |
|---|---|
GET / | Cluster state, election view and replication state |
GET / | Nodes with state, role, address, vector count and last heartbeat |
GET / | Replication information |
GET /, POST / | Shard map and rebalancing |
GET / | Maintenance state of the receiving node |
POST /, /disable | Maintenance mode of the receiving node (disable is 409 while maintenance jobs run, unless force) |
Production checklist
- Odd replica counts — run 3 or 5 nodes for clean quorum math
- One S3 bucket per node — nodes must not share a bucket as their store
DAKERA_CLUSTER_SECRETand a stableDAKERA_CLUSTER_NODE_IDon every node, on a private network or behind TLS — a node without the secret does not start (v0.12.1)- NTP on every node — a node whose clock runs ahead refuses writes
- Stop nodes gracefully — a node lost for good keeps counting toward the quorum
- External load balancer — place outside the Dakera node pool
- Monitor all nodes — scrape
/metricson every node; alert onelection.alive_members, the outbox (dakera_cluster_sync_outbox_entries) and clock skew - Firewall gossip port 7946 — restrict to cluster nodes only (UDP), and the API port to the load balancer and peers
- Rolling upgrades — one node at a time, waiting for
/health/readyand an empty outbox - Cross-region backups — replicate to a separate S3 bucket for disaster recovery
- Test failover — periodically stop a node and verify automatic recovery
Want zero-ops HA? Dakera Cloud handles clustering, replication, and failover — you ship agents, not infra.
Join Cloud Waitlist →