Skip to content

Deploy TEE Docker on your own server

Stand up your own instance on Docker or Dokploy — an agent prompt, the runbook by hand, the environment traps that stop boot, and where the logs are.

Updated View as Markdown

The repository ships everything the service needs to run — a Dockerfile, a Compose file, config/tenants.example.json, and scripts/hash-secret.mjs — and no runbook, because docs/ is not published. This page is that runbook.

Running your own instance makes you the operator. Nobody issues you credentials; you issue them. That is the part Get started assumes is already done, and it is three things: a server HMAC key, a tenants.json holding at least one tenant, and — only if you want key export — an X25519 keypair.

Hand it to an agent

Copy this into an agent that has a shell on the server, or SSH access to it: Claude Code, Codex, Cursor, or anything else that can run commands and read back what they printed. It asks you for the handful of values it cannot know, does the work, and stops rather than guessing.

Agent prompt

Deploy TEE Docker to a Linux server

Preflights the host, provisions a tenant, wires the domain, boots the container, and proves it is actually up — not merely running.

You are deploying tee-docker, a self-custody wallet API, onto a Linux server I
control. Work on that server, directly or over SSH. The reference runbook is
https://tee.hypetrade.xyz/deploy/index.md — fetch it if you can; this prompt is
self-contained if you cannot.

Before you touch anything: read the INPUTS, ask me for every one that is blank,
then echo back a plan and wait for me to say go.

## INPUTS

  REPO         = https://github.com/HypeTradeXYZ/tee-docker
  MODE         = bare-docker | dokploy      # is Dokploy running on this host?
  API_HOSTNAME =                            # e.g. tee-api.example.com; DNS must already point here
  HOST_DIR     = /opt/tee-docker            # absolute host path for everything persistent
  TENANT_ID    =                            # lowercase slug, e.g. acme
  EXPORT       = yes | no                   # enable sealed key export for this tenant?

## RULES - not suggestions

1. Never invent a value above. Ask me.
2. You generate the secrets. Print each one to me EXACTLY ONCE, in a clearly
   marked block, and never again - not in a file, not in a later summary, not
   in your final report. Say plainly that the service stores only a hash and
   can never give a secret back.
3. Never pass a secret as a command-line argument. Pipe it on stdin. An argv
   secret is in `ps` output and in shell history permanently.
4. Exactly one instance may hold a state directory, and the service never
   reclaims that lock automatically. Do not raise replicas above 1, do not
   point a second stack at the same HOST_DIR, and never delete
   state.json.lock while any container might still be running.
5. If a step fails, stop and show me the real log output. Do not set
   TEE_ALLOW_UNSAFE_KDF, do not widen file permissions to make an error go
   away, and do not remove the health check.
6. Do not go looking for docs/DEPLOY.md. It is not in the repository.

## STEPS

