Docs/Get startedv0.0.1-preview
Install TulaDB
Download, verify and install TulaDB with tuladb-installer — a single node or a three-voter cluster on Linux, a development install on macOS — then configure, upgrade and remove it.
TulaDB installs with one program, tuladb-installer. It is a static binary, one per platform, and it carries the whole release for that platform. Download it from Downloads. Nothing else needs to be installed first: no Go, no packages, no container runtime. On Linux it installs TulaDB as a systemd service through apt or dnf, writes a configuration and validates it, generates an admin key and, for a single node, starts the node and waits until it is ready. Installing the packages by hand is not supported.
This page covers the server. The SDKs, the dashboard, the Iceberg exporter and the observability files are separate downloads: see Install the components, and Integrated cluster to run them all with a three-node cluster.
Before you start
Supported platforms
| Production | Ubuntu 22.04 and 24.04, Debian 12, RHEL 9 (Rocky Linux and AlmaLinux 9 count as RHEL 9); x86_64 and arm64; systemd, with apt or dnf |
| Development | macOS on Apple silicon: a user install under ~/.tuladb, with no service and no root |
The Linux build is made on Rocky Linux 9, so it needs glibc 2.34 and OpenSSL 3. Every supported distribution has both, and the package manager installs the rest: libssl3 and liburing2 on Debian and Ubuntu. Other distributions are refused unless you pass --allow-unsupported-os.
The machine
-
Root access. Run the installer with
sudo. -
Disks. The data lives in
/var/lib/tuladb. For production, put the write-ahead log on its own fast local disk with--wal-dir. A WAL that shares a disk with the data is the most common cause of tail latency. -
Ports, all TCP and all bound to one address:
Port Default Used for client 7700 writes and reads (SDKs, tulactl, the REST gateway)query 7800 read-only queries fabric 8700 replication between nodes heartbeat 9700 cluster control traffic A single node binds them to
127.0.0.1. A cluster node binds them to its own address, and every node must reach every other node on all four. -
Clocks. Keep them synchronised (chrony or systemd-timesyncd). Ledger time is replicated, but operational timestamps and certificate validity are not.
Download and verify
Releases are served from https://downloads.tuladb.com/core/<version>/ and listed on Downloads. Take the installer for your platform, together with SHA256SUMS, SHA256SUMS.cosign.bundle and trusted_root.json. The manifest is signed keylessly (Sigstore) by the TulaDB release workflow at the release tag. Verify the signature, offline, then the installer:
cosign verify-blob --offline --trusted-root trusted_root.json \
--bundle SHA256SUMS.cosign.bundle \
--certificate-identity https://github.com/TulaDB/tuladb-core/.github/workflows/release.yml@refs/tags/v0.0.1-preview \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
chmod +x tuladb-installer-0.0.1-preview-linux-x86_64
trusted_root.json comes from the same place as the files; drop --offline --trusted-root trusted_root.json to have cosign fetch Sigstore’s trust root itself.
The installer runs its own check as well. It compares the release embedded in it against the SHA-256 recorded when it was built, and refuses to run on a mismatch:
tuladb-installer: ERROR: the embedded payload does not match its recorded SHA-256 (...): this installer is corrupted or modified; download it again
That check catches corruption and an edited payload. It cannot prove where the binary came from; the signed manifest does that.
Install a single node
sudo ./tuladb-installer-0.0.1-preview-linux-x86_64 install --single
In order, the installer:
- Checks the platform (the distribution, systemd) and that TulaDB is not already installed.
- Checks its payload (the SHA-256 above). It unpacks the package for this machine (
.debor.rpm) to a scratch directory, and on macOS also checks that the server binary runs here. - Installs the package through
aptordnf, which brings its dependencies. It then runs the installedtuladbd --version: a missing library or a glibc that is too old shows here, not when systemd first starts the node. - Writes
/opt/tuladb/node.tomlfrom theprod-singleprofile. Your ports and directories are set in it, every directory the profile names is placed under the data directory, and the API-key authentication described below is turned on. If there is nonode.tomlbut the data or WAL directory already holds data (anything butlost+found), it refuses: that store may be another node’s. Pass--use-existing-dataonly if it is this node’s own. It also refuses paths the service’s systemd sandbox cannot use (see Paths the service cannot use). - Creates the missing directories, owned by the
tuladbuser, mode0750, and records each one in/opt/tuladb/installer-created-dirs.json. A directory that already exists (a mount point, say) is never chowned or chmodded: the install stops if thetuladbuser cannot write it, unless you pass--own-existing-dirsto have it handed over. See Directories. - Validates the configuration with
tuladbd --check-config. - Checks the ports by binding each one. A port another process holds stops the install here, before anything starts.
- Probes the disks (autotune). The node records what it measured and sizes itself from it.
- Starts the service (
systemctl enable --now tuladbd) and waits until the node reports ready. A first start replays nothing; a restart can replay the WAL for minutes, so the default wait is 600 s (--ready-timeout).
The run ends like this:
tuladb-installer: ready: {"ready":true,"leader_id":1,...,"reason":"ready","daemon_verdict":true}
tuladb-installer: installed and running TulaDB 0.0.1-preview (single node). tuladb-installer status
tuladb-installer: admin API key: /root/.tuladb/admin.key (tulactl --api-key "$(cat /root/.tuladb/admin.key)" ...)
If any step fails, nothing is left running. The installer removes the package and the service, and keeps the node.toml it wrote so you can correct it and run install again. It never overwrites an existing node.toml. Ctrl-C (or SIGTERM) does the same: the command in progress finishes (a package manager is never cut off mid-transaction), then the install rolls back as a failure would, and the installer exits 130 (143 for SIGTERM). One exception: if the wait for readiness runs out while the node is still starting (a long WAL replay), the node is left running and the installer says so, rather than restart the replay.
First commands
Anonymous clients can read but not write. The admin key can do everything:
KEY="$(sudo cat /root/.tuladb/admin.key)"
tulactl --api-key "$KEY" ledger-create --ledger 10 --currency 840 --yes
tulactl --api-key "$KEY" create-account --id 5001 --ledger 10 --currency 840 --limit-low -1000000
tulactl --api-key "$KEY" create-account --id 5002 --ledger 10 --currency 840
tulactl --api-key "$KEY" transfer --from 5001 --to 5002 --amount 25 --ledger 10 --currency 840 --txn first-1
tulactl --api-key "$KEY" balance --id 5002 --ledger 10 --currency 840
Run the same transfer again and it answers OK_DUPLICATE, the original result: the idempotency contract. tulactl covers the rest of the CLI.
What is where
| Path | Owner | |
|---|---|---|
/opt/tuladb/tuladbd, tulactl, tuladb_backup, tuladb_upgrade, tuladb_verify_store, tuladb_prometheus_exporter |
package | the CLIs are linked into /usr/bin |
/opt/tuladb/share/config/ |
package | the release’s configuration profiles |
/usr/lib/systemd/system/tuladbd.service |
package | restart policy, sandboxing, a 300 s graceful stop |
/opt/tuladb/node.toml |
installer | 0640 root:tuladb; never overwritten (see Configuration for the one line it may append) |
/opt/tuladb/api_keys |
installer | SHA3-256 digests of the API keys; the daemon never holds a key itself |
/root/.tuladb/admin.key |
installer | 0600; the admin key (principal tuladb-admin, role cluster-admin) |
/var/lib/tuladb/ |
installer | data, WAL (unless --wal-dir), archive; 0750 tuladb; created only if absent |
/opt/tuladb/installer-created-dirs.json |
installer | the directories the installer created: the only ones a purge may delete |
/opt/tuladb/previous/ |
installer | after an upgrade, the binaries it replaced |
The package owns no configuration and no data, and it never enables or starts the service. Removing it therefore cannot remove financial state.
Directories: created, or handed over
Every directory node.toml names (data_dir and each *_dir) is created if it is missing, owned by tuladb, mode 0750, and recorded in /opt/tuladb/installer-created-dirs.json together with any parent the installer had to create. That record is the only thing a purge deletes.
A directory that already exists is never chowned, chmodded or recorded. If the tuladb user cannot write it, the install stops and changes nothing; pass --own-existing-dirs to have the installer give it to tuladb with mode 0750. Either way a purge keeps it and its content. Symbolic links are never followed for a change of owner or mode.
Paths the service cannot use
The unit runs tuladbd with ProtectHome=yes, PrivateTmp=yes and ProtectSystem=full, which tuladbd --check-config (run outside the unit) cannot see. The installer therefore refuses a configuration naming a directory or file under /home, /root, /run/user, /tmp or /var/tmp, or a directory under /usr, /boot, /efi or /etc. Keep TLS material in /etc/tuladb-pki or /opt/tuladb, for instance (files under /etc are readable; the service cannot write there).
Install a cluster
A production cluster is three voters on three machines. The installer prepares each node and never starts one: forming a cluster is a deliberate step you take once every voter is ready, so that a mistyped peer list cannot quietly create a second cluster.
1. Certificates
Every port of a cluster node requires mutual TLS (OpenSSL and TLS covers rotation and revocation). Each node needs:
- a key and certificate, signed by a CA all the nodes trust;
- a SAN for the address its peers use;
- both
serverAuthandclientAuth(a node is a client of its peers).
Use your PKI. For a test, with OpenSSL:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 365 \
-subj /CN=tuladb-ca -keyout ca.key -out ca.crt
for n in 1 2 3; do # IP of node n in $IP_n
ip=$(eval echo \$IP_$n)
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=node$n \
-keyout node$n.key -out node$n.csr
printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth,clientAuth\n' "$ip" > node$n.ext
openssl x509 -req -in node$n.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 365 \
-extfile node$n.ext -out node$n.crt
done
On each node, put ca.crt and the node’s own certificate and key in place. The service user must be able to read the key:
sudo groupadd --system tuladb 2>/dev/null || true
sudo install -d -m 0755 /etc/tuladb-pki
sudo install -m 0644 ca.crt node1.crt /etc/tuladb-pki/
sudo install -m 0640 -g tuladb node1.key /etc/tuladb-pki/
2. One API-key file for all nodes
A client must be able to authenticate to whichever node it reaches, so every node carries the same key file. Make it once:
openssl rand -hex 32 | tr -d '\n' > admin.key # keep this secret
printf '# digest principal\n%s tuladb-admin\n' \
"$(openssl dgst -sha3-256 -r < admin.key | awk '{print $1}')" > api_keys
Copy api_keys (digests only, safe to copy) to every node. Keep admin.key where your operators keep secrets.
3. Prepare each node
On node 1 (10.0.0.1), with the other two as peers:
sudo ./tuladb-installer-0.0.1-preview-linux-x86_64 install --cluster-node \
--node-id 1 --node-addr 10.0.0.1 \
--peer 2=10.0.0.2:7700 --peer 3=10.0.0.3:7700 \
--tls-cert /etc/tuladb-pki/node1.crt --tls-key /etc/tuladb-pki/node1.key \
--tls-ca /etc/tuladb-pki/ca.crt \
--api-key-file api_keys \
--wal-dir /mnt/wal/tuladb
Here /mnt/wal is the mount point of the WAL disk, and /mnt/wal/tuladb does not exist yet: the installer creates it, gives it to tuladb and records it, so a later purge deletes it (never the mount point). To use an existing directory instead, such as the mount point itself (--wal-dir /mnt/wal), add --own-existing-dirs; it must hold nothing but lost+found unless you also pass --use-existing-data for this node’s own WAL, and a purge keeps it.
Repeat on nodes 2 and 3, each with its own --node-id, address and certificate, and the other two as peers. The installer validates each node the way it validates a single one, then stops:
tuladb-installer: PREPARED cluster node 1 (0.0.1-preview). It is NOT started and NOT enabled.
It refuses a cluster node that is not ready for production: fewer than two peers, missing TLS material, or a bad peer list. Node and peer ids are numbers from 1 to 8 (01 is 1); a peer id may appear once and may not be the node’s own.
A cluster node’s client port requires a client certificate and its default_role is denied, so the installer grants this node’s own certificate principal read-only (role_grant_1): status and the upgrade’s readiness wait present that certificate.
4. Start the cluster
Check that every node lists the same peers in /opt/tuladb/node.toml, and the same use_iso20022_ref_size (see Configuration). Then start all three:
sudo systemctl enable --now tuladbd # on each node
The voters elect a leader within a few seconds. Writes can go to any node; a follower forwards them to the leader.
5. Check every layer
sudo ./tuladb-installer-0.0.1-preview-linux-x86_64 status
installed: 0.0.1-preview at /opt/tuladb
service: active / enabled
process: {"healthy":true,"status":"ok"}
build: tuladbd 0.0.1-preview ...
readiness: {"ready":true,"leader_id":2,...,"reason":"ready"}
membership (committed config on disk):
...
note: readiness says this node may take client traffic now. It is not proof of
cluster correctness: compare state across nodes with `tulactl digest-at-cut`...
status connects to the node’s own address over mTLS, with the node’s certificate. It reports each layer separately on purpose, and its exit status says which one failed:
| Exit | Meaning |
|---|---|
0 |
installed and ready |
3 |
not installed (no node.toml, or no binaries) |
4 |
installed, but tuladbd does not answer: stopped, still starting, or a cluster node not started yet |
5 |
answering, but not ready: replaying, catching up, no leader |
1, 2 |
the installer itself failed (2: an internal error) |
Ready means this node may take client traffic now. That the three nodes hold the same state is a separate question: answer it with tulactl digest-at-cut at a common point (see Replication).
Adding a node to a running cluster is a reconfiguration, not a fresh start. Follow Node join. To connect the dashboard, the Prometheus exporters and the Iceberg exporter to this cluster, continue with Integrated cluster.
Options
| Option | Default | |
|---|---|---|
--single / --cluster-node |
install mode (one is required) | |
--profile NAME |
prod-single; run-tier-aws-cluster for a cluster node |
a profile from /opt/tuladb/share/config/ |
--node-id N, --node-addr IPV4 |
1, 127.0.0.1 |
this node, N from 1 to 8; a cluster node needs the address its peers reach it on |
--peer ID=HOST:PORT |
a voter peer’s client endpoint, ID from 1 to 8; repeat for each | |
--data-dir DIR |
/var/lib/tuladb |
|
--wal-dir DIR |
under the data directory | put the WAL on its own disk |
--own-existing-dirs |
give an existing data or WAL directory to tuladb (chown, 0750); without it an existing directory is never changed |
|
--use-existing-data |
install although the data or WAL directory already holds data and there is no node.toml (only this node’s own data) |
|
--client-port, --query-port, --fabric-port, --heartbeat-port |
7700, 7800, 8700, 9700 | |
--tls-cert, --tls-key, --tls-ca |
required for --cluster-node |
|
--api-key-file FILE |
one admin key generated | the same file on every node of a cluster |
--no-start |
single node: install and validate, but do not start | |
--allow-unsupported-os |
install on a distribution outside the tested list | |
--ready-timeout SECONDS |
600 | how long install and upgrade wait for readiness |
The options that shape the configuration only apply when node.toml does not exist yet: --profile, --node-*, --peer, the ports, TLS and --api-key-file. Once it exists, edit it.
Configuration
/opt/tuladb/node.toml is yours: the installer writes it once and never overwrites it. The one change it may make later is to append the read-only grant for the node’s own certificate (role_grant_1, described under Install a cluster) when a node.toml written by an older installer lacks it: upgrade, and an install that keeps an existing node.toml, add that single line if no grant matches the principal yet. The edited file is validated (by the new build, on an upgrade) before it atomically replaces the old one, with the same owner and mode; nothing else changes.
Edit it, check it, restart:
sudoedit /opt/tuladb/node.toml
sudo -u tuladb /opt/tuladb/tuladbd --check-config --config /opt/tuladb/node.toml
sudo systemctl restart tuladbd
--check-config exits 78 on a configuration error. systemd does not retry exit codes 64 (bad command line) or 78 (bad configuration), so a broken config fails the unit at once instead of looping. Every key is described in Configuration.
Some choices are fixed for the life of a store. The idempotency key format (use_iso20022_ref_size, on by default) is recorded at the first boot in <meta_dir>/identity_key_format, and a node whose configuration later disagrees refuses to start: changing it would change every duplicate-detection key, so a retried transfer could post twice. When that record file is absent (a store written before it existed, or an older backup restored), the node does not trust the configuration blindly: it infers the format from the store’s own data and refuses to start if the configuration disagrees, naming the value the store needs. Every node of a cluster must use the same value. A node of another format is refused by its peers: they do not vote for it, follow it, or accept its replication or snapshots.
io_uring on RHEL 9
The write-ahead log uses io_uring wherever the kernel allows it. Ubuntu and Debian allow it. RHEL 9 ships with kernel.io_uring_disabled = 2, so there the WAL uses fdatasync and the node logs once:
WARN [wal] iouring_disabled_by_kernel: the kernel refuses io_uring (EPERM: kernel.io_uring_disabled is set; RHEL 9 ships 2). The WAL uses fdatasync...
The node is fully correct on fdatasync. To allow io_uring for this service only:
gid=$(getent group tuladb | cut -d: -f3)
printf 'kernel.io_uring_disabled = 1\nkernel.io_uring_group = %s\n' "$gid" | sudo tee /etc/sysctl.d/60-tuladb-io-uring.conf
sudo sysctl --system && sudo systemctl restart tuladbd
metrics.json (under /var/lib/tuladb/runtime/) reports the backend in use, as wal_backend. io_uring and the WAL has the details.
Upgrade
Run the new version’s installer with upgrade. It upgrades this node only:
sudo ./tuladb-installer-0.0.2-linux-x86_64 upgrade
- It refuses a downgrade or a reinstall of the same version. Downgrading is not supported: restore from a backup instead.
- It checks that the new build runs here and accepts this node’s configuration, before anything stops. If
node.tomllacks the read-only grant for the node’s own certificate, it appends that one line here (see Configuration). - It stops the unit whatever its state (a unit waiting to auto-restart included), gracefully: the node writes a final checkpoint; up to 300 s.
- It asks the new build’s
tuladb_upgrade --checkwhether the data directory opens in place. - It keeps the previous binaries in
/opt/tuladb/previous/, upgrades the package in place and starts the node again, then waits until it is ready.
If anything fails after the node is stopped and before the new build starts (the check in step 4, keeping the previous binaries, the package manager, a new binary that does not run), the installer puts the previous build back from /opt/tuladb/previous/ and restarts it if it was running, so the node returns as it was. Ctrl-C in that window rolls back the same way. Once the new build has started, a failure is reported but not rolled back: the new build may already have opened the data.
The data and the rest of node.toml are kept. In a cluster, upgrade one node at a time and wait for each to rejoin before the next; the installer warns that it is a cluster node and does not orchestrate the others.
Uninstall
sudo ./tuladb-installer-0.0.1-preview-linux-x86_64 uninstall
This removes the package and the service, and keeps node.toml, the API keys and all the data. Running install again later picks the node up where it was.
Deleting the ledger’s data is a separate, explicit step:
sudo ./tuladb-installer-0.0.1-preview-linux-x86_64 uninstall --purge --yes-delete-financial-data
A purge deletes node.toml, the API keys, the admin key and only the directories recorded in /opt/tuladb/installer-created-dirs.json, and each only while it is still the directory the installer created: the same device, inode and owner, and not replaced by a link. It never deletes a mount point or a directory with a filesystem mounted inside it, a directory that existed before the install, or a system directory such as /etc or /var even if a configuration names one. A parent the installer created on the way goes only if it is empty. An install made by an older installer has no record, so its purge deletes no directory at all. Every directory it keeps is named, so you can delete it yourself.
Development install on macOS
chmod +x tuladb-installer-0.0.1-preview-darwin-arm64
./tuladb-installer-0.0.1-preview-darwin-arm64 install --single
Everything goes under ~/.tuladb: binaries, node.toml, the data and secrets/admin.key. Nothing is installed system-wide and no service is created. The installer prints the command that starts the node:
cd ~/.tuladb/data && ~/.tuladb/tuladbd --config ~/.tuladb/node.toml
uninstall and uninstall --purge --yes-delete-financial-data work as on Linux. A macOS install is for development and evaluation, not production.
Troubleshooting
| You see | What it means, what to do |
|---|---|
port(s) 7700 ... are already in use by another process |
Another program holds a port. Choose others (--client-port and so on) or free them. Nothing was started. |
configuration /opt/tuladb/node.toml is invalid (tuladbd --check-config exit 78) |
The lines above it say which key. Fix node.toml and run install again; the file is kept. |
platform ... is outside the tested matrix |
Use a supported distribution, or pass --allow-unsupported-os knowingly. |
the embedded payload does not match its recorded SHA-256 |
The download is damaged or modified. Download it again and verify it. |
apt could not resolve the dependencies; refreshing the package lists |
Normal on a fresh machine: the installer runs apt-get update once and retries. |
the ... build does not run on this machine |
A library is missing (the lines above it name it from ldd), or the platform is older than supported. |
TulaDB ... is already installed |
Use upgrade, or uninstall first. |
... already holds data and there is no /opt/tuladb/node.toml |
The data or WAL directory has a store in it. If it is this node’s own, pass --use-existing-data; otherwise move it away. |
... exists and the tuladb user cannot write it |
An existing directory is never changed. Pass --own-existing-dirs, or name a directory that does not exist yet. |
the service could not use these paths (its systemd sandbox) |
A path is under /home, /root, /run/user, /tmp or /var/tmp, or a directory is under /usr, /boot, /efi or /etc. Move it. |
interrupted (interrupt) |
Ctrl-C: the install or upgrade was rolled back. Run it again. |
tuladbd failed to start |
The journal lines above it say why; also run journalctl -u tuladbd. |
not ready after 600s |
Usually a long WAL replay after an unclean stop; status shows the readiness reason. Raise --ready-timeout if replay is expected. |
a cluster node shows no-leader |
It cannot reach a quorum: check the peer list, the ports between the nodes, and the certificates. |
Exit codes of tuladbd that matter to operations: 0 stopped cleanly; 1 a start failure a restart may clear; 2 the engine halted on an integrity fault, and a restart re-installs from the last snapshot; 64 a bad command line; 78 a bad configuration. systemd restarts on 1 and 2, and does not retry 64 or 78.