---
title: "Deploy TEE Docker on your own server"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://tee.hypetrade.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy TEE Docker on your own server

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](/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.

```text
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 |

> **Three values leave the server exactly once**
>
> The API secret, the export private key, and any workspace password the agent
> generates are printed once and are unrecoverable afterwards — the service
> stores an HMAC of the secret, never the secret. Have your password manager
> open before you say go.

## 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

1. **Step 1**

### Prepare the persistent directory

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

```bash
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`.

2. **Step 2**

### Generate the server HMAC key

```bash
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.

3. **Step 3**

### 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.

```bash
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`.

4. **Step 4**

### 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](#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.

```bash
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.

   > **The example file marks this one for you**
   >
   > `config/tenants.example.json` carries the key as `_exportPublicKey`, with a
   > leading underscore and an obviously fake value. The underscore keeps it inert —
   > the schema ignores keys it does not recognise — so it cannot stop a boot while
   > still being visible to whoever edits the file. Rename it to `exportPublicKey`
   > and set a real value to enable export; leave it alone to keep export off.

5. **Step 5**

### Write tenants.json

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

```json
{
  "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](#letting-a-browser-call-it).

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

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

   > **The file must exist first**
   >
   > Docker creates a *directory* at a single-file bind-mount target that does not
   > exist. The service then fails reporting operator config it cannot read, and
   > the fix is `sudo rm -rf /opt/tee-docker/tenants.json` before writing the real
   > file.

   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](/concepts/networks-and-rpc/).

   > **Delete the example's rpc block before you start**
   >
   > This is the one placeholder in `tenants.example.json` that still stops a boot.
   > It ships pointing at `https://rpc.example.com/eth` and `https://rpc.example.com/sol`,
   > and `rpc.example.com` does not resolve — so a file copied across with only the
   > credentials edited fails closed at startup with `tenant acme has an unsafe RPC
   > endpoint`, having never served a request. The minimal file above leaves the
   > block out entirely, which is the right shape unless you are supplying real
   > endpoints.

6. **Step 6**

### Serve it

### Bare Docker

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.

```yaml
# 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.

```bash
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:

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

### Dokploy

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 |

> **TEE_API_HOST has a default, and it is not yours**
>
> Unlike `TEE_HOST_DIR`, which refuses to start without a value, the hostname
> falls back to `tee-api.hypetrade.xyz` when unset. Forget it and the stack comes
> up healthy while Traefik routes a hostname you do not control — a 404 at your
> domain with nothing wrong in the logs. Set it explicitly, even if you are only
> testing.

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.

7. **Step 7**

### Prove it is up, not merely running

```bash
curl -fsS https://tee-api.example.com/v1/health
```

```json
{ "status": "ok" }
```

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

```bash
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`](/errors/#bad_api_key) here means the `secretHash` was
   computed under a different key than the container is running with. From here,
   [Get started](/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:

```yaml
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:

```text
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 |

> **The state lock is never reclaimed automatically**
>
> `service state is already locked` has two possible causes and **the service
> cannot tell them apart**: another instance really is running, or a previous one
> died without releasing it. Guessing by inspecting a recorded pid is unsafe
> across PID reuse and container restarts, and guessing wrong means two live
> writers on one workspace.
>
> Recovery, in order: establish **at the orchestrator** that nothing is running
> against that state directory — scale to zero or stop the container, do not
> trust a health check. Then delete **only** `state.json.lock`; leave
> `state.json`, `state.json.bak` and `state.json.initialized` alone, because
> startup recovers the ledger from them. Then start.
>
> The error names the *container* path, `/var/lib/tee-docker/state/state.json.lock`.
> The file you delete is `$HOST_DIR/state/state.json.lock`.

## Reading the logs

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

```bash
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:

```text
[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`:

```text
[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:

```text
[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:

```bash
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.

> **A failing export is usually configuration, not a bug**
>
> [`export_disabled`](/errors/#export_disabled) means no `exportPublicKey` is
> registered for the tenant, and you are the one who registers it. It is a
> tenant-level switch in `tenants.json`, read at boot — adding the field requires
> a restart. [`scope_denied`](/errors/#scope_denied) is the other half: `export`
> is never in a default token and has to be requested explicitly. See
> [Scopes and permissions](/concepts/scopes/) and
> [Export key material](/guides/export-key-material/).

## 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`:

```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"]
}
  ]
}
```

> **There is no endpoint for this**
>
> Origins are added by hand, by the operator. There is no self-service route, no
> signup flow, and no API for registering a domain — a developer sends you their
> origin, you add it to that tenant's array, and you restart. `tenants.json` is
> read once at boot, so the change does nothing until the service restarts.

### 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`.

```text
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:pass@app.example.com` | 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.

```json
{
  "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:

```bash
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"]}'
```

```json
{
  "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`](/errors/#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

- [Get started](/get-started/) — mint a token against the instance you just deployed
- [Safe usage](/safe-usage/) — handling the credentials you now issue
- [Scopes and permissions](/concepts/scopes/) — including why `export` is gated three separate ways
- [Errors](/errors/) — every code the service returns

Source: https://tee.hypetrade.xyz/deploy/index.mdx
