A1 API Gateway — Installation and Operations

Self-host guide · 19 September 2026 · For whoever installs, runs and upgrades their own copy of the source. Served at /docs/self-host and shipped in the source delivery. The guide for developers calling a running gateway is the API guide, at /docs/api.
Read this before you plan a launch date. The self-host profile in deploy/ — the Dockerfile, the compose file, the Caddyfile and the Litestream configuration — is written and reviewed and has never been run against a real host, a real domain or a real certificate (spec §18.4). The gateway itself is tested and in production; the packaging around it is a design. Where this guide describes something that has been exercised it says so, and where it has not it says that too. Budget time for the first install accordingly, and do not put anyone else's traffic on it before the restore drill in §7.3 has passed once.
  1. What this is, and what it commits you to
  2. Requirements
  3. Getting it running
  4. Configuration and secrets
  5. Domains and TLS
  6. The first organisation and the first operator
  7. Backups and restore
  8. Upgrading
  9. Security checklist
  10. Troubleshooting

1. What this is, and what it commits you to

A1 API Gateway is a multi-tenant gateway that sits between the projects in your organisation — or your customers' organisations — and the AI vendors: Anthropic, OpenAI and xAI for chat, OpenAI for images and embeddings, ElevenLabs, AssemblyAI and OpenAI for speech and transcription, SendGrid for mail. Callers hold a gateway key that starts with sk-gw-. The gateway authenticates it, checks the caller's address, enforces rate limits and spending caps before the vendor is called, forwards the request with the real vendor key, meters the exact cost and writes it to that organisation's ledger.

You have bought the whole source and every future update, so you run it. That is two Node processes, one Redis and one reverse proxy.

Internet ──▶ Caddy (TLS: apex + *.{BASE_DOMAIN}, DNS-01) │ ├─ api.{BASE_DOMAIN} ──▶ :8080 consumer API apps/gateway └─ {BASE_DOMAIN} ──▶ :8081 public pages, registration platform.{BASE_DOMAIN} ──▶ :8081 platform console + API apps/server {slug}.{BASE_DOMAIN} ──▶ :8081 one console per organisation, and the workers /data/registry.db the platform registry /data/tenants/<id>.db one file per organisation /data/snapshots/, archive/ nightly copies, closed organisations Redis counters, budgets, credit holds, reservations

Both processes are started by scripts/start.mjs, which reads APP: unset or all runs both, gateway or server runs one. If either child exits, the other is stopped and the container exits, so a partial failure is visible to the supervisor rather than silent. The background workers — rollups, credit sync, alerts, reconciliation, retention, snapshots, integrity — run inside the server process, and are switched off with RUN_WORKERS=0 (used when you start a second copy against a data directory for a release gate, §8.2).

1.1 Three things you are taking on

One box. A single node holds every organisation's file. Multi-node sharding is specified and not built (spec §19, Phase 3). The honest availability story is that of one machine and its backups, and it should be a stated one rather than an assumed one: if the box is down, every organisation is down. A corrupt or locked file for one organisation, on the other hand, affects only that organisation (§10).

Redis fails closed, by design. Budgets, the tenant ceiling, per-vendor-key caps, prepaid credit and the rate limits are all reserved atomically in Redis before a request reaches a vendor. If Redis is unreachable the gateway cannot know whether a request is within its caps, so it refuses with 503 gateway_unavailable rather than spending money it cannot account for. This is not a bug to work around. It means Redis is a hard dependency of serving traffic, and it needs the same care as the database.

The master key is the single point of unrecoverable loss. GATEWAY_MASTER_KEY is 32 bytes that wrap every vendor key secret and every TOTP seed with AES-256-GCM. If the data directory survives and the key does not, every organisation's vendor keys are gone and must be re-entered by hand. There is no recovery path and none can exist. Store it somewhere the database backups are not, so that one leaked backup is not also the key that opens it. Rotating it would mean re-encrypting every credential and is not built (spec §18.4, §19 Phase 3).

2. Requirements

WhatVersionWhat it is for
Node26Both processes. The runtime image is node:26-alpine. SQLite is the built-in node:sqlite, so there is no native module to build and no separate database server to run.
pnpm10.33.4Workspace install. Pinned in package.json (packageManager) and activated through corepack in the Dockerfile. Install with --frozen-lockfile so a deployment cannot silently take a different dependency tree from the one that was tested.
Redis8 (compose pins redis:8-alpine)Rate-limit windows, concurrency, budget and credit counters, reservations, the cache-invalidation channel. Run with AOF persistence (--appendonly yes --appendfsync everysec) and --maxmemory-policy noeviction, so a full Redis refuses writes rather than quietly dropping a budget counter. The counters are rebuildable from the ledgers, but only at the cost of a restart's worth of accounting.
A domainOne registrable domain. You need an A or AAAA record for the apex and a wildcard *.{BASE_DOMAIN} record, both pointing at the proxy. Organisations get a subdomain each, so a new organisation must need no DNS work at all.
A wildcard certificateOne certificate for the apex and *.{BASE_DOMAIN}, obtained with the ACME DNS-01 challenge. That needs a Caddy build carrying the DNS module for your DNS host and an API token scoped to that one zone. §5 covers the alternative.
Disk/data holds registry.db, one file per organisation under tenants/, the nightly snapshots/ (a second copy of every file), and archive/ for closed organisations. A ledger row is roughly 300 bytes (spec §11.3). Plan for at least twice the live size because of the snapshots, plus Redis's AOF and Caddy's certificate store.
File descriptors≥ 4 × MAX_OPEN_TENANT_DBS + 1024Two to three descriptors per open organisation file. At the default pool of 256 that is 8192, which is what the compose file sets as the container's nofile limit.
An S3-compatible bucketLitestream's replication target for the registry, and where you will put the nightly snapshots yourself (§7.2). Optionally also the file store for logged attachments.
A SendGrid accountVerification emails, invitations, password resets and alerts. Without a key, mail is only written to the log — which means registration cannot be completed by anyone who is not reading your logs (§6.2).

