Scaling and high availability
A Tricryption key server can run as a cell: several key-server members, each on its own host, sharing one key database, reached by every client through one address. Losing a member or a whole zone leaves the cell serving; the key database is the one component the cell cannot lose, so it is the one you make highly available by other means.
The shape, and why one address
- Members. Each member is a key-server instance on its own host, with its own instance id. All members run the same encrypted configuration and read and write the same key database. Place them in at least two zones of one region.
- The key database. One PostgreSQL database, highly available in its own right (for example
a managed instance with a standby in a second zone), reached by every member over
verify-full. - One address. The Console, the REST Gateway and the C and C++ SDKs each connect to exactly one key server address. Put a TCP load balancer in front of the members and give clients its address; the member list is yours, never a client's. The remote engine is the exception: it takes a list of key servers of its own.
- One anchor. Each member presents a self-signed root. A member that joined another carries
that member's configuration, so the members of one cell present the same root; build the
anchor a client trusts (
KS_SERVER_ANCHORfor the Console and the Gateway) from every member all the same, in one PEM file, so a member installed separately is covered too. A member answers with the same name whichever address it is dialled by, so the load balancer's address needs no certificate of its own.
Choose the member count before the first install
The number of instance ids a key database serves is fixed when the database is installed. Every persistent object a member creates is counted per instance id, those counters are written only by the install, and a member started with an id that has no counter rows refuses to start. So decide the count first:
--instance-total Nat the first install provides instance ids0to2^k - 1, wherekis the number of bits inN - 1.--instance-total 3and--instance-total 4both provide ids0,1,2and3.- Install for one more member than you plan to run. The spare id is what you add a member with later, or join a replacement with while the old one is still being taken apart.
Install the first member (instance id 0) against the key database. Every member listens on
0.0.0.0 and on the same port, because a passthrough load balancer delivers each
connection to the members unchanged, addressed to the load balancer's own address:
sudo tricryption-ks-provision --instance main --instance-total 3 --instance-id 0 --xauth-policy \
--bind-host 0.0.0.0 --port 8893 --db tecloud --db-role teks_db \
--db-host 192.0.2.10 --db-password-file /opt/te/managed-db/db-password \
--db-sslmode verify-full --db-sslrootcert /opt/te/managed-db/server-ca.pem \
--db-servername db.example.com
Add a member
A new member joins a running one. It copies the source member's protection material and encrypted configuration, relocates its own listener, backs that configuration up into the database under its own id, and starts. It creates nothing in the database and changes no existing row.
The join material is the source's encrypted configuration, its master, administrator and
database passwords, its system id and its instance record:
ks-config _MASTERPASSWORD_ _SYSTEM_ _ADMINPASSWORD_ _DBPASSWORD_ ks-instance.txt in
/var/lib/pisoft/ks/main. Move it from host to host without writing it anywhere in between:
stream it from the source over one SSH session into the next, into a directory only root can
read, and remove it after the join:
ssh ks-a.example.com 'sudo tar -C /var/lib/pisoft/ks/main -cf - ks-config _MASTERPASSWORD_ _SYSTEM_ _ADMINPASSWORD_ _DBPASSWORD_ ks-instance.txt' \
| ssh ks-b.example.com 'sudo sh -c "umask 077; rm -rf /root/join-src; mkdir -p /root/join-src && tar -C /root/join-src -xf -"'
Compare the configuration's digest on both sides, then join on the new host. Install the
tricryption-ks package there first, and place the key database's CA file at the same path
the source uses:
ssh ks-a.example.com 'sudo sha256sum /var/lib/pisoft/ks/main/ks-config'
ssh ks-b.example.com 'sudo sha256sum /root/join-src/ks-config'
ssh ks-b.example.com 'sudo tricryption-ks-provision --check-join --join-from /root/join-src --instance-id 1 --bind-host 0.0.0.0'
ssh ks-b.example.com 'sudo tricryption-ks-provision --join-from /root/join-src --instance-id 1 --instance main --bind-host 0.0.0.0 --port 8893; sudo rm -rf /root/join-src'
--check-join reads the source's record and this member's command line exactly as the join
does, prints the member it would make, and writes nothing. The provisioner refuses an id the
database has no rows for, the source's own id, and, for a source instance on the same host,
that source's own port and address. The new member takes the source's database settings from
its record; do not pass --db-* options with --join-from.
Add the new member to the load balancer once it listens.
Remove a member
- Take it out of the load balancer first, and wait until the load balancer no longer lists it as a backend. Connections already open on it finish; new ones go elsewhere.
- Stop its unit:
sudo systemctl stop tricryption-ks@main. - Move its state aside, outside
/var/lib/pisoft/ksand/etc/pisoft/ks. Purging the package deletes both trees, and a removed member's state is evidence you may want later. - Purge the package.
The database is not touched: the other members keep serving every key the removed one made. Measured with a client making a fresh request about once a second: taking the member out of the load balancer first (the backend list no longer named it 14 seconds later) and only then stopping it failed none of 95 requests.
Replace a member
A member whose host is lost is replaced by joining, not restored:
- Make sure the old host is stopped or deleted, and that it is out of the load balancer.
- Bring up a new host in the same zone and join it from a live member, with the lost member's instance id (a join refuses a source whose id is the new member's own).
- Add the new host to the load balancer.
The replacement keeps the lost member's instance id and the cell's system id. Measured with a member's host deleted outright and a client making a fresh request about once a second: none of 489 requests failed while the load balancer took the deleted host out, the longest time between two successful requests was 8.3 seconds, and the replacement was in rotation 13 seconds after its join.
This procedure was qualified on a cell of three members installed with --instance-total 3
(ids 0 to 3), adding a fourth member on the spare id and removing it again.
The load balancer's requirements
- TCP passthrough. It must not terminate TLS: each member's TLS and certificate authentication run end to end between the client and the member.
- Connection-level balancing. One connection stays on one member for its whole life; a client's next connection may land on another. Nothing a client does depends on reaching the same member twice.
- A TCP health check on the members' port. A member that stops listening is taken out of rotation; one that comes back is put back.
- Health-check sources admitted by the members' firewall, as well as the clients' networks.
On Google Cloud this is an internal passthrough Network Load Balancer: one unmanaged instance group per zone, a regional TCP health check, a regional internal TCP backend service, a reserved internal address and a forwarding rule on the members' port. See Installing on Google Cloud.
Provision the Console and the Gateway against the load balancer's address, with an anchor from every member and administration against one member directly:
sudo tricryption-tier-provision --ks-host 192.0.2.50 --ks-port 8893 \
--anchor-endpoint 192.0.2.21:8893 --anchor-endpoint 192.0.2.22:8893 --anchor-endpoint 192.0.2.23:8893 \
--admin-endpoint 192.0.2.21:8893 --admin-password-file /root/ks-admin \
--keydb-host 192.0.2.10 --keydb-name tecloud --keydb-user teks_db \
--keydb-password-file /opt/te/managed-db/db-password --keydb-sslmode verify-full \
--keydb-sslrootcert /opt/te/managed-db/server-ca.pem --keydb-servername db.example.com \
--system-id 20260912 --xauth-policy
--system-id is the key database's system id (in any member's ks-instance.txt) and
--xauth-policy says the members were provisioned with it. The tool writes the delegation
policy into the database and prints RESTART-REQUIRED:: each member reads that policy when it
starts, so restart every member in turn, waiting for the load balancer to report it healthy
before you restart the next.
Upgrading a cell
Upgrade one member at a time, in the order of Upgrading:
- Take the member out of the load balancer and wait until the load balancer no longer lists it.
- Install the new
tricryption-kspackage over the old one. The package restarts the member's unit; wait for it to listen. - Put it back and wait until the load balancer reports it healthy. Go on to the next member.
Upgrade the tier last. At code-signing level 2 the Gateway and the Console start only with a
registered release, so register the new release before you install it, from the old tier,
with the new Console bundle's signed BUNDLE-MANIFEST.txt, .sig and .cert.pem in a directory
on the tier host:
sudo tricryption-tier-provision --register-release --release-dir ./next-release \
--ks-host 192.0.2.50 --ks-port 8893 \
--anchor-endpoint 192.0.2.21:8893 --anchor-endpoint 192.0.2.22:8893 --anchor-endpoint 192.0.2.23:8893 \
--admin-endpoint 192.0.2.21:8893 --admin-password-file /root/ks-admin \
--keydb-host 192.0.2.10 --keydb-name tecloud --keydb-user teks_db \
--keydb-password-file /opt/te/managed-db/db-password --keydb-sslmode verify-full \
--keydb-sslrootcert /opt/te/managed-db/server-ca.pem --keydb-servername db.example.com \
--signer-cert ./signer.pem --signer-ca ./cacert.pem --signer-crl ./ca.crl.der
--release-dir is available when the installed tier's tricryption-tier-provision --help lists
it. With a tier that does not have it yet, install the new tier packages first and then run
--register-release without --release-dir: the Gateway answers 503 until the registration
completes.
A release signed by a different signer. --signer-cert, --signer-ca and --signer-crl name
the signer of the release you register, which need not be the signer of the release you run: the
registration adds that signer's CA, its CRL and the publisher beside the ones already trusted, and
both releases are admitted afterwards. Until it is registered, a client that attests the new
release is refused with the signer certificate does not chain to a trusted CA.
So the order, at code-signing level 2:
- Register the new release from the old tier, with the new release's signer files and
--release-dir. - Roll the members one at a time, as above.
- Install the new tier packages last.
Measured on a three-member cell at code-signing level 2, upgrading from an earlier release signed
by another signer, with a client making a fresh request about once a second: no request failed
while the three members were rolled in turn (0 of 117, 102 and 124 at the three steps; the
longest time between two successful requests was 3.3 seconds), and none failed while the tier
was upgraded (0 of 32). Each member was out of the load balancer for about two minutes. A key
encrypted before the upgrade decrypted after every step, whichever member served it. Registered
ahead, the upgraded tier was admitted at level 2 as it started; the Gateway answered 503 for
1.4 seconds while it restarted. A second roll, to the next build signed by the same signer, had
the same shape: no request failed at any step, and the longest gap was 2.2 seconds.
Measured again on 2026-10-27, rolling a three-member cell at code-signing level 2 to 8.1.0+3578
(which refuses an expired key, below), signed by another signer: no request failed while the
three members were rolled in turn (0 of 101, 106 and 101 at the three steps; the longest time
between two successful requests was 3.4 seconds), and none failed while the tier was upgraded
(0 of 33). Each member was out of the load balancer for about two minutes. A key encrypted before
the upgrade decrypted after every step. The upgraded tier was admitted at level 2 as it started,
and the Gateway answered 503 to one readiness probe while it restarted. Upgrade the members
before the tier, as above: a member that has the new release serves an older tier unchanged.
Measured again on 2026-10-29, rolling a three-member cell at code-signing level 2 from 8.1.0+3578
to 8.1.0+3647 (which ships Node.js 24.22.2 and answers a ciphertext that does not decrypt with
422 DECRYPT_FAILED, below), signed by another signer: no request failed while the three members
were rolled in turn (0 of 96, 103 and 96 at the three steps; the longest time between two
successful requests was 4.3 seconds), and none failed while the tier was upgraded (0 of 38). A key
encrypted before the upgrade decrypted after every step. The upgraded tier was admitted at level 2
as it started.
Reading the audit across members
Every member writes its audit rows to the one key database, so the Console's audit view, and its
GET /api/audit route, list the rows of every member together. Each member numbers its rows in a
range of its own, so the view does not page by one highest row number. The first page of a
selection answers a ceiling: the highest row of each member at that moment. Every later page
of that selection is read under it, so rows the members write while you page do not push rows
from one page onto the next or into it. A client of GET /api/audit pages the same way: send the
first page's ceiling back, unchanged, with every later offset.
Measured on a three-member cell, with every member writing between two pages: the view's first page carried one ceiling per member, and the pages after it listed every row the selection held at the first page, from all three members, each once (69 rows over three pages), and none of the 75 rows written while it paged. The same walk without the ceiling listed rows twice, missed others and never reached its last page.
When something fails
A member stops. The load balancer's health check takes it out of rotation; the next connection is served by another member. A request in flight on the member as it died either completed or is answered with a retryable error; it never hangs. Revocation crosses members through the key database: a key's access withdrawn on one member is refused on every member at the next request, and so is a session whose certificate a CRL imported through one member revokes, whichever member serves its next request. (Releases before 8.1.0+3538 could serve that session's next request once more on a member it had just used; upgrade a cell that relies on CRL revocation.) Measured on a three-member cell, with a client opening a fresh connection to the load balancer's address about once a second: with the health check probing every 5 seconds and taking a member out after 2 failed probes, the killed member was out of rotation 14 seconds after the kill; 5 of 92 requests in that window failed to connect, each answered at once with a connection error, and the longest time between two successful requests was 5.4 seconds. The member, started again, was back in rotation 14 seconds later. Retry a request that fails to connect: the next connection is balanced again.
A key is expired or removed. An expiry or a removal through any member is refused on every
member at the key's next use, whichever member serves it, even a member that has just used the
key: an expired key answers 410 KEY_EXPIRED and its record stays readable, a removed key answers
404 KEY_NOT_FOUND. Measured on a three-member cell running 8.1.0+3578, each member expiring a key
once and using a key another member expired once: the next decrypt on a member that had just
decrypted the key was refused at its first attempt, three times out of three, and a removal the same
way. (Releases before 8.1.0+3578 recorded an expiry but went on serving the key, on every member.)
Measured again on 8.1.0+3647, three times out of three.
A ciphertext does not decrypt. A ciphertext encrypted under another key, damaged or cut short
answers 422 DECRYPT_FAILED on every member, and the session that sent it goes on working.
Measured on a three-member cell running 8.1.0+3647: through each member, a ciphertext cut by one
byte and one with its last byte changed were each answered 422 DECRYPT_FAILED, the member's own
audit row recorded the failure as Decrypt failed, and the same session then decrypted the intact
ciphertext. (Releases before 8.1.0+3647 answered 403 ACCESS_DENIED.)
A zone is lost. The members in the other zone keep serving through the same address. Measured with the zone holding one of three members stopped: 3 of 250 requests failed to connect while the load balancer took that member out, the longest time between two successful requests was 11.5 seconds, and the member was back in rotation 14 seconds after its host started.
The key database fails over to its standby. The members detect the dropped connections and reconnect on their own; no unit restarts, and the database's address does not change. On a managed regional instance the provider reported the standby primary 92 seconds after the failover was issued; the cell served again about 70 seconds after the failover was issued (requests made in between waited, and 2 of 303 failed), and the longest time between two successful requests was 32 seconds. A member in the primary's original zone then reached the database in the other zone and served. Fail the database back to its primary zone afterwards: the failback was the same shape (2 of 137 failed, 31.9 seconds), and work measured with the database in another zone from the members is not comparable.
The key database pauses or restarts. Members recover without a restart once the database answers again: a member gives back a connection whose transaction could not begin, and a key generator that fails keeps retrying. If a member stays slow after the database is back, check its journal for database errors and restart that member.
A member stops doing work while the database is idle. A member waiting on its own database connection pool, with every backend idle in the database, is a pool held by work that needs a second connection. Current releases authorize before they open a transaction, so this no longer wedges a member; if you see it, the member's pool is smaller than the number of threads that can each hold one connection, and raising the pool at install time is the fix.
Audit records appear in the journal. When an audit insert fails, the audit writer retries
the batch, then the rows one by one, and journals only a row that still fails, with a line
beginning AuditLogJournal: and a summary AuditLog: <n> record(s) written to the journal (stdout) instead of the table. The next batch tries the table again. Watch for those lines: they
mean audit rows are reaching the journal rather than the database.
Recovering the whole cell
A member is replaced by joining. The cell is recovered from the key database: it is the single point every member depends on. Recovering a key server is the procedure; on a cell with a managed key database it runs as follows.
The recovery set. Take it while the cell serves: the dump's start is the cut, and the dump
is one consistent snapshot of the database. From a member, as the database's role, over
verify-full:
sudo sh -c 'umask 077; PGSSLMODE=verify-full PGSSLROOTCERT=/opt/te/managed-db/server-ca.pem PGHOSTADDR=192.0.2.10 pg_dump -Fc --no-owner --no-privileges -h db.example.com -U teks_db -d tecloud > /root/keydb.dump'
Then the first member's state root, without its master password, and the recovery password kept apart from both:
sudo tar --numeric-owner -C / --exclude=var/lib/pisoft/ks/main/_MASTERPASSWORD_ -cf /root/ks-main-state.tar var/lib/pisoft/ks/main etc/pisoft/ks/main.env
sudo cat /root/.tricryption/ks-main.recovery
When the database and every member are lost:
-
Create a new database instance, highly available like the old one. A managed service may not give a deleted instance's name back for days, so it has a new name, a new private address and a new certificate name. Restore the dump into its empty database as the role, with
pg_restore --no-owner --no-privileges --exit-on-error. -
On a new host for the first member, install the same release and lay the state root back. In the plaintext
ks-config.xml, put the new instance's address and certificate name in both connections, and recover the master password with the recovery password (in a mode-600 secrets file withRECOVERY=, the new role password asDBPASSWORD=, and a placeholderMASTERPASSWORD=):sudo ./ks-setup -RECOVERY -CONFIGFILE=ks-config.recovery -SECRETS=ks-recovery.secrets -PROTECTOR=PasswordProtector -DBUSER=teks_db -IC=3 -ID=0 -MASTERPASSWORDFILE=_MASTERPASSWORD_Then repair the recovered configuration to the new instance (the secrets file now carries
MASTERPASSWORD=,REPAIRDBPASSWORD=andREPAIRDBPASSWORD2=), and start the member:sudo ./ks-setup -CONFIGFILE=ks-config -SECRETS=ks-repair.secrets -PROTECTOR=PasswordProtector -ID=0 -REPAIR -REPAIRDBSERVER=192.0.2.11 -REPAIRDBSSLSERVERNAME=db2.example.com -REPAIRDBSERVER2=192.0.2.11 -REPAIRDBSSLSERVERNAME2=db2.example.comsudo systemctl restart tricryption-ks@main -
Keep client access closed: stop and mask the Gateway and the Console. Join every other member from the first one again.
-
Reconcile. Everything after the cut is not in the set. A revocation made after the cut is served again by the recovered cell until it is replayed: replay every change that took access away, directly to the key server, and read each back.
-
Unmask the Gateway and the Console and provision the tier against the new database.
Measured on a three-member cell whose instance and every member were deleted 11.6 seconds after the cut, with one revocation made inside that window: the recovered cell presented the same root and system id, decrypted every ciphertext made before the cut byte for byte, served the revocation made after the cut until it was replayed and refused it after, and refused the one made before the cut throughout. From the first restore command to the first verified decrypt took 49 minutes; without the two interruptions of that run, its parts took 11 minutes to create the instance, 9 minutes to restore the first member, 9 minutes to join the other two, 5 minutes to reconcile and 7 minutes to reopen client access.
What was qualified
On 2026-10-20 every procedure on this page ran on Google Cloud, with a client making a fresh
request through the load balancer's address about once a second throughout: three members on
Compute Engine e2-standard-4 (Ubuntu 24.04) in two zones of one region, a Cloud SQL for
PostgreSQL 16 db-custom-2-8192 regional instance (primary in the first zone, standby in the
second) reached over verify-full on a private address only, one internal passthrough Network
Load Balancer, and the Console and the Gateway on a host of their own. Release 8.1.0 was
installed and upgraded member by member to the next build at code-signing level 2.
On 2026-10-26 the same shape (image ubuntu-2404-noble-amd64-v20261024, Cloud SQL 16.15) was
installed from the release qualified on 2026-10-20 (8.1.0+3328) and rolled without being taken
down to 8.1.0+3507, signed by another signer, and on to 8.1.0+3538, at code-signing level 2 with
the same client running: the upgrade, the audit view across members, the encrypt size limit and
the refused request's audit row, certificate revocation across members, and a member's loss were
measured on it, and every check passed on 8.1.0+3538. A zone's loss, the database failover, adding,
removing and replacing a member, and recovering the whole cell are the 2026-10-20 measurements.
On 2026-10-27 the same shape was installed from 8.1.0+3507 and rolled without being taken down to 8.1.0+3578, signed by another signer, at code-signing level 2 with the same client running. Before the roll an expired key still decrypted on another member; after it, the upgrade, a key's expiry and its removal across members, certificate revocation across members, the Console's first change of the code-signing level after a restart, and a member's loss were measured, and every check passed.
On 2026-10-29 the same shape was installed from 8.1.0+3578 and rolled without being taken down to
8.1.0+3647, which ships Node.js 24.22.2, signed by another signer, at code-signing level 2 with the
same client running. Before the roll a ciphertext that does not decrypt was answered 403; after
it, the upgrade, a key's expiry across members, a failed decrypt through every member, certificate
revocation across members, the Console's first change of the code-signing level after a restart,
and a member's loss were measured, and every check passed.
Qualified at engine master afc556ab71cf.