PerdurancePerdurance

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:

--roleServes
both (default)The API and the executor in one process
apiThe routes callers reach
workerThe 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.

VariableDefault
CTX_BIND_ADDR0.0.0.0:8080Where it listens
CTX_DATABASE_HOSTlocalhost:5433Where Postgres answers
CTX_DATABASE_USERrouterThe role to connect as
CTX_DATABASE_NAMErouterThe database to open
CTX_DATABASE_SSLMODErequireWhat the connection expects of its transport
CTX_DATABASE_MAX_CONNECTIONS32Per process
CTX_SECRETS_DIRsecretsWhere the secret mount is
CTX_CONSOLE_ORIGINShttp://localhost:5173Browser origins the console may call from
CTX_OPERATOR_ORIGINSunsetBrowser origins the operator portal may call from
CTX_CHUNK_BUS_URLunsetWhere 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.

FigureDefault
Lease60 sHow long a claim holds a request after its holder stops renewing
Sweep interval15 sHow often expired leases and stranded rows are re-dispatched
Synchronous hold300 sHow long a synchronous answer holds its connection
Upstream read timeout120 sBetween bytes from a provider
Upstream call ceiling3600 sThe longest a single provider call may take
Drain deadline30 sHow long a shutdown waits for work in flight
Global concurrency64Upper bound on simultaneous provider calls
Per-tenancy concurrency16Stops one tenancy holding every slot in the pool
Per-backend concurrency16Stops one slow provider starving the pool
MeteringoffWhether 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.

FilePurposeRequired
credential-keyAES-256-GCM key for backend credentials, 32 bytes base64Yes
api-key-hash-keyKeyed BLAKE3 key for API key hashes, 32 bytes base64Yes
database-passwordThe password for CTX_DATABASE_USERYes
registration-token-<client>A token that may register a tenancy, one file per clientNo

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

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.

On this page