Vendor keys are not a requirement of the installation. They are added afterwards, in the console, either by you (platform keys, assigned to organisations, billed to you and paid for with prepaid credit) or by each organisation (its own keys, billed to it by the vendor). A freshly installed gateway with no vendor keys starts and serves its consoles; it simply answers 402 to requests until an organisation has a key or credit.

3. Getting it running

3.1 What is in deploy/

FileWhat it actually contains
DockerfileTwo stages on node:26-alpine. The build stage enables corepack, pins pnpm 10.33.4, copies the manifests, packages/, apps/, scripts/ and docs/, runs pnpm install --frozen-lockfile, builds the console bundle (pnpm --filter @aigw/admin build) and then pnpm prune --prod. The runtime stage sets NODE_ENV=production DATA_DIR=/data APP=all, adds tini and wget, creates /data owned by node, drops to the node user, exposes 8080 and 8081, declares a health check that fetches http://127.0.0.1:8080/healthz every 30 seconds, and runs node scripts/start.mjs under tini. docs/ is in the image on purpose: the three product documents are served from those files, so a document left out of the package is a 404 on the live site.
docker-compose.ymlFour services. caddyghcr.io/caddybuilds/caddy-cloudflare:2 (stock Caddy plus the Cloudflare DNS module), the only service publishing ports (80, 443, 443/udp), with the Caddyfile mounted read-only and named volumes for its data and config. gateway — built from this repository, read_only: true with a tmpfs /tmp, APP=all, both listeners bound to 0.0.0.0 inside the container network, REDIS_URL=redis://redis:6379, TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8, the rest of its environment from .env, the data volume on /data, and nofile 8192. It publishes no ports: only Caddy can reach it. redis — AOF on, noeviction, its own volume. litestream — replicates using the mounted configuration and the backup credentials.
Caddyfileapi.{$BASE_DOMAIN} reverse-proxies to gateway:8080; the apex and *.{$BASE_DOMAIN} together reverse-proxy to gateway:8081 and take one certificate through tls { dns cloudflare {$CLOUDFLARE_API_TOKEN} }. Both send HSTS for a year including subdomains and enable zstd and gzip. The on-demand alternative is present as comments (§5.2).
litestream.ymlOne entry: /data/registry.db replicated to ${BACKUP_TARGET_URL}/registry, 720 hours of retention, one-second sync interval. Organisation files are not in it. Its own comment says to add per-database entries for hot organisations, and §7.2 explains why you should not skip that.
.env.exampleThe starting point for .env, which lives next to the compose file. Copy it, do not edit it in place.
aiaw-ec2/Not part of this profile. It holds copies of the Caddy site file, the certificate deploy hook and the systemd renewal units as they run on the vendor's own shared box, kept in the repository so changes to them are reviewed in a diff. It is a hand-installed layout, not Docker Compose. Read it if you would rather install onto a host directly than run containers; it is the only installation of this product that has actually run in production.

3.2 The steps

cd deploy
cp .env.example .env

# A master key you can never lose. Put a copy somewhere the backups are not.
openssl rand -base64 32                      # paste into GATEWAY_MASTER_KEY in .env

# Edit .env: BASE_DOMAIN, ACME_EMAIL, CLOUDFLARE_API_TOKEN (or your DNS provider's),
#            REGISTRATION_MODE, SENDGRID_API_KEY, EMAIL_FROM, BACKUP_TARGET_URL and its credentials.
chmod 600 .env

docker compose up -d --build                 # the build context is the repository root
docker compose logs -f gateway               # both processes announce the address they listen on

curl -fsS https://api.$BASE_DOMAIN/healthz   # {"ok":true,"mode":"full"}
curl -fsS https://api.$BASE_DOMAIN/readyz
# {"ready":true,"redis":"ok","registry":"ok","tenant_dir":"ok","platform_keys":"not_required"}

