Deployment
What a Perdurance deployment is made of: one container image, one Postgres, and a handful of pointers. Every figure it charges or enforces lives in the database and changes without a restart. Dedicated deployments run in our cloud or in yours, and we operate both.
Perdurance runs as a shared hosted service, or as a deployment dedicated to one customer — in our cloud or in the customer's own cloud account. We operate it either way; there is nothing here you install and run yourself. This page is what a deployment is made of: the reference for whoever operates one, and the answer to the security review that asks what would be running.
A dedicated deployment holds one customer's prompts, provider credentials and transcripts in a database no other customer's traffic reaches, and it is deliberately small enough that one person can hold all of it in their head.
One container image and a Postgres. There is no queue to operate, no cache to size, no orchestrator, and no second datastore. The image is the same one whichever role it runs as.
The shape
The binary reads --role and assembles itself accordingly:
--role | Serves |
|---|---|
both (default) | The API and the executor in one process |
api | The routes callers reach |
worker | The executor, and its own health probes only |
A manifest that says nothing gets both: one process, one Postgres, and you are running. Split
into two Deployments when you want to scale answering and executing apart, or want a rollout of
one not to disturb the other.
A worker could answer every route — it has the same database and the same keys — and does not,
so that what a deployment exposes is a property of the manifest rather than of whichever pod a
request happened to reach.
Configuration
A deployment is configured in three places, and which one a setting lives in follows one rule.
The environment carries pointers. A URL, an address, or a token — something that tells this process where another one is, or lets it in when it arrives. Nothing else, apart from the one exception noted under the table. Everything has a default.
| Variable | Default | |
|---|---|---|
CTX_BIND_ADDR | 0.0.0.0:8080 | Where it listens |
CTX_DATABASE_HOST | localhost:5433 | Where Postgres answers |
CTX_DATABASE_USER | router | The role to connect as |
CTX_DATABASE_NAME | router | The database to open |
CTX_DATABASE_SSLMODE | require | What the connection expects of its transport |
CTX_DATABASE_MAX_CONNECTIONS | 32 | Per process |
CTX_DATABASE_SESSION_NAME | The same as CTX_DATABASE_NAME | The database name the dispatch listener and the migrator open |
CTX_SECRETS_DIR | secrets | Where the secret mount is |
CTX_CONSOLE_ORIGINS | http://localhost:5173 | Browser origins the console may call from |
CTX_OPERATOR_ORIGINS | unset | Browser origins the operator portal may call from |
CTX_CHUNK_BUS_URL | unset | Where live chunks are fanned out between processes |
CTX_BACKEND_HOSTS | any | Which addresses a backend may be pointed at: any, or public for addresses the internet routes to |
That table is what a dedicated deployment sets, not the whole list. CTX_DATABASE_SESSION_NAME
is there for one case: where Postgres is reached through a connection pooler. A pooler in
transaction mode does not keep a LISTEN across statements and would deliver no dispatch
wake-up, silently, and the migrator's advisory lock is session-scoped for the same reason. The
variable names the same physical database under a name the pooler serves in session mode. A
deployment connecting straight to Postgres never loses its session and leaves it unset.
CTX_BACKEND_HOSTS is the one entry in that table that is not a pointer. It says which
addresses somebody may point a backend at, and it is a variable because it describes the
network the deployment stands in. any is the default and checks nothing, because a dedicated
deployment usually reaches its model provider inside its own network. public narrows it to
addresses the internet routes to, which is what a deployment open to people you have not met
wants. Administration has what a public
deployment refuses.
The command line carries what the process is. Which half it runs, how it logs, and which conditional surfaces it assembles — none of them a pointer, and none of them something a shared database row could answer, since every pod reads the same row.
router [--role api|worker|both] [--log text|json] [--dev-bootstrap] [--operator-portal]
[--credential-passthrough]--credential-passthrough is the one of those that decides a policy rather than a topology. It
allows a backend that forwards the credential each caller brought instead of one this deployment
stored, and without it every such backend and every submission to one is refused. Claude Code
and the Agent SDK has what it is
for and Anthropic's position on it.
A malformed value is a startup error with a sentence about it, not a default quietly
substituted. So is a flag the binary does not take: a manifest that misspells --role would
otherwise start a second API where it meant a worker.
Everything else is in the database
Not one figure the deployment charges or enforces is configuration. The fee, the retention horizons, the concurrency bounds, the lease, the registration ceilings and the rest live in the database and are changed while the deployment is running — a price an operator writes reaches every replica within seconds, with nothing restarted. There is a screen for it, described below.
There is no environment variable for any of them, and no precedence to learn. A figure resolves from three levels and no more: what a tenancy was given, what the deployment's row says, and what the build ships with.
| Figure | Default | |
|---|---|---|
| Lease | 60 s | How long a claim holds a request after its holder stops renewing |
| Sweep interval | 15 s | How often expired leases and stranded rows are re-dispatched |
| Synchronous hold | 300 s | How long a synchronous answer holds its connection |
| Upstream read timeout | 120 s | Between bytes from a provider |
| Upstream call ceiling | 3600 s | The longest a single provider call may take |
| Drain deadline | 30 s | How long a shutdown waits for work in flight |
| Global concurrency | 64 | Upper bound on simultaneous provider calls |
| Per-tenancy concurrency | 16 | Stops one tenancy holding every slot in the pool |
| Per-backend concurrency | 16 | Stops one slow provider starving the pool |
| Metering | off | Whether what the meter records is also spent |
Two of these wait for a restart
The two upstream bounds are built into the HTTP client when the process starts, so changing either takes effect on the next start rather than the next call. The drain deadline has to fit inside the pod's termination grace period, which the service cannot see — check the two together when you change either.
A fresh install has no operator yet, and an operator can be locked out. router operator settle
takes the same document the portal writes, so the figures can be set from wherever your
manifests are applied:
router operator settle '{"wide": {"lease_seconds": 30, "global_concurrency": 32}}'It replaces the deployment's layer whole, as the portal does. A level's row is its answers, so a figure the document leaves out returns to the built-in rather than staying where it was. The command above sets two figures and resets every other one. Read the current document off the portal and send it back with your edits in it, or accept the built-in for everything you did not name. It writes an audit row attributed to nobody. Whoever ran it had shell access, and no grant stands above that.
The operator portal
Where the figures above are changed. It is a separate sign-in on an origin of its own, and it
exists only where --operator-portal asked for it — a deployment that says nothing has no such
surface, and every route under it answers 404.
It shows every figure, which level answered for it, and when a change to it will land. It never shows a provider credential, a request body, or a password hash.
The first operator is created against the database rather than through a form:
router operator create [email protected] "Ops"It prompts for the password with the terminal's echo off. There is no operator registration route, here or anywhere.
Concurrency is per process
Both concurrency bounds apply to one process, while a provider's rate limit applies to your whole account. Raising the replica count without lowering these is how a rollout becomes a rate-limit incident. Two replicas want half the single-process figure each.
Three horizons, and only one of them is yours to set
What a request contains is kept for as long as its namespace says, per namespace. What happened to it is kept for three months and its fee ledger for seven years, both deployment-wide. The first is clamped by the second: a record cannot outlive the metadata whose deletion takes it away, so asking to keep a body for ever keeps it for three months. A tier, on a deployment that sells them, sets a ceiling of its own on the first — see Usage and storage.
Secrets are files, not variables
The secret mount is a directory. The file name is the secret name, the file content is the value.
| File | Purpose | Required |
|---|---|---|
credential-key | AES-256-GCM key for backend credentials, 32 bytes base64 | Yes |
api-key-hash-key | Keyed BLAKE3 key for API key hashes, 32 bytes base64 | Yes |
database-password | The password for CTX_DATABASE_USER | Yes |
registration-token-<client> | A token that may register a tenancy, one file per client | No |
Files rather than variables because a variable is readable from /proc/<pid>/environ and is
inherited by every child process — and these are the keys to prompt bodies, sealed provider
credentials and password hashes. The rest of the connection string is assembled from CTX_*
parts with database-password in the middle.
Two keys you cannot lose
credential-key decrypts your stored provider credentials and api-key-hash-key verifies your
API keys. Losing either does not lose your request history, but every backend credential must
be pasted again and every API key reissued. Back them up somewhere other than the database
they protect.
Upgrades
The binary applies its own migrations at startup, before it binds, and reports ready only
afterwards. There is no migration job to sequence, and sqlx takes an advisory lock while it
runs, so several replicas starting at once apply the set exactly once between them.
That makes a rolling update the ordinary path: the new pod starts, migrates, and reports ready while the old one drains. The drain deadline is how long the old one waits for work it is still holding.
Postgres
One database, and it holds everything: the requests, the responses, every stream chunk, the
usage ledger, the accounts and the sealed credentials. Size the volume against your namespaces'
retention horizons and what GET /storage reports — GET /storage counts
what is still held rather than what was ever sent, so it falls as records are swept.
It wants a volume of its own that survives rescheduling. Concurrent writers are expected — the
claim protocol is built for them — so scaling the router out needs nothing done to the database
beyond connections: CTX_DATABASE_MAX_CONNECTIONS is per process, so the total is that figure
times your replica count.
Getting the first tenancy in
A fresh deployment has no accounts, and no route creates the first one. Someone with write access
to the secret mount puts a registration-token-<client> file there, and that token buys exactly
one registration:
ROUTER_REGISTRATION_TOKEN=… sar register \
--url https://api.acme.example --tenancy acme --email [email protected]That creates the tenancy and its owner. On a dedicated deployment we run this once at handover and give you the owner account; everything after it is the console or the CLI.

