Tritium is a RAM-only, zero-dependency key-value store that speaks the Redis protocol. Each node is one static Go binary with its own in-memory store — or, if you prefer, a small server in front of a RESP store (Valkey, Redis, Garnet, anything that speaks RESP). Nodes find each other by gossip and replicate every write into each other's stores, so any node answers for any key, and any Redis client can talk to any node.
The tritium-wails desktop client talks to it. The design notes live in this gist; docs beyond this file are indexed in docs/.
A single node, nothing else to install:
go run ./cmd/tritium # loads .env if present; environment overrides itThree nodes on bare metal, each with its own embedded store:
make cluster
go run ./cmd/tritium-monitor # live dashboard over 8080-8082 (each store read through its node); -config node.env takes addresses and passwords from a node's env file
make cluster-downThe same three nodes each in front of a Valkey primary and replica, the way a node runs with an external store:
podman compose up --build # or: docker compose up --buildPrebuilt binaries for Linux and macOS, amd64 and arm64, plus 32-bit ARM for a
Pi Zero, are on the releases page;
each tarball holds tritium, tritium-cli, tritium-monitor, tritium-msg
and tritium-load. make dist builds the same set locally, and
brew install --formula packaging/homebrew/tritium.rb installs a release.
Any Redis client works. From the shell:
valkey-cli -p 8080 set hello world EX 3600
valkey-cli -p 8081 get hello # any node answers
valkey-cli -p 8080 info tritiumFrom Python — see examples/python/ for a runnable version, including one
that reads back a value sealed the way the Go client seals it:
import redis
r = redis.Redis(port=8080, password="change-me")
r.set("hello", b"world", ex=3600)
r.get("hello")From Go, without pulling in a Redis library, and with values encrypted before
they leave the process — examples/go/ runs this against a real node:
import "github.com/we-be/tritium/pkg/tritium"
client, err := tritium.NewClient(&tritium.ClientOptions{
Address: "localhost:8080",
Key: tritium.KeyFromPassphrase("correct horse battery staple", "my-app"),
})
if err != nil {
return err
}
defer client.Close()
err = client.Set("hello", []byte("world"), new(3600)) // TTL in seconds; nil uses the server default
value, err := client.Get("hello") // tritium.ErrNotFound when missing or expired
opts, err := tritium.OptionsFromEnv(".env") // or reach the node next door from its own env file
nodes, err := client.Nodes() // the cluster viewtritium-cli wraps that client for the shell and adds scan, where, nodes, info, clients and events:
export TRITIUM_KEY=$(openssl rand -hex 32)
go run ./cmd/tritium-cli set hello world # sealed with $TRITIUM_KEY
go run ./cmd/tritium-cli get hello # world
valkey-cli -p 8080 get hello # "TE1..." ciphertext
go run ./cmd/tritium-cli scan 'hel*' # every matching key, its type and TTL
go run ./cmd/tritium-cli where hello # which nodes hold it, the TTL on each, and whether they agree
go run ./cmd/tritium-cli del hello
go run ./cmd/tritium-cli nodes # every node's state, weight, replicas, keys, memory and writes, from this node's view
go run ./cmd/tritium-cli info tritium # this node's replicas, held and queued ones, ownership, stamps
go run ./cmd/tritium-cli clients # who is connected to this node, and what each last did
go run ./cmd/tritium-cli events -since 1h # this node's view of every node's cluster events| Command | Notes |
|---|---|
SET key value [EX seconds | PX millis] [NX] |
Without an expiry the key gets the default TTL |
SETEX key seconds value |
|
GET key, GETDEL key, MGET key [key ...] |
|
DEL key [key ...] |
|
EXISTS key [key ...] |
|
TTL key |
|
EXPIRE key seconds [NX | XX | GT | LT] |
Seconds must be positive; use DEL to remove a key |
ZADD key score member [...] |
Plain form only; the set's TTL is refreshed to the default |
ZRANGEBYSCORE, ZREM, ZREMRANGEBYSCORE, ZCARD |
Passed through; writes replicate |
SCAN cursor [MATCH pattern] [COUNT n] [TYPE t], TYPE key, DBSIZE |
Read the local primary, like GET; the cursor is opaque. KEYS stays unsupported — it has no cursor |
PING, ECHO, AUTH, HELLO, QUIT |
RESP2 by default, RESP3 after HELLO 3 |
INFO [section], COMMAND, SELECT 0 |
Enough for client libraries to connect cleanly |
CLIENT SETNAME name, CLIENT GETNAME, CLIENT ID, CLIENT LIST |
A connection says what it is — worker, bridge, CLI — and LIST says who is connected: id, address, name, age, idle, identity, last command. LIST is for the node's own identity and peers; a prefix user is refused |
TRITIUM.NODES |
The cluster view as JSON |
TRITIUM.GOSSIP <node-json> |
Peer-only. What nodes send each other; replies with the view |
TRITIUM.REPLICATE cmd [args...] |
Peer-only. A write's owner fans this out to every other node's primary |
TRITIUM.FORWARD cmd [args...] |
Peer-only. A write for a key this node doesn't own, sent on to the owner |
TRITIUM.PEERLINK <node-json> |
Peer-only. Hands this connection to the node that answers, which serves the peer over it from then on (docs/cloud.md) |
ACL WHOAMI |
Which identity the connection carries |
Every key expires; the default TTL is 17600 seconds. XX and KEEPTTL are
not supported. Every key has one owner among the live nodes, and its writes
are carried out there (see below), so NX is decided in one place: two
nodes racing the same claim get one OK between them.
Each node owns one RESP primary: its own, in-process, unless
SECURE_STORE_ADDRESS points it at an external one (replicate that however
you like; the compose file gives each one a replica). That connection is
plaintext unless SECURE_STORE_TLS=true, so an external store belongs on
loopback, on the same container network, or behind TLS. The embedded store
holds strings and sorted sets, expires keys on time, walks SCAN without
ever handing a key out twice, and is reached over RESP through connections
that never leave the process, so it behaves exactly like an external store
would — a node restart empties it, and the peers fill it back on rejoin. A
write is carried out by the key's owner — the live node that rendezvous
hashing, weighted by each node's ELECTRONEGATIVITY, picks for that key, the same on every node that agrees on the
members — which applies it to its own primary with SETEX and fans it out
to every other node's primary, the node that took the client's command
included, before answering. A node handed a write for a key it does not own
forwards it as TRITIUM.FORWARD; if the owner cannot be reached it applies
the write itself and fans it out, as every node did before ownership, and a
held or gone peer stops being picked. So the writes to one key are ordered
in one place and NX holds cluster-wide, except in the moment two nodes
disagree about the members — a replication timeout, not a key's lifetime.
KEY_OWNERSHIP=off restores local-first writes. Every string write also
carries a stamp — a hybrid clock: the millisecond, a count within it, the
node — and the embedded store applies a write only if its stamp is newer
than the key's last, keeping a tombstone after a delete so an older write
arriving late cannot bring the key back. So whatever order writes reach a
node, in that disagreement window or across a partition, every node ends
with the same value: the later write as far as the fleet's clocks agree.
Sorted sets are not stamped; their members are written independently and
apply as they come. An external store keeps no stamps, so a node in front
of one settles by arrival order, as before. Reads hit the local primary
only. A peer that stops answering is held: writes note the keys it
missed instead of waiting on it, and every 5 s the node replays them — the
current value, or the deletion — until it answers again. Every write waits
for its peers by default, so a key read from any node right after the answer
is there — except on a peer at the other end of a link, which is on another
network and is fed in order from a queue, so no write waits out the internet.
REPLICATION=async feeds every peer that way, answering once the local store
has the write, and tritium-load -peer shows the lag that buys. A peer that falls too far behind is held and
repaired like one that stopped answering.
Each node keeps its own cluster events — attach, detach, hold, repair,
stall, evict, resync, and its own start — in tritium:events:<node id>, a
sorted set scored by time and capped at a day and a few hundred entries so a
flapping peer can't grow it without bound. It replicates like any other key,
so go run ./cmd/tritium-cli events [-since 1h] [-node NAME] shows what
happened across the whole fleet from any one node, and the monitor's Recent
Events panel reads the same log.
Membership is gossip. A joining node asks any member for TRITIUM.NODES,
adopts the view, and announces itself to everyone in it with
TRITIUM.GOSSIP; after that each node swaps views over the same command
every 5 seconds with a random peer, and with any peer it has not heard from
since the last round, so a peer that cannot dial back is kept fresh. A peer silent for 10 s is degraded, for 15 s
is down and dropped from replication, and for 60 s is forgotten. Every live
peer is attached and has the other's store copied over: a peer that restarted
since it was last seen is a fresh incarnation and stale, so the survivor's keys
win there; a newcomer, or a peer back from a partition both sides lived
through, keeps what it holds and only has its gaps filled — and what it
missed while the link was down, which the other side noted while holding it,
is replayed on top, so a key updated on one side of a partition reaches the
other once it heals, and a key written on both sides ends up, on both, with
the later write by its stamp. A node whose own clock stops for
longer than 15 s (stopped, asleep, starved) knows it was the one away and
rejoins as a fresh incarnation itself. Node-to-node traffic uses
the same port and TLS settings as clients, authenticated as the peer user.
A node that cannot be dialed — behind NAT, on another network from the rest
of the fleet — sets LINK_ADDRESS instead of JOIN_ADDRESS: it opens the
connections itself and is served over them with TRITIUM.PEERLINK, so
gossip, replication, holding and repair all work unchanged over a socket it
opened rather than one that dialed it. That is how a fleet gets a node that
is always reachable — in the cloud, rather than behind a home NAT — without
a VPN. See docs/cloud.md for the design, what changed on the
wire, and what it costs to run one.
pkg/messenger is a secure messenger on top of tritium, standard library
only, built from one-to-one sessions: a name can hold several devices and
belong to groups, but every message travels over a pairwise Double Ratchet,
never a shared key. Identities are an Ed25519 signing key and an X25519 agreement
key, published as a signed bundle under id:<name>. A session starts with an
X3DH-style agreement, so you can message someone who is offline, and runs a
Double Ratchet from there: every message has its own key, and every change of
direction mixes in fresh Diffie-Hellman, so a copied device state stops
reading the conversation as soon as the other side has answered again. The
signed prekey rotates weekly and retired ones are forgotten after thirty days.
Mailboxes are named by secrets derived from the session, so nodes can't see
who is talking to whom; first contact seals the sender's identity so a node
sees only an ephemeral key; the ratchet header is encrypted too, so a node
can't count messages per direction; messages are padded so their sizes say
little; and everything expires.
A message is deleted from the server only by the read after the one that
delivered it, so a client that stores its state between reads never loses one.
go run ./cmd/tritium-msg init alice # identity in ~/.tritium-msg, published as id:alice
go run ./cmd/tritium-msg lookup bob # prints bob's fingerprint: compare it with bob in person
go run ./cmd/tritium-msg send bob "hey"
go run ./cmd/tritium-msg recv -watch
go run ./cmd/tritium-msg status # every session: peer, last used, messages each way, waiting unreadPrograms use it as request and reply. serve prints each incoming message as
one JSON line on stdout and sends back the JSON lines it reads on stdin; ask
speaks from a throwaway identity, so processes never share ratchet state, and
refuses a peer whose fingerprint is not the one pinned with -fp:
go run ./cmd/tritium-msg serve -name bob # stdout: {"from","fp","time","body"} per message; stdin: {"fp","body"} per reply
echo '{"q":"lunch"}' | go run ./cmd/tritium-msg ask -fp 23FK7-ISTCB-… bob # prints bob's reply; exit 2 on no reply, 3 on a wrong pinNames are first come, first served per node; the fingerprint is the identity, the name is a convenience.
A name can hold several devices. The name's own identity keeps publishing at
id:<name> exactly as before, so old clients keep working against a name
that has grown devices; each device is a second, ordinary identity of its
own — its own fingerprint, sessions and mailboxes — published under
id:<name>/<device>. What makes it a device rather than an unrelated name
is a certificate: the name's identity signs the device's long-term keys, so
nobody can attach a device to a name they don't hold. send fans out to the
primary and every certified device, each over its own pairwise session; a
message delivered to one device is not visible on another, since there is
no state shared between them — the worker end of this is meant to pin the
list of device fingerprints it expects, the same way it already pins one.
go run ./cmd/tritium-msg -state ~/.tritium-msg-phone init bob/phone # the device publishes itself first
go run ./cmd/tritium-msg -state ~/.tritium-msg-phone me # its fingerprint, read on the device
go run ./cmd/tritium-msg device authorize phone <FINGERPRINT> # run as bob: certifies bob/phone onto bob
go run ./cmd/tritium-msg device list # certified devices and their fingerprints
go run ./cmd/tritium-msg send bob "hey" # reaches bob's primary identity and bob/phone
go run ./cmd/tritium-msg send -fp <FP> bob -file secrets.env # a secret: pinned to bob's fingerprint, read from a file, never on the command lineThe device roster shares the bundle's TTL and is refreshed whenever the name republishes. A roster's version only moves forward on any client that has seen a newer one, so a copy replayed into the store cannot bring back a device, or a group member, since removed.
A group is a roster its creator signs — member names and a version — published
under grp:<name>, first come like any name. There is no group key: a group
send is a pairwise send of the same body to every member (and each of their
devices) over the ordinary sessions, with the group's name folded into the
encrypted plaintext so a receiver's Receive can attribute it — after
checking the claim against the group's signed roster, never on the sender's
say-so alone. Only the creator can add or remove members.
go run ./cmd/tritium-msg group create book-club bob carol
go run ./cmd/tritium-msg group send book-club "meeting friday"
go run ./cmd/tritium-msg group add book-club dave # creator only
go run ./cmd/tritium-msg group list book-clubRead from .env (or the file given by -config), then overridden by the environment.
| Variable | Default | Purpose |
|---|---|---|
LISTEN_ADDRESS |
localhost:8080 |
Where the node accepts clients and peers |
ADVERTISE_ADDRESS |
bound address | Address peers dial; set it behind NAT or in containers |
JOIN_ADDRESS |
none | Nodes to join, comma-separated; dialed until they answer and again whenever one drops out, so nodes boot in any order. Unset seeds a new cluster |
LINK_ADDRESS |
none | Peers that cannot dial us back: joined like JOIN_ADDRESS, but we open the connections and are served over them (docs/cloud.md) |
AUTH_PASSWORD |
none | Password clients must AUTH with |
USER_<name> |
none | <password>:<rights> — a client that may touch only the keys it names, e.g. pw:rw:node:gateway,sig:gateway:;r:board:. A right ending in : or / covers everything under it; any other names one key exactly. In the process environment the entry is TRITIUM_USER_<name> |
USERS_FILE |
none | A file of those entries, one per line, with the bare name on the left of the = |
PEER_ALLOW |
none | The only addresses this node takes as peers, comma-separated; gossip naming any other is ignored. Set it on every node of a fleet that can be reached from the internet |
ALLOW_NO_AUTH |
false |
A node with no AUTH_PASSWORD refuses to listen anywhere but loopback unless this says it is meant |
AUTH_USER |
none | Which of them a client next to this node (-config) authenticates as |
PEER_PASSWORD |
none | Password nodes present to each other as AUTH peer <password>. Required, and must differ from AUTH_PASSWORD, on any node that peers (JOIN_ADDRESS, ADVERTISE_ADDRESS or LINK_ADDRESS set) — otherwise the node refuses to start |
ALLOW_SHARED_PEER_PASSWORD |
false |
Start anyway on a PEER_PASSWORD that is unset or equal to AUTH_PASSWORD, with a warning instead of a refusal |
SECURE_STORE_ADDRESS |
none | RESP server this node writes through; unset, the node runs its own store in-process. Plaintext without SECURE_STORE_TLS, so keep it loopback or container-local unless TLS is on |
SECURE_STORE_PASSWORD |
none | AUTH for that store and every replica |
SECURE_STORE_TLS |
false |
Verify the store's certificate instead of dialing it in the clear |
SECURE_STORE_CA |
system roots | What the store's certificate must chain to, under SECURE_STORE_TLS |
SECURE_STORE_SERVER_NAME |
store's host | Name to verify and send as SNI, under SECURE_STORE_TLS |
STORE_MAX_MEMORY |
none | Bytes the embedded store keeps (256M, 1G); past it the soonest-expiring keys are evicted, and a write with nothing left to evict is refused |
MAX_SERVER_CONNECTIONS |
4 |
Connections pooled per RESP server |
MAX_CLIENTS |
10000 |
Connections a node accepts at once; more are turned away with an error. A connection that has not authenticated within 10 s, or is refused five AUTHs, is closed |
KEY_OWNERSHIP |
on |
Each key's writes go through its owner node, so NX and write order hold cluster-wide; off writes locally first and fans out from there |
ELECTRONEGATIVITY |
1 |
This node's pull on key ownership: it competes for each key with this many rendezvous points, so 2 owns twice the keys of 1, and 0 never owns a key nor reads as leader — for a cloud hub that carries replicas and orders nothing |
REPLICATION |
sync |
sync: a write is answered once every peer on this network has it — a peer across a link (LINK_ADDRESS, or one that linked to us) is fed from a queue either way, so no write waits out the internet. async: answered once this node's store has it; every peer is fed in order from a queue |
TLS_CERT, TLS_KEY |
none | Serve TLS, and dial peers with TLS presenting this certificate |
TLS_CA |
system roots | What peers, and clients under TLS_CLIENT_AUTH, must chain to |
TLS_CLIENT_AUTH |
false |
Require client certificates: mutual TLS for clients and between nodes |
- RAM-only. The embedded store never touches disk; run an external one
with
--save "" --appendonly no, as the compose file does. Every key expires. - Zero dependencies. Standard library only;
go.modhas no requirements. - Authentication. Set
AUTH_PASSWORDand every client mustAUTH. A node that peers needs its ownPEER_PASSWORDtoo, distinct fromAUTH_PASSWORD: joining the cluster means every node starts replicating its writes to the newcomer's store, so membership has its own credential — a peering node refuses to start without one (ALLOW_SHARED_PEER_PASSWORD=trueoverrides this for a closed lab network).TRITIUM.GOSSIPis refused to anyone not authenticated aspeer, and underTLS_CLIENT_AUTHalso to any connection without a verified certificate. - Users with fewer rights.
USER_<name>=<password>:<rights>configures a client that may only touch the keys it names —AUTH <name> <password>,NOPERMoutside them, never a peer command, never the cluster view, and never a command whose keys the node cannot locate; its keys live at most the default TTL, so it cannot outlast everyone else's in the eviction order. It is how a credential lives somewhere the node's own password should not. See docs/cloud.md. - Peers are trusted, so name them. A peer can inject membership and
writes;
PEER_ALLOWlists the addresses that may be members, and underTLS_CLIENT_AUTHa peer must hold a certificate for the address it announces. A node with no password refuses to listen off loopback. Connections get five refusedAUTHs, 10 s to authenticate, and an address that keeps guessing is shut out for ten minutes;MAX_CLIENTScaps them. The full review is docs/security-review.md. - Encryption in transit. Set
TLS_CERTandTLS_KEY; addTLS_CAandTLS_CLIENT_AUTH=truefor mutual TLS, which covers node-to-node traffic too. - Encryption at rest, end to end. Give the Go client a
Keyand every value is sealed with AES-256-GCM before it leaves the process. Nodes, stores and the network only ever see ciphertext; key names stay in the clear and are bound to the ciphertext, so a value can't be replayed under another name. Every client sharing the data needs the same 32-byte key, fromtritium.ParseKey(hex or base64) ortritium.KeyFromPassphrase.
The sealed format is "TE1" || 12-byte nonce || AES-256-GCM(plaintext, aad = key name),
so other languages can read it — examples/python/sealed.py is a runnable version of this:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ct = r.get("hello")
assert ct[:3] == b"TE1"
plaintext = AESGCM(key).decrypt(ct[3:15], ct[15:], b"hello")make test # unit tests against an in-process RESP fake
make integration # same tests against a real server on localhost:6379
make lint # gofmt, go vet, go fixCI runs all of the above, plus a container build, on every push. See
CONTRIBUTING.md for the full workflow, including the
zero-dependency rule and pkg/tritium's compatibility expectations.