/healthz says the process is up. /readyz is the one that matters: it pings Redis, runs a statement against the registry and checks it is writable, checks the tenants directory is writable, and checks there is at least one healthy platform key or no prepaid organisations. It answers 503 when any of those fails, so it is what a load balancer or an uptime check should watch. It is served by the consumer API on 8080; the console server answers /healthz only, and the container's own health check uses that.

One known defect in the image, found by reading it. The Dockerfile's last build step is pnpm prune --prod, which removes development dependencies. scripts/start.mjs — the container's command — executes node_modules/.bin/tsx, and tsx is declared only in the root devDependencies. As written, the pruned image has no tsx to start with. Either drop the prune step or move tsx into dependencies before your first build. This is exactly the class of thing §18.4 warns about: the file has been reviewed, never executed.

3.3 Running it without Docker

Nothing in the product needs a container. The two processes are apps/gateway/src/main.ts and apps/server/src/main.ts, run with tsx; scripts/start.mjs is a twelve-line supervisor that spawns them. A host installation is therefore: a Node 26 runtime, pnpm install --frozen-lockfile (not --prod, because tsx is a development dependency), pnpm --filter @aigw/admin build for the console bundle, two service units with the environment file loaded at 0600 and root-owned, a Redis instance, and your own reverse proxy in front. deploy/aiaw-ec2/ is a worked example of the proxy and certificate half of that.

4. Configuration and secrets

Configuration is read once at boot by loadConfig in packages/core/src/config.ts. That file is the authority: if a name or a default is not in it, it does not exist. Every value is validated at boot and a bad one throws a ConfigError naming the variable, so a misconfiguration is a failed start with a readable message, not a surprise at request time.

4.1 The three with no safe default

VariableWhy there is no default
BASE_DOMAINEverything is derived from it: api., platform. and the apex hosts, the session cookie domain (.{BASE_DOMAIN}), and the links in every email. It is lower-cased, a trailing dot is stripped, and it must be a bare hostname (^[a-z0-9.-]+$) — not a URL, not a port.
REDIS_URLThere is no in-process fallback in production, and there should not be: see §1.1.
GATEWAY_MASTER_KEY32 bytes, base64. Any other length is refused at boot. A default would mean every installation shared one key, which would make every vendor secret readable by anyone holding a copy of the source.

4.2 Everything else

VariableDefaultWhat it does
DATA_DIR/dataHolds registry.db, tenants/, snapshots/, archive/. Both processes create tenants/ at boot.
MAX_OPEN_TENANT_DBS256Size of the LRU of open organisation files. Handles idle for ten minutes are closed. Raise it and raise the file-descriptor limit with it.
LISTEN_ADDR / CONSOLE_LISTEN_ADDR127.0.0.1:8080 / 127.0.0.1:8081The consumer API and the console server. The defaults are loopback deliberately. Bind them to an address only the proxy can reach, and never publish them.
TRUSTED_PROXIES127.0.0.1/32, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16CIDRs whose X-Forwarded-For is believed. Validated as CIDRs at boot. See §5.3 — this one carries real consequences.
CLIENT_IP_HEADERName a provider header (Cloudflare's, a load balancer's) instead of walking X-Forwarded-For.
ADMIN_ALLOWED_CIDRS— (empty: any address)An address allowlist for platform. only. Worth setting: it is the console that can move credit and read every organisation's bill.
REGISTRATION_MODEopenopen, approval, invite_only or closed. A fallback only — see §6.1.
REGISTRATION_RATE_PER_IP_HOUR5Sign-ups accepted per client address per hour.
BLOCKED_EMAIL_DOMAINS_FILEA file of one domain per line, # for comments. Overridden by the list held in platform settings once one is saved there.
RESERVED_SLUGSapi, platform, www, admin, mail, status, docs, static, internal, login, registerSubdomains an organisation may not claim. Add any host you serve yourself before you open registration.
RECONCILE_SCHEDULE30 3 * * *Cron (UTC) for the nightly comparison of the gateway's figures with the vendors' own, per vendor key.
RECONCILE_LOOKBACK_DAYS / RECONCILE_DELTA_ALERT_BPS3 / 200Days re-imported each run, because vendors finalise usage hours late; and the delta in basis points that raises an operator alert (200 = 2%).
DEFAULT_IP_POLICYrequire_allowlistWhat a new virtual key's address rules mean when no rule matches. Leave it as it is.
MAX_BODY_BYTES20971520JSON body limit (20 MB).
AUDIO_BODY_LIMIT_MB25Largest upload POST /v1/audio/transcriptions accepts. The upload is buffered in memory, so this is also how much memory one transcription may hold — raise it with that in mind.
TRANSCRIPTION_TIMEOUT_MS / TRANSCRIPTION_POLL_MS600000 / 1000How long a transcription may hold the caller's socket, and the first wait between polls of an asynchronous vendor.
LEDGER_RETENTION_MONTHS120Ten years, which is retention effectively switched off. The nightly worker still vacuums and checkpoints but deletes nothing. The comment in config.ts explains the choice: until you have decided what may be discarded, a shorter default would destroy the evidence the decision needs. Set a real window once you have a policy.
PROMPT_LOG_RETENTION_DAYS3650The same reasoning, for request and response bodies — which are stored only for keys an organisation has switched to full logging. Ten years of other people's prompts is not a defensible default to leave in place. Set it.
CLOSED_TENANT_RETENTION_DAYS30Different in kind: the closure receipt promises the organisation in writing that its archived file is deleted on this date. Lengthening it breaks a promise already made.
BACKUP_TARGET_URLRead into configuration and shown in the platform console. Litestream reads the same value from its own environment. Nothing else in the application uses it — see §7.2.
FILE_STORE_BUCKET, FILE_STORE_REGION, FILE_STORE_ACCESS_KEY_ID, FILE_STORE_SECRET_ACCESS_KEYObject storage for attachments on logged prompts and mail sends. All four or none: a half-configured store would fail on every upload, so a partial configuration is treated as none and the bytes stay inline in the database exactly as before. The feature is additive, never a silent loss.
FILE_STORE_ENDPOINT— (AWS S3)Only for a non-AWS S3 service: MinIO, R2, B2, Wasabi.
SENDGRID_API_KEYWithout it the mailer only writes messages to the log, and says so once at boot.
EMAIL_FROM— (the mailer then uses no-reply@{BASE_DOMAIN})Must be a verified sender on the SendGrid account, or every message is rejected by SendGrid.
CONTACT_EMAIL— (every platform admin)Comma-separated recipients for marketing-site enquiries. Each is validated as an address at boot.
LOG_LEVELinfoOne structured JSON line per request. Authorization headers are redacted; only organisation, key and vendor-key identifiers are logged.
OTEL_EXPORTER_OTLP_ENDPOINTOptional OpenTelemetry endpoint.
DEV_SKIP_TOTPoffLocal development only. It refuses to boot on any domain that could be real — see §6.4.
UPSTREAM_CONNECT_TIMEOUT_MS, UPSTREAM_TOTAL_TIMEOUT_MS, UPSTREAM_STREAM_IDLE_TIMEOUT_MS, UPSTREAM_DRAIN_TIMEOUT_MS10000, 600000, 120000, 60000Vendor call timeouts: connect, whole non-streaming call, idle gap in a stream, and how long a stream is drained after the client has gone so the usage figures still land in the ledger.

