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.

On this page
  1. Before you start
  2. Download and verify
  3. Install a single node
  4. Install a cluster
  5. Options
  6. Configuration
  7. Upgrade
  8. Uninstall
  9. Development install on macOS
  10. Troubleshooting

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:

  1. Checks the platform (the distribution, systemd) and that TulaDB is not already installed.
  2. Checks its payload (the SHA-256 above). It unpacks the package for this machine (.deb or .rpm) to a scratch directory, and on macOS also checks that the server binary runs here.
  3. Installs the package through apt or dnf, which brings its dependencies. It then runs the installed tuladbd --version: a missing library or a glibc that is too old shows here, not when systemd first starts the node.
  4. Writes /opt/tuladb/node.toml from the prod-single profile. 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 no node.toml but the data or WAL directory already holds data (anything but lost+found), it refuses: that store may be another node’s. Pass --use-existing-data only if it is this node’s own. It also refuses paths the service’s systemd sandbox cannot use (see Paths the service cannot use).
  5. Creates the missing directories, owned by the tuladb user, mode 0750, 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 the tuladb user cannot write it, unless you pass --own-existing-dirs to have it handed over. See Directories.
  6. Validates the configuration with tuladbd --check-config.
  7. Checks the ports by binding each one. A port another process holds stops the install here, before anything starts.
  8. Probes the disks (autotune). The node records what it measured and sizes itself from it.
  9. 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 serverAuth and clientAuth (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
  1. It refuses a downgrade or a reinstall of the same version. Downgrading is not supported: restore from a backup instead.
  2. It checks that the new build runs here and accepts this node’s configuration, before anything stops. If node.toml lacks the read-only grant for the node’s own certificate, it appends that one line here (see Configuration).
  3. 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.
  4. It asks the new build’s tuladb_upgrade --check whether the data directory opens in place.
  5. 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.