1. PREFLIGHT - report, fix nothing yet:
     uname -m            (expect x86_64 or aarch64)
     nproc; free -m      (below ~2 GB RAM, warn me the build may be OOM-killed)
     docker --version; docker compose version
     getent hosts $API_HOSTNAME   (must resolve to this server's public IP)
     MODE=dokploy: docker network inspect dokploy-network

2. Clone REPO into a build directory and cd into it.

3. PERSISTENT DIRECTORY. The container runs as uid 1000, and on first boot it
   chmods its own state directory to 0700 - which requires ownership. A
   root-owned bind mount fails boot with EPERM instead of anything readable:
     sudo mkdir -p $HOST_DIR/state $HOST_DIR/data
     sudo chown -R 1000:1000 $HOST_DIR
     sudo chmod 700 $HOST_DIR/state $HOST_DIR/data

4. SERVER HMAC KEY. Every tenant secretHash is computed under it; changing it
   later invalidates all of them at once.
     node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
   Print it to me once. Tell me to store it in a password manager now, because
   a restored data/ directory is unusable without it. Keep it in a shell
   variable for step 6 only.

5. TENANT CREDENTIALS. Generate:
     apiKey    - a public identifier, at least 16 characters, e.g. ak_live_<16 hex>
     apiSecret - 32 random bytes, base64url
   Print both to me once.

6. SECRET HASH - stdin only, never argv:
     printf %s "$API_SECRET" | TEE_SECRET_HMAC_KEY="$HMAC" node scripts/hash-secret.mjs
   The output is exactly 64 hex characters. hash-secret.mjs uses only
   node:crypto, so it needs no install step.

7. EXPORT KEYPAIR - only if EXPORT=yes:
     node -e "const{generateKeyPairSync}=require('node:crypto');const k=generateKeyPairSync('x25519');console.log('x25519:'+k.publicKey.export({format:'der',type:'spki'}).subarray(-32).toString('base64'));console.log(k.privateKey.export({format:'pem',type:'pkcs8'}))"
   The first line is exportPublicKey for tenants.json. The PEM private key is
   the only thing that can ever open a sealed export - print it to me once and
   do not write it anywhere on this server.
   If EXPORT=no, OMIT the exportPublicKey field entirely. Leaving the
   placeholder from tenants.example.json in place fails schema validation and
   the service will not boot.

8. TENANTS.JSON. Build it from config/tenants.example.json: drop the _comment
   array, replace every placeholder, keep limits (maxWorkspaces, maxWallets).
   Omit the rpc block unless I gave you real, public RPC URLs - every rpc URL
   is resolved over live DNS at boot, and one that is unresolvable or on a
   private address fails the service closed with
   "tenant <id> has an unsafe RPC endpoint". allowDefaultRpc defaults to true.
   Install it, then delete your working copy:
     sudo install -o 1000 -g 1000 -m 600 tenants.json $HOST_DIR/tenants.json
   It must EXIST before first start: Docker creates a DIRECTORY at a missing
   single-file mount target, and the service then fails on operator config it
   cannot read.

9. SERVE IT.
   MODE=dokploy - create a COMPOSE service, not an Application: an Application
     is a Swarm service whose rolling update starts the replacement before
     stopping the old one, meets the held state lock, and crash-loops on every
     deploy. In the Environment tab set TEE_API_HOST=$API_HOSTNAME,
     TEE_HOST_DIR and TEE_SECRET_HMAC_KEY. TEE_API_HOST is the one that matters
     here: it DEFAULTS to the upstream's own hostname rather than refusing to
     start, so leaving it unset comes up healthy while Traefik routes a host we
     do not control. Check tls.certresolver=letsencrypt matches the resolver
     this Dokploy install actually uses. Deploy from the dashboard.
   MODE=bare-docker - there is no Traefik and no dokploy-network. Write
     docker-compose.self.yml (the deploy page has the full file) that keeps the
     build, volumes, healthcheck and resource limits, drops the labels and the
     external network, and publishes 127.0.0.1:3000:3000 - loopback, not
     0.0.0.0. Put TEE_HOST_DIR and TEE_SECRET_HMAC_KEY in a chmod 600 .env
     beside it. Then:
       docker compose -f docker-compose.self.yml up -d --build
     Terminate TLS in a reverse proxy in front of it. Tenant secrets and
     workspace tokens travel these requests; do not serve them over plain HTTP.

10. VERIFY - running is not up:
      curl -fsS http://127.0.0.1:3000/v1/health     # bare-docker, on the host
      curl -fsS https://$API_HOSTNAME/v1/health     # through the proxy
    Expect {"status":"ok"}. In the logs expect
      native KDF backend verified (probe NNNms)
    If instead you see "KDF readiness check failed (probe_slow); refusing
    startup", this host is too slow or too throttled for the 1000ms default:
    raise TEE_KDF_MAX_PROBE_MS to 2500 and restart. If it says backend_unsafe,
    the image is not the shipped glibc one - do not work around it, tell me.

11. PROVE THE CREDENTIALS AGREE with the HMAC key - the only check that
    actually tests tenants.json:
      curl -X POST "https://$API_HOSTNAME/v1/workspaces" \
        -H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
        -H "content-type: application/json" \
        -d '{"slug":"smoke","password":"<a strong password you print to me once>"}'
    A 201 is success. A 401 bad_api_key means secretHash was computed under a
    different key than the container is running with.

12. REPORT: the health response, the KDF probe line, the smoke result, every
    file you created with its owner and mode, and a checklist of every secret
    you printed so I can confirm I stored each one. Confirm you deleted the
    build directory's copy of tenants.json.

Fill these in before you paste it

Input What it is If you get it wrong
MODE dokploy if Dokploy manages this host, otherwise bare-docker The wrong networking. The shipped Compose file publishes no host port and expects Traefik on dokploy-network
API_HOSTNAME The hostname you will call, with DNS already pointing at this server Certificate issuance fails, and the domain 404s at the proxy
HOST_DIR One absolute path holding state/, data/ and tenants.json Everything persistent lands inside the container and dies with it
TENANT_ID A lowercase slug for your first tenant, up to 63 characters Boot fails on schema validation
EXPORT Whether this tenant may export sealed key material no is reversible later; the field’s absence is the off switch

What the deployment actually is

One container, one persistent directory, one configuration file you write by hand.

Piece Where it lives Why it is separate
tenants.json $HOST_DIR/tenants.json, mounted read-only Holds every tenant’s secret hash. Never baked into an image
state/ $HOST_DIR/state, 0700, uid 1000 Machine-owned ledger. Never hand-edited
data/ $HOST_DIR/data, 0700, uid 1000 Workspace and wallet material
TEE_SECRET_HMAC_KEY Environment only The key every secretHash is computed under. A data/ backup is unusable without it
errors.json Baked into the image Ships with the code, so an upgrade cannot leave a stale copy behind

Stand it up by hand

Prepare the persistent directory

The container runs as the unprivileged node user, uid 1000. On first boot the service creates its state directory and chmods it to 0700 — and chmod requires ownership, so a root-owned bind mount fails boot with EPERM rather than anything that explains itself.

sudo mkdir -p /opt/tee-docker/state /opt/tee-docker/data
sudo chown -R 1000:1000 /opt/tee-docker
sudo chmod 700 /opt/tee-docker/state /opt/tee-docker/data

A named volume does not need this — the image creates those directories as node, so the volume inherits the right owner. Only bind mounts, which keep the host directory’s own ownership, need the chown.

Generate the server HMAC key

node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

Store it before you use it. Every tenant’s secretHash is computed under this key, so replacing it invalidates all of them at once, and a restored data/ directory without it is inert. Anything under 16 bytes is refused at boot; there is no default, because a per-process random key would break every configured hash and a fixed fallback would be a published credential.

Hash your first tenant’s secret

The API key is a public identifier — at least 16 characters. The API secret is yours to generate; the service only ever stores its HMAC.

API_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))")
printf %s "$API_SECRET" | TEE_SECRET_HMAC_KEY=the-key-from-the-previous-step node scripts/hash-secret.mjs

