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. 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_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 |
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]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}}'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 ops@example.com "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.
Retention is unset by default
Nothing is ever deleted until you say so. That is the right default for a system of record and the wrong one for a storage bill — 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 you@example.comThat 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.