Three more are read directly by the processes rather than through loadConfig: APP (all, gateway or server, in scripts/start.mjs), RUN_WORKERS (anything other than 0 runs the workers, in apps/server/src/main.ts), and ADMIN_DIST_DIR (where the built console bundle is, if it is not beside the server). Each vendor also takes an optional base URL — ANTHROPIC_BASE_URL, OPENAI_BASE_URL, XAI_BASE_URL, ELEVENLABS_BASE_URL, ASSEMBLYAI_BASE_URL, SENDGRID_BASE_URL, and ANTHROPIC_ADMIN_BASE_URL / OPENAI_ADMIN_BASE_URL for reconciliation — which exist so the test suites and the development stack can point at the in-repository mock vendor. An empty value means the vendor's own host. In production, leave them unset.

Two lines in .env.example that nothing reads. ANTHROPIC_ADMIN_KEY and OPENAI_ADMIN_KEY appear in the example file, but no running code reads them; they survive only in a package README. Reconciliation takes its admin credentials from the registry — a platform credential of kind = 'admin', added in the platform console, or an organisation's own admin credential in its own file. Setting the environment variables does nothing.

4.3 Where the secrets live

The compose profile keeps every secret in one file, deploy/.env, loaded into the container by env_file. Own that file: chmod 600, owned by the user that runs the stack, never committed, never copied into a build context. It holds the master key, the DNS token, the SendGrid key and the backup credentials. If you install on a host instead, the same applies to the systemd environment file, and its directory should be 0700.

Vendor key secrets themselves are never in the environment. They are stored as AES-256-GCM ciphertext — platform keys in the registry, an organisation's own keys inside that organisation's file — decrypted only for the upstream call, never returned by any API, never written to a log. All any response ever shows is a four-character hint. Several tests exist solely to keep that true, so do not add convenience that defeats it.

5. Domains and TLS

5.1 The four host shapes

HostServesPort
api.{BASE_DOMAIN}The consumer API: every /v1/… endpoint callers use8080
{BASE_DOMAIN}Marketing pages, the three product documents under /docs/, registration, login, the organisation chooser, password reset8081
{slug}.{BASE_DOMAIN}One organisation's console and its tenant API8081
platform.{BASE_DOMAIN}The platform console and platform API8081

The console listener picks the surface from the Host header: platform. requires the platform-admin flag, the apex is public, any other single label is looked up as an organisation slug. An unknown slug gets a generic 404 that does not reveal whether it ever existed. One login on the apex sets a session cookie on .{BASE_DOMAIN} and therefore works on every subdomain; membership of the organisation named by Host is re-checked on every request regardless, so the shared cookie does not widen anything.

5.2 The wildcard