Pipe it on stdin. Passed as an argument it lands in ps output and in your shell history, and it cannot be un-leaked afterwards. hash-secret.mjs uses only node:crypto, so it runs on a checkout with nothing installed.

The output is exactly 64 hex characters — that is secretHash.

Generate an export keypair, or skip it

Only if this tenant should be able to export sealed key material. exportPublicKey is not an on/off flag — it is the X25519 recipient every export for that tenant is encrypted to. A tenant without one cannot export because there is nowhere to send the secret, not because a policy says no.

Whoever will hold the exported keys generates this pair, and keeps the private half. If you are your own first tenant, that is you, right here. If the tenant is another developer, they generate it and send you the public half only — see What to collect from a developer. The private half must never reach the server; it is the only thing that can open a sealed blob, and that is exactly what makes a stolen token useless.

node -e "const{generateKeyPairSync}=require('node:crypto');\
const k=generateKeyPairSync('x25519');\
console.log('x25519:'+k.publicKey.export({format:'der',type:'spki'}).subarray(-32).toString('base64'));\
console.log(k.privateKey.export({format:'pem',type:'pkcs8'}))"

The first line is the value for exportPublicKey, in the only form the schema accepts: x25519: followed by base64 of the raw 32-byte key.

Write tenants.json

Build it from config/tenants.example.json, dropping the _comment array.