You need one certificate covering the apex and *.{BASE_DOMAIN}, because a new organisation gets a subdomain the moment it registers and must not have to wait for certificate work. That means the ACME DNS-01 challenge, which means a Caddy binary built with your DNS provider's module. The compose file uses ghcr.io/caddybuilds/caddy-cloudflare:2 and passes CLOUDFLARE_API_TOKEN through to the tls block; for another provider, swap the image and the directive together. Scope the token to that one zone and nothing else.

The alternative is in the Caddyfile as comments: stock Caddy with tls { on_demand }, issuing one certificate per subdomain on first visit, gated by an ask endpoint that answers only for slugs that exist. It avoids the DNS module at the cost of a first-visit delay and one certificate per organisation. The wildcard is the recommendation.

5.3 Why TRUSTED_PROXIES is not a detail

X-Forwarded-For is written by whoever sends the request. The gateway's address allowlists, its per-address rate limits, the automatic suspension of a key seen from too many new addresses, and every line in the refusal log are all keyed on the address the gateway decides the caller has. Get this wrong in either direction and something breaks quietly:

The rule the code follows (spec §8.3): if the socket peer is not in TRUSTED_PROXIES, that peer is the client and the header is ignored entirely. Otherwise walk the forwarded chain from the right and take the first address that is not itself a trusted proxy. So TRUSTED_PROXIES should be exactly the range your proxy speaks from and nothing more. The compose file sets 172.16.0.0/12,10.0.0.0/8, which is the Docker bridge network Caddy sits on. If Caddy itself is behind Cloudflare or a cloud load balancer, add that range too or name the provider's own header in CLIENT_IP_HEADER — otherwise every caller looks like the load balancer.

6. The first organisation and the first operator

6.1 Registration modes

ModeWhat happens on sign-up
openThe organisation becomes active as soon as the email is verified. Default.
approvalIt waits as pending in the platform console queue until you approve it.
invite_onlySign-up needs an invite token you issue.
closedNobody can create an organisation. The marketing pages change their call to action to "Request access".

No mode grants anything. Registration gives no trial, no allowance and no credit, so an open gateway has no cost exposure from fake sign-ups: a new organisation can do nothing until it adds its own vendor key or you record prepaid credit for it. The question REGISTRATION_MODE answers is only who may create an organisation on your instance.

The environment variable is only a fallback. The effective mode is registration_mode in the registry's platform settings, which the platform console writes. Once it has been set there, changing REGISTRATION_MODE and restarting does nothing. The same is true of the blocked-email-domain list, where the stored list overrides BLOCKED_EMAIL_DOMAINS_FILE. If a mode change appears not to take, look in the console, not the environment.

6.2 What a sign-up needs from you

The flow is: the form on the apex (organisation name, slug, name, email, password), then a single-use verification token by email that expires in 24 hours, then mandatory TOTP enrolment on first login with ten recovery codes shown once, then provisioning — the organisation's database file is created from the current schema and the owner membership written, in one transaction.

Every step but the first depends on email. Without SENDGRID_API_KEY the mailer only logs, and the boot line says so plainly: platform mail: not sent, only logged. The verification link is then in your logs, which is workable for your own first account and unworkable for anyone else. Set the key and a verified EMAIL_FROM before you invite a single person.

6.3 Making the first platform admin

Platform administration is a flag on a user in the registry, users.is_platform_admin. It is never granted through a role inside an organisation, on purpose: an organisation's owner must not be able to reach the platform console by promoting themselves.

Once one platform admin exists, the rest are made from the platform console or its API — POST /platform/v1/users with an email, which promotes an existing account or creates one and sends it a password-reset link, and DELETE /platform/v1/users/:id, which refuses to remove the last administrator. Both write to the platform audit log.

There is no bootstrap command for the first one. The repository ships no CLI and no first-run flow for this. Register normally through the web form, verify the email and enrol TOTP, then stop the services and set the flag directly in the registry — which is exactly what the demo scripts do:
# With the sqlite3 command line, if you have it:
sqlite3 /data/registry.db "update users set is_platform_admin = 1 where email = 'you@example.com';"

# The runtime image does not carry sqlite3. Node 26 has SQLite built in, so this needs nothing extra:
node --input-type=module -e "
  import { DatabaseSync } from 'node:sqlite';
  const db = new DatabaseSync('/data/registry.db');
  console.log(db.prepare('update users set is_platform_admin = 1 where email = ?').run('you@example.com'));
"
Then start the services and sign in at platform.{BASE_DOMAIN}. Stop the services first rather than writing to a file the running processes hold open. If you would rather not depend on an undocumented step, this is the first thing worth adding to your copy of the source.

6.4 DEV_SKIP_TOTP, and why it refuses to work

DEV_SKIP_TOTP turns off the mandatory second factor so a local stack can be signed into with a password alone. It checks BASE_DOMAIN against a list of local suffixes — localhost, test, local, localdomain, and the loopback addresses — and on anything else it throws at boot:

DEV_SKIP_TOTP is a local development switch and refuses to run on "gateway.example.com".
Two-factor authentication is mandatory (spec §6.2); unset DEV_SKIP_TOTP.