{
  "tenants": [
    {
      "id": "acme",
      "apiKey": "ak_live_0123456789abcdef",
      "secretHash": "the 64 hex characters from the previous step",
      "limits": { "maxWorkspaces": 5, "maxWallets": 200, "maxUnlockedWorkspaces": 8 },
      "allowDefaultRpc": true
    }
  ]
}

If a browser will call this instance, the tenant needs an origins array too — see Letting a browser call it.

Install it before the first start, owned by uid 1000 and readable by nobody else:

sudo install -o 1000 -g 1000 -m 600 tenants.json /opt/tee-docker/tenants.json

Every rpc URL you put in this file is resolved over live DNS during startup, and one that is unresolvable or points at a private address fails the service closed. Leave the block out unless you have real public endpoints — allowDefaultRpc defaults to true. See Networks and RPC.

Serve it

The shipped docker-compose.yml publishes no host port and expects Traefik on an external dokploy-network. On a plain host, keep it untouched — so git pull stays clean — and write a sibling file instead.

# docker-compose.self.yml — plain Docker host, own reverse proxy in front.
services:
  tee-docker:
    build:
      context: .
      dockerfile: Dockerfile
    image: tee-docker:local
    init: true
    restart: unless-stopped

    # Loopback only. Terminate TLS in a proxy in front of this; tenant
    # secrets and workspace tokens travel these requests.
    ports:
      - "127.0.0.1:3000:3000"

    environment:
      TEE_SECRET_HMAC_KEY: "${TEE_SECRET_HMAC_KEY:?set it in .env beside this file}"
      TEE_KDF_MAX_PROBE_MS: "${TEE_KDF_MAX_PROBE_MS:-1000}"

    volumes:
      - "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/state:/var/lib/tee-docker/state"
      - "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/data:/var/lib/tee-docker/data"
      - "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/tenants.json:/app/config/tenants.json:ro"

    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "fetch('http://127.0.0.1:3000/v1/health').then(r=>r.json()).then(j=>process.exit(j.status==='ok'?0:1)).catch(()=>process.exit(1))",
        ]
      interval: 30s
      timeout: 5s
      start_period: 60s
      retries: 3

    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: "1024M"

Put the two variables in a .env beside it and lock it down — it holds the HMAC key in plaintext. .dockerignore already keeps .env* out of the build context, so it cannot end up inside the image.

printf 'TEE_HOST_DIR=/opt/tee-docker\nTEE_SECRET_HMAC_KEY=%s\n' "$HMAC" > .env
chmod 600 .env
docker compose -f docker-compose.self.yml up -d --build

Then point your proxy at 127.0.0.1:3000. Caddy is two lines:

tee-api.example.com {
	reverse_proxy 127.0.0.1:3000
}

Create a Compose service, not an Application. An Application runs as a Swarm service, and a Swarm rolling update starts the replacement task before stopping the old one — it meets the held state lock and crash-loops on every single deploy until someone deletes the lock file by hand. Compose recreates a changed service stop-then-start, which hands the lock over cleanly.

The same reasoning pins deploy.replicas to 1. Do not raise it.

Point the service at your fork or clone. The Traefik router is declared in the Compose file’s labels and reads its hostname from the environment, so there is nothing to edit in the file itself.

In the Environment tab set:

Variable Value
TEE_API_HOST tee-api.example.com
TEE_HOST_DIR /opt/tee-docker
TEE_SECRET_HMAC_KEY the key you generated

Check that traefik.http.routers.tee-docker.tls.certresolver=letsencrypt matches the resolver name your Dokploy install actually uses — a mismatch presents the same way, as a Traefik 404 rather than a certificate error.

Everything else in the file has a working default. Nothing goes in the Domains tab — the labels already declare the router.

Prove it is up, not merely running

curl -fsS https://tee-api.example.com/v1/health
{ "status": "ok" }

Then prove tenants.json and the HMAC key agree, which nothing else tests:

curl -X POST "https://tee-api.example.com/v1/workspaces" \
  -H "X-Api-Key: $API_KEY" \
  -H "X-Api-Secret: $API_SECRET" \
  -H "content-type: application/json" \
  -d '{"slug":"smoke","password":"a-strong-workspace-password"}'

A 401 bad_api_key here means the secretHash was computed under a different key than the container is running with. From here, Get started picks up at minting a token.

Environments that need a second look

A small or shared VPS

Password derivation is Argon2, which is deliberately memory-hard. Startup runs one real derivation before the port opens, and refuses to continue if it takes longer than TEE_KDF_MAX_PROBE_MS.

That budget defaults to 1000. The same probe measures 559 ms on an idle Apple-silicon laptop — under two times headroom on fast hardware, which means a throttled shared core can and does exceed it. Raising it to 2500 on such a host is reasonable:

environment:
  TEE_KDF_MAX_PROBE_MS: "2500"

The ceiling is 60000, and the check itself is not optional. TEE_ALLOW_UNSAFE_KDF=1 exists as an emergency operator acknowledgement — it still runs and reports the probe, and it logs UNSAFE KDF ACCEPTED BY OPERATOR. Reaching for it because boot failed is choosing to run password derivation you have not verified.

Memory matters twice over. At runtime, an OOM-kill strands the state lock and costs you a manual recovery, so keep the memory limit at 1024M or above. At build time, the builder stage installs a toolchain and compiles TypeScript; on a 1 GB instance that is the step most likely to be killed without a useful message. Build somewhere larger and push the image, or size the box up.

arm64 and x86_64

node:22-bookworm-slim — Debian, glibc — is a deliberate choice, not a default. Password derivation goes through @node-rs/argon2, whose linux-x64-gnu and linux-arm64-gnu prebuilds load on glibc with no C toolchain present. Startup refuses to continue unless that native backend is the one actually selected, so a base image that silently falls back to a slower implementation is a boot failure, not a slow build. It presents as:

KDF readiness check failed (backend_unsafe); refusing startup

Both architectures are fine. What is not fine is a mismatch: building the image on an Apple-silicon laptop and pushing it to an x86_64 server ships linux-arm64-gnu binaries that cannot load there. Both Compose files here build on the host, which sidesteps it entirely — if you build elsewhere, build for the target platform explicitly.

Swapping the base to node:22-alpine fails the same way. musl is not glibc, and the prebuilds do not load.

Dokploy

Covered in the tab above, and worth restating because each one costs a deploy cycle to discover:

  • Compose, never Application. Swarm’s rolling update overlaps the old and new tasks, and two instances against one state directory is exactly what the lock refuses.
  • replicas: 1 is a correctness constraint, not a capacity decision.
  • The certresolver name in the shipped labels is letsencrypt. If your install names it something else, the domain 404s.
  • Nothing goes in the Domains tab. The router is declared in the labels.

Bare Docker behind your own proxy

The shipped Compose file deliberately publishes no host port — Traefik reaches the container over dokploy-network. Without that network, nothing reaches it at all. The docker-compose.self.yml above publishes 127.0.0.1:3000 instead: bound to loopback, so only a proxy on the same host can reach it. Publishing 0.0.0.0:3000 puts a wallet API on the public internet with no TLS in front of it.

If you are on Podman rather than Docker, the uid-1000 ownership step works differently under rootless mode — the container’s uid 1000 maps to a different host uid through your subuid range. Either use a named volume, or work out the mapped uid before you chown.

When it will not come up

Every one of these is a hard boot failure by design: the service refuses to start half-understood rather than run in a state you did not intend.

What you see What it is What to do
TEE_SECRET_HMAC_KEY is not set. No key in the environment Set it. There is no default, deliberately
TEE_SECRET_HMAC_KEY is too short Key under 16 bytes Generate a 32-byte one
cannot read operator config at /app/config/tenants.json The host file was missing at first start, so Docker created a directory there sudo rm -rf $HOST_DIR/tenants.json, write the real file, restart
EPERM / EACCES during boot Bind mount not owned by uid 1000 sudo chown -R 1000:1000 $HOST_DIR
KDF readiness check failed (probe_slow); refusing startup The derivation exceeded its budget on a slow or throttled host Raise TEE_KDF_MAX_PROBE_MS, or give the container more CPU
KDF readiness check failed (backend_unsafe); refusing startup The native Argon2 backend is not the one selected — usually a swapped base image or an architecture mismatch Use the shipped node:22-bookworm-slim build, built for the target architecture
KDF readiness check failed (probe_failed); refusing startup The probe threw rather than finishing. It derives a throwaway workspace under the container’s temp directory, so this is usually no writable temp space — or the native module failing to load at all Check the temp directory and its free space; if that is fine, treat it as the backend_unsafe case
tenant <id> has an unsafe RPC endpoint An rpc URL is a placeholder, unresolvable, or on a private address Fix it, or drop the rpc block and rely on allowDefaultRpc
expected a canonical X25519 public key exportPublicKey is still the example placeholder Generate a real one, or remove the field
exportPublicKey is not a usable X25519 recipient Well formed, but not a valid curve point Regenerate it
service state is already locked. Another instance holds it — or one died without releasing it See below. Do not delete anything yet
Traefik 404 at the domain Certresolver name mismatch, or DNS not resolving Check the label against your Traefik config, and the A record
401 bad_api_key on a correct-looking call secretHash computed under a different HMAC key Recompute every tenant hash under the running key

Reading the logs

The service logs to stdout, so the container runtime holds them. There is no log file to find.

docker compose -f docker-compose.self.yml logs -f tee-docker

On Dokploy, the same stream is the service’s Logs tab.

Output is colourised even when it is not a terminal, which makes saved logs noisy. Set NO_COLOR=1 in the environment for plain text before you pipe logs anywhere.

A healthy boot says so explicitly:

[Nest] 1  - 08/20/2026, 5:25:20 PM     LOG [KdfCheckService] native KDF backend verified (probe 559ms)
[Nest] 1  - 08/20/2026, 5:25:20 PM     LOG [bootstrap] tee-docker listening on :3000

Failed key exports

Export is the highest-consequence route in the service, so it is the one operation where when did this key leave has to be answerable afterwards. Every attempt is logged — successes and failures alike — as a structured record under the event name key_export.

An export that fails after passing the scope check logs outcome: 'FAILURE' at WARN, from ExportController:

[Nest] 1  - 08/20/2026, 5:25:20 PM    WARN [ExportController] Object(8) {
  event: 'key_export',
  outcome: 'FAILURE',
  tenantId: 'acme',
  workspaceSlug: 'demo',
  accountSlug: 'treasury-a1b2',
  target: 'privateKey',
  walletId: 1,
  vm: 'evm'
}

An export refused because the token lacked the export scope never reaches the controller. It is logged separately by the scopes guard as outcome: 'DENIED', and it is the record that carries a requestId you can match against the client’s error body:

[Nest] 1  - 08/20/2026, 5:25:20 PM    WARN [ScopesGuard] Object(7) {
  event: 'key_export',
  outcome: 'DENIED',
  tenantId: 'acme',
  workspaceSlug: 'demo',
  target: 'privateKey',
  required: [ 'export' ],
  requestId: '01J...'
}

Both are pretty-printed across several lines, so a bare search finds the header and drops the fields. Ask for trailing context:

docker logs tee-docker 2>&1 | rg -A 10 key_export

Reading the outcomes:

Sequence What happened
ATTEMPT then SUCCESS Sealed material was returned. Note the time
ATTEMPT then FAILURE The caller had the scope; the operation failed — export not enabled for the tenant, wrong account kind, unknown wallet, or a VM the wallet has no address for
DENIED alone, no ATTEMPT The token lacked export. Nothing was decrypted, nothing was read
ATTEMPT with no terminal record The process died mid-operation. Treat it as unresolved

The record deliberately never contains key material, a mnemonic, or the sealed blob — only who asked, for what, and how it ended.

Redeploys and backups

An ordinary redeploy is safe. Compose stops the running container before starting its replacement, so the lock is released and retaken in order. There is a brief gap with no service: this deployment cannot do zero-downtime releases, by design, because the alternative is two writers on one workspace.

What is never safe is anything that leaves two containers against one state directory — raising replicas, a second service pointed at the same TEE_HOST_DIR, or a manual docker compose up alongside a Dokploy-managed stack.

Back up the whole of $HOST_DIR with the service stopped. data/ holds wallet material and state/ holds the ledger describing it; a backup that catches the two at different moments may not restore cleanly. Exclude state.json.lock — it is process state, not data — or delete it from the restored copy before starting.

The HMAC key belongs in your secret store, not in the filesystem backup. A restored data/ directory is unusable without it, and no part of the backup contains it.

What to collect from a developer

Once the service is up, onboarding someone onto it is two values in one tenant entry in tenants.json. Both are edited by hand, both take effect only on restart, and neither has an endpoint behind it.

Ask them for Lands in Without it
Every origin their code runs on, e.g. https://app.example.com and http://localhost:4321 origins Their browser calls fail while curl works
Their X25519 public key, x25519: followed by 43 base64 characters and = exportPublicKey They cannot even mint a token with the export scope

Collect both at the same time. They are the only two things about a tenant that a developer supplies rather than you deciding, and each one strands them in a way that looks like a bug in your deployment rather than missing configuration.

Letting a browser call it

Everything above assumes a server-side caller — curl, or your own backend. A browser is different. It will happily send the request and receive the response, then refuse to let your script read it unless the origin the script runs on has been allowlisted on a tenant.

That allowlist is a per-tenant origins array in tenants.json:

{
  "tenants": [
    {
      "id": "acme",
      "apiKey": "ak_live_0123456789abcdef",
      "secretHash": "the 64 hex characters from earlier",
      "limits": { "maxWorkspaces": 5, "maxWallets": 200 },
      "origins": ["https://app.example.com", "http://localhost:4321"]
    }
  ]
}

CORS is not an access-control boundary

Worth being blunt about, because readers get this wrong in both directions.

An allowlisted origin grants nothing. Every route still requires X-Api-Key and X-Api-Secret, or a bearer token. CORS decides only whether a browser will let script read a response it was already authorized to receive.

So: handing over your domain does not give anyone access, and being on the allowlist does not let a page call the API without credentials. The two questions are unrelated.

What each entry must look like

Exactly scheme://host with an optional port, http or https, and nothing else — because that is all a browser ever sends as an Origin.

https://app.example.com          ✓
http://localhost:4321            ✓
https://sub.domain.example.com:8443   ✓

Everything below is rejected at boot — the service refuses to start rather than carry an entry that could never match:

Rejected Why
* Not an origin. There is no wildcard mode
https://*.example.com No wildcard subdomains either — list each one
https://app.example.com/ A trailing slash. A browser never sends one
https://app.example.com/path Origins have no path
https://user:[email protected] Origins carry no credentials
app.example.com No scheme
ftp://app.example.com http and https only
"" Empty string

Failing closed is the point: an entry a browser can never match would otherwise sit in the allowlist looking effective.

What the allowlist actually permits

Leave origins off every tenant — the default, and the previous behaviour — and CORS is not enabled at all.

Set it on any tenant and the allowlist is the union across all tenants. It has to be: a preflight arrives before authentication, so there is no tenant to scope it to yet. That union decides readability only, which is why it grants nothing.

Value
Methods advertised GET, POST, PUT, DELETE — plus OPTIONS, which is the preflight itself
Request headers accepted authorization, content-type, x-api-key, x-api-secret, x-request-id
Response headers readable from script x-request-id, retry-after, x-rpc-source. Nothing else
Credentials Off. Access-Control-Allow-Credentials is never sent — auth is header-based and nothing reads a cookie, so a browser client must set its auth headers explicitly
Preflight cache 600 seconds

The published route surface is unchanged at 32 routes. Preflight is handled as middleware, not as a route, so no OPTIONS entries appear in the reference.

When it does not work

The symptom is “curl works, the browser does not.” That one sentence explains almost every report you will get. The server is not rejecting the caller — the browser is refusing to hand the response to script.

Every failure looks the same from outside, which is unusually opaque:

  • A preflight from an origin no tenant registered returns 404 — not 204, not 403. The refusal is not short-circuited, so it falls through to the unrouted OPTIONS. The browser console shows only the generic No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
  • A simple request returns 200 with its real body, and is merely unreadable to script. GET /v1/health is the only route simple enough to skip preflight, so it is the one place you can watch this happen.
  • CORS switched off entirely and your origin merely missing are indistinguishable — both 404, with nothing in the response saying which. Telling them apart means reading tenants.json.

Do not expect the status code to tell you which of those it is. It cannot.

Nothing is logged operator-side when an origin is refused, and the Origin value is not recorded anywhere — so there is no log line to go and find. The diagnosis is manual, and it is short: ask the developer for their exact origin string, and compare it against the tenant’s array by eye, scheme and port included. https://app.example.com and https://app.example.com:443 are different strings to the matcher, even though a browser treats them as the same page.

Registering an export key

The other half of onboarding, and the one that strands a developer earlier than they expect.

{
  "id": "acme",
  "apiKey": "ak_live_0123456789abcdef",
  "secretHash": "the 64 hex characters from earlier",
  "limits": { "maxWorkspaces": 5, "maxWallets": 200 },
  "origins": ["https://app.example.com"],
  "exportPublicKey": "x25519:Sbw+3ZFGa1P6EiLjasvKZg4HBeh8Vx86v8JukHa3PGE="
}

The value is canonical base64 of a 32-byte X25519 public key, prefixed x25519:. Validation at boot is real, not a format check: the service performs an actual X25519 agreement against the key and rejects a degenerate result, so an invented or all-zero value will not pass. It also rejects a non-canonical base64 spelling of an otherwise valid key.

The failure comes earlier than the export call

A tenant with no exportPublicKey is refused at token mint, not at the export route:

curl -X POST "$API_URL/auth/token" \
  -H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
  -H "content-type: application/json" \
  -d '{"workspace":"demo","password":"…","scopes":["read","sign","export"]}'
{
  "error": {
    "code": "export_disabled",
    "message": "no exportPublicKey registered for this tenant",
    "status": 403
  }
}

So the report you get is “I can’t even get a token with the export scope”, not “export returned an error” — and a developer reading their own code will reasonably suspect their scope request rather than your configuration.

The export routes refuse the same way, with the same export_disabled code and the same message. That is deliberate — a token can never carry a scope the tenant cannot use — but it does mean the code alone does not tell you which of the two calls produced it. Check whether the tenant has an exportPublicKey before reading anything else into it.

Next

Navigation

Type to search…

↑↓ navigate↵ selectEsc close