The reason it fails loudly rather than being ignored is that the quiet version of this mistake is a production console with no second factor and nothing to show for it. A variable copied from a development environment file into a production one should stop the deployment, not weaken it.

7. Backups and restore

7.1 What the system does on its own

Every one of these is a named job you can inspect and force. GET /platform/v1/workers lists each job with its schedule, whether it is running now, when it last started and finished, its run count and its last error; POST /platform/v1/workers/{name}/run runs one immediately and is refused if it is already running. The names are rollup, alerts, rebuild, reconciliation, housekeeping (retention, snapshots and deletion together), integrity and limits-summary. Use them before a planned restore rather than waiting for 03:00.

7.2 What it does not do, and you must

The nightly snapshots are never sent anywhere. They are written to ${DATA_DIR}/snapshots/, on the same disk as the files they copy, and nothing in the application uploads them. BACKUP_TARGET_URL is read into the configuration and displayed in the platform console's settings page; beyond that only Litestream's own container uses it, and Litestream's configuration lists the registry alone. A copy of a file next to the file is not a backup of that file. Until you close this gap, the loss of the volume loses every organisation's ledger, keys and usage history while the registry survives — the worst possible split. Close it either by adding a per-database replica entry to litestream.yml for each organisation file (what its own comment suggests) or by a job of your own that ships snapshots/ off the box after the 03:00 run and verifies what it uploaded.

Also yours: the master key. It is not in the data directory and no backup of the data directory contains it. Keep it somewhere else, and make sure whoever would perform a restore at three in the morning knows where that is.

7.3 The restore drill

A backup nobody has restored from is not a backup, and neither path above has ever been exercised in the direction that counts (spec §18.4). Restoring is the only test that means anything, and it is a go-live requirement, not an improvement to schedule later.

  1. Have at least two organisations on the running system, each with real ledger rows and its own vendor key.
  2. Restore into a clean, empty directory — not over the live one.
    litestream restore -o /restore/registry.db ${BACKUP_TARGET_URL}/registry
    cp /data/snapshots/<tenant-id>.db /restore/tenants/<tenant-id>.db    # per organisation
  3. Check that tenants.db_path in the restored registry points at where the files now are. Those paths are absolute, so a restore into a different directory needs them rewritten.
  4. Start the stack against the restored directory with the same GATEWAY_MASTER_KEY.
  5. Rebuild the Redis counters. Redis itself needs no backup — everything in it is derived from the ledgers and the registry — but a restored database and a stale or empty Redis disagree until the rebuild has run. It runs at startup and hourly on its own; to force it, POST /platform/v1/workers/rebuild/run. It recomputes each organisation's credit from top-ups minus the prepaid ledger, each key budget's spend for its current period, the monthly ceiling spend and each platform key's cap counter. In-flight holds are dropped, by design.
  6. Prove three things, in this order. Both organisations' keys still authenticate. Their balances and ledgers are intact and agree with what you recorded before. And — the one that is usually skipped — their vendor keys still decrypt: press Test on a vendor key in each organisation's console, which makes one tiny real call with the decrypted secret. That is what proves the master key was backed up separately rather than merely assumed.
  7. While you are there, confirm isolation: read one organisation's console and find nothing of the other's.

Restoring a single organisation, as opposed to everything, is copying one file back and restarting nothing.

8. Upgrading

An update arrives as source. You rebuild and restart, and the data is migrated. The whole risk of an upgrade is in that last clause, so this section is mostly about it.

8.1 How migrations run

8.2 The release procedure

This is the procedure the project uses for its own deployments, and it is the one to copy. It was last performed on 19 September 2026 for 0.1.0-alpha.6, the release that took the data to registry v8 and organisation-file v11, and the steps below are what was actually done. Its principle: nothing touches live data until the same migration has been run end to end on a copy of that data and checked, and until the previous release has been shown to survive the result.

  1. Snapshot. Take a fresh copy of the data directory — VACUUM INTO per file, with integrity_check and foreign_key_check on each copy — into a timestamped folder. This is both the gate input and the rollback.
  2. Make the gate copy. Copy that snapshot into a scratch directory, then point the copied registry's tenants.db_path values at the copied organisation files. They are absolute paths, so without this step the migration would run against production.
  3. Dry run. Read-only, reports what would change per organisation, exits non-zero on the first error naming the file and the step.
  4. Migrate the copy, then check it against the snapshot (§8.3).
  5. Run the new release against the migrated copy on spare ports with RUN_WORKERS=0, and click through it. Check host-routed pages with curl and an explicit Host header; a 401 from the platform console without a session is correct, not a fault. If the gate shares Redis with production, give it a different database index so its refusals do not land in the live logs.
  6. Prove the rollback both ways. Start the previous release against the migrated copy, and open the migrated files with the previous release's own database code. Forward-only migrations mean this is the only evidence you will get that a rollback is survivable.
  7. Switch. Stop the services, take a second snapshot, migrate the live directory, run the check against that second snapshot, and only start the new release if it passes. Then verify health and traffic, with the rollback script already written.

8.3 The v11 release, as a worked example

The most recent data-model change gave every vendor key its own model list, environment and monthly cap, converted every key pinned to a single address into an ordinary allow rule for exactly that address, and added the refusal log. It shipped with two scripts, which are the model for the ones a future release will bring.

# 1. Dry run against the gate copy: reads only, writes nothing.
npx tsx scripts/migrate-v11.mts --data-dir /srv/gate/data --dry-run

# 2. Migrate the gate copy for real.
npx tsx scripts/migrate-v11.mts --data-dir /srv/gate/data

# 3. Compare the migrated copy with the snapshot it came from.
npx tsx scripts/migrate-check-v11.mts --before /srv/snapshots/20260919T2227Z --after /srv/gate/data

migrate-v11.mts migrates the registry and every organisation file in one pass, with the services stopped, so that nothing migrates in the middle of live traffic — opening a file still migrates it too, as a safety net. It prints a JSON report per organisation (vendor keys, virtual keys, the model-access selection, pinned keys converted, keys still waiting to pin) and a one-line summary on stderr. It stops at the first error and names the file and the step. Exit 0 means success.

migrate-check-v11.mts is the part that makes the gate worth running. It fails the release, exit 1, on any difference that is not a planned one:

A PASS line names the counts it compared. A FAIL lists every difference it found. Only tenants.db_path is permitted to differ, because the gate copy rewrites it.

Known defect in the dry run. migrate-v11.mts --dry-run reports the registry's target version as unchanged (for example "registry v7 → v7") because the dry path deliberately never opens the registry and so never learns the target. Every fact it prints about organisations is correct; only that one version number is wrong. Do not read it as "the registry needs no migration".

9. Security checklist

CheckWhy
The gateway's two ports are reachable only from the proxy. In the compose profile, only Caddy publishes ports; if you install on a host, bind to loopback or a private interface.They speak no TLS and expect a trusted proxy in front. Exposing 8080 hands callers direct reach and lets them set their own forwarded headers.
HTTPS only, HSTS on both host blocks, TLS terminated at the proxy.Gateway keys and session cookies travel on every request.
The container runs as a non-root user with a read-only root filesystem, /data the only writable volume and a tmpfs for /tmp.Already set in the compose file. Keep it that way; it is cheap and it bounds what a compromise can change.
GATEWAY_MASTER_KEY is 32 random bytes, in a 0600 file, backed up somewhere the database backups are not.§1.1. Rotation is not built, so the copy you made at install time is the copy you will have forever.
TRUSTED_PROXIES is exactly the proxy's range.§5.3. Every address-based control depends on it.
ADMIN_ALLOWED_CIDRS restricts platform. to addresses you control.That console moves credit, assigns vendor keys and can enter support mode on any organisation.
DEV_SKIP_TOTP is unset. TOTP is mandatory for every role, with recovery codes shown once.§6.4. The boot refuses it on a real domain, which is a backstop and not a reason to set it.
Redis is reachable only from the application.The compose profile relies on the private network alone and sets no password. If Redis is reachable from anywhere else, put credentials on it and change REDIS_URL to match — it holds every budget counter and credit hold.
Set LEDGER_RETENTION_MONTHS and PROMPT_LOG_RETENTION_DAYS to real windows once you have a policy.Both default to ten years, deliberately, so that nothing is discarded before you have decided. Leaving them is a decision too.
Ship the snapshots off the box and run the restore drill.§7.2 and §7.3.
Install dependencies with --frozen-lockfile.The lock file is the tested dependency tree.
Decide who runs the tests before each release, and when.There is no continuous integration; nothing runs the suite except a person choosing to (spec §18.4), so the guarantee is that the tests passed when somebody last looked. pnpm test is the suite. pnpm test:report runs the whole round and writes a timestamped HTML report into docs/test-reports/, one file per run, so the folder is a history rather than an overwritten result; --no-sweep skips the live sweep and is much faster.
Prove at least once that a real vendor key works, not just that the code is correct.The suites and the report's sweep both run against the in-repository mock vendor, so a green report says nothing about whether your Anthropic or OpenAI key is good. pnpm live:check is the one that calls the real APIs — a plain request, a streamed request, an image attachment and a PDF, on both the OpenAI-shaped and the Anthropic-native endpoint, against a running pnpm dev:stack. Pass anthropic, openai or xai to narrow it. A passing run spends a few hundred tokens of real money. In a failure, credential_unavailable means the vendor refused the key, while credit_exhausted means there was no usable key to route to at all.
Two things the product does not give you, and you may need. Running this gateway makes you a processor of other people's text, whether or not money changes hands, and whether or not the organisations use their own vendor keys. The controls exist — bodies are stored only for keys an organisation switches to full logging, inside that organisation's own file, with a retention window — but three things are specified and not built (spec §14.4): a published data statement reachable from the console and the registration form; a sub-processor list naming which vendor receives which requests; and a visible mark in the console on keys that are logging bodies. Registration already asks people to accept terms that do not exist. If you are running this for anyone but yourself, that is your gap to fill.

10. Troubleshooting

SymptomWhat it meansWhat to do
Every request answers 503 gateway_unavailableRedis is unreachable. This is the designed behaviour, not a failure to handle a failure./readyz on port 8080 reports redis: unreachable and gw_redis_up is 0. Fix Redis; nothing needs reconfiguring and there is no degraded mode to switch on. If you find yourself wanting one, read §1.1 first: serving traffic without the counters means spending money with no cap. On reconnect, run the counter rebuild (POST /platform/v1/workers/rebuild/run) rather than waiting for the hourly pass, so budgets and credit agree with the ledgers again. Reservations that were in flight at the moment of loss are dropped by the rebuild; the reconciliation run settles what the vendors actually charged.
One organisation's requests answer 503 credential_unavailableA vendor rejected the key with 401 or 403, so the gateway marked that vendor key unhealthy and took it out of routing. The owner — you for a platform key, the organisation for its own — was alerted.Open the vendor key in the console and press Test, which makes one tiny real call. If the secret is dead, rotate it; the key stays out of routing until an admin re-activates it. The refused attempts are in the ledger at zero cost with status 503, so they are visible without being charged for. Two vendor quirks are handled for you: AssemblyAI sends rate limits as 403, so only a 401 marks its keys unhealthy; xAI answers a bad key with 400, so a 400 whose message names the API key is treated as a credential failure.
503 provider_unavailable, then recovery about half a minute laterThe circuit breaker for one vendor key opened after five failures and will stay open for 30 seconds.Usually a vendor incident. If it repeats, look at the vendor's status and at that key's failure alerts — more than ten failures in ten minutes for one vendor key raises one.
One organisation gets 503 tenant_storage_unavailable, everyone else is fineIts file could not be opened, is corrupt, or its handle was evicted mid-write. Files are opened WAL, with foreign_keys on and a five-second busy timeout, so this is not ordinary contention.An operator alert (storage.integrity) will have fired. Run PRAGMA integrity_check on the file. The usual cause is another process holding a write lock — a migration script left running, or a copy taken with cp rather than VACUUM INTO. If the file is damaged, restoring one organisation is copying one file back from snapshots/.
The certificate did not renewCaddy renews on its own, but the DNS-01 challenge needs the API token to still be valid and scoped, and Caddy needs its state.Nothing in the gateway watches the certificate, so watch Caddy's log or check the expiry yourself on a schedule. Never recreate the Caddy container without keeping its caddy_data volume: that is where the certificates and the ACME account live, and losing it means re-issuing everything and possibly meeting the issuer's rate limits.
A migration failed part-wayEach version runs in its own transaction, so the file is at the last version that completed — never half-way through one. The script stops at the first error and prints the step and the file.Do not start the new release against a partly migrated directory, and do not try to hand-finish it. Migrations are forward-only: go back to the snapshot you took before the run, work out what failed on a copy, and start the gate again.
The container starts and exits immediatelyUsually configuration. loadConfig throws a ConfigError naming the variable: BASE_DOMAIN is required, GATEWAY_MASTER_KEY must be 32 bytes, base64-encoded, TRUSTED_PROXIES contains an invalid CIDR, or the DEV_SKIP_TOTP refusal of §6.4.Read the first line of the log. If the message is instead that tsx cannot be found, it is the Dockerfile defect in §3.2.
Nobody can complete a sign-upEmail. Verification is a single-use token with a 24-hour expiry, and without SENDGRID_API_KEY nothing is sent.The boot log says which mailer is in use. Check EMAIL_FROM is a verified sender on the SendGrid account; SendGrid rejects everything otherwise. Also check the effective registration mode in the platform console rather than the environment (§6.1).
Everyone's requests look as though they come from one addressTRUSTED_PROXIES does not cover the proxy, or something sits in front of it that you have not accounted for.§5.3. Fix it before you rely on an address allowlist, because the allowlist will look correct in the console while matching the wrong thing.

For anything that leaves a trace, the places to look are: the structured request log (one JSON line per request, carrying the request id the caller was given, and never a body or a credential), the organisation's own Refusals page for anything refused before a vendor was called, the platform console's reconciliation page for a disagreement between the gateway's figures and a vendor's, and /metrics on the consumer API. Five series there answer most questions:

SeriesWhat a change in it means
gw_redis_up0 means the gateway is refusing everything (§1.1).
gw_rejections_total{code}Rejections by error code. A rise in ip_not_allowed for one organisation is what a leaked key looks like; credit_exhausted means somebody needs a top-up; rate_limit_exceeded usually means a client's limit is set below what it actually does.
gw_circuit_state{credential_id}0 closed, 1 half-open, 2 open. A key sitting at 2 is out of routing.
gw_reconciliation_last_run_age_secondsOlder than a day means the nightly run is failing. Check the server log and the admin credentials — a delta you never see is worse than one that alerts.
gw_db_pool_openOpen organisation-file handles. Pressed against MAX_OPEN_TENANT_DBS means the pool is thrashing; raise it and the file-descriptor limit together.