This page covers what routes where, which images to run, and how to size and persist the API host, the runner fleet and the review workspace.
For what the components are and how they fit together, see what you run. Every variable named on this page has its default and accepted range in the environment variable reference.
Deployment topology
If you bring your own ingress instead of the bundled proxy, this is what has to route where.
| Path | Goes to | Notes |
|---|---|---|
/ | Frontend | A static SPA; unknown paths fall back to index.html |
/api/ | API, prefix stripped | The /api prefix exists only at the proxy layer |
/webhooks/ | API, prefix not stripped | Webhook ingress is not under /api |
The published frontend image calls the API at /api on the origin it was served from, so the frontend
and the API have to be served from one hostname.
The API serves plain HTTP on port 8080 and ProCursor on port 8081, both as a non-root user. The frontend image also serves HTTP on 8080, as a non-root user. TLS terminates at your proxy. Nothing routes to ProCursor’s port from outside - see ProCursor as a separate service.
Set MEISTER_PUBLIC_BASE_URL to the externally reachable API base URL, including the /api prefix if
your proxy uses one. ProPR uses it for the webhook listener URLs shown in the UI, for SSO redirect URLs,
and for the browser origin it allows. Without it, callback URLs fall back to the request host.
X-Forwarded-For and X-Forwarded-Proto are honoured from a single hop, and only when the connecting
proxy is on loopback or in a private range (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). There is no
setting that widens that list. Where your ingress sits outside it, both headers are ignored and ProPR
reads the direct connection. Every request then appears to come from the proxy’s address, so the per-IP
rate limit on the sign-in endpoints buckets all sign-in attempts together (see
sign-in and sessions). The request scheme becomes the
one the proxy used to reach the API, not the one the browser used. MEISTER_PUBLIC_BASE_URL keeps
generated URLs correct in that case, because ProPR uses it ahead of the request’s own scheme and host.
Allow additional browser origins with CORS_ORIGINS (comma-separated).
Do not route /metrics, or /api/metrics, from a public edge - see
what to block at your edge.
Symptoms that trace back to routing or the public base URL are indexed in troubleshooting.
Running published images
docker compose up --build builds the three ProPR images - API, ProCursor and frontend - from the
checkout. To run published release images instead, replace the build: block of each of those three
services in example/docker-compose/docker-compose.yml with an image: line:
| Service | Image |
|---|---|
meisterpropr | ghcr.io/meister-dev-ai/propr:<tag> |
procursor | ghcr.io/meister-dev-ai/propr/procursor:<tag> |
frontend | ghcr.io/meister-dev-ai/propr/frontend:<tag> |
Then start the stack with docker compose up -d, without --build. Keep the three tags aligned - see
upgrades and backups. Everything else in the file, including
PostgreSQL and the bundled proxy, already runs published images.
A fourth image, ghcr.io/meister-dev-ai/propr/runner:<tag>, is published from the same release and is
not part of the compose stack. It runs on hosts of your own; see scaling runners.
Give it the same tag as the control plane, because a runner has to stay inside that control plane’s
contract compatibility window.
Review workers
There is no separate worker to deploy - the background work runs inside the API process, listed under what you run. Sizing review throughput therefore means sizing the API host.
Two settings multiply into peak load. WORKER_MAX_CONCURRENT_REVIEW_JOBS bounds how many reviews one API
instance runs at once, and AI_MAX_FILE_REVIEW_CONCURRENCY bounds how many files one review works on in
parallel. Raise either and the host needs the memory, CPU and provider rate limit to match.
Both settings need a commercial license that allows parallel review execution. Without one, ProPR runs one review at a time and works on one file at a time. A license stating a lower concurrent-review ceiling applies instead of the configured number, and a license that leaves the concurrent-review limit absent applies one review at a time - see editions.
Do not add API instances. The in-process workers are not coordinated between processes, so a second instance runs the crawler, the mention scan and the retention purge twice. Run one API instance and raise its concurrency.
Scaling runners
Adding runners is the supported way to review more at once. See the runner architecture for what a runner is.
The two deployment shapes differ in how a host gets its enrollment.
Hosts you start yourself. Issue a token per host and start it. Use this for a small installation.
A group the platform scales for you, such as Azure Container Apps, a Kubernetes Deployment or an ECS service. Replicas appear without an operator present, so they cannot each be handed their own token. Issue one token whose enrollment count covers the replicas the group may run, put it in the platform’s secret store, and give every replica the same environment. Each spends one use as it starts.
Scaling on the queue, not on traffic
A runner polls for work; nothing calls it. An HTTP scale rule therefore measures nothing, and the default Container Apps rule will hold the group at its minimum however deep the queue is. Scale on the queue itself with a KEDA PostgreSQL scaler pointed at the control plane’s database:
scale:
minReplicas: 1
maxReplicas: 10
rules:
- name: pending-reviews
custom:
type: postgresql
metadata:
query: "SELECT count(*) FROM review_jobs WHERE status = 'Pending'"
targetQueryValue: "2"
auth:
- secretRef: propr-db-connection
triggerParameter: connection
targetQueryValue is jobs per replica: at two, a queue of ten asks for five replicas. Size it against
RUNNER_CAPACITY, which is how many each replica runs at once. A capacity of two and a target of two
means a replica is asked for only once the existing ones are full.
On minReplicas: 0. Scale-to-zero is the cheapest setting. It costs the first review of an idle
period a cold start, including the runner’s tree-sitter probe. Keep one warm replica where that delay
matters.
What a rolling update depends on
A redeploy depends on three behaviours.
Draining. The platform signals the replica and waits. The runner releases the leases it holds on shutdown, so a job in flight returns to Pending and another replica picks it up. Give the group a termination grace period long enough for that release. It takes a handful of requests, so it does not need to cover the length of a review.
Version skew. During the rollout, replicas on the old and the new contract version poll at once. The compatibility window covers that. A runner outside the window is refused, and the refusal reads as a lost lease, not a crash. Update runners together with the control plane, because skipping enough versions in one jump leaves the window.
Contract version 3 removed the comment-screening and evidence-verification switches from the job manifest, because every finding is now judged on the runner. A version 3 control plane refuses runners of version 2, so upgrade the runner image together with the control plane.
Identity. A replaced replica does not keep its registry row. The credential is held in memory only,
so the new one enrolls afresh. Rows from replicas that are gone are removed by the prune sweep; see
RUNNER_PRUNE_UNSEEN_DAYS in the configuration reference.
Review workspace
ProPR clones the repositories it reviews to local disk on the API host and reuses the mirror across
reviews. Plan for a writable directory large enough for the repositories you review, and bound it with
REVIEW_WORKSPACE_MAX_CACHE_SIZE_MEGABYTES.
The directory is a cache - losing it costs re-cloning, not data. But if you leave it on the container’s
writable layer, as the example stack does, every restart re-clones everything. Mount a volume for it in
any deployment you keep, and point REVIEW_WORKSPACE_ROOT_PATH at it.
Sizing it means budgeting for two things that are bounded separately:
- The mirrors, one per repository you review.
REVIEW_WORKSPACE_MAX_CACHE_SIZE_MEGABYTESis the target the eviction sweep works towards, andREVIEW_WORKSPACE_FETCH_DEPTH_POLICYdecides how much of each repository a mirror holds. A mirror is kept after the review that fetched it, and is evicted once the mirrors together exceed the target. The target is not a limit: the sweep skips any mirror a running review still holds, so with several large repositories under review at once the total can sit above it until those reviews end. Leave headroom for the mirrors of the reviews you expect to run together. - The checkouts, one per review, holding the head revision. These are not counted against the cache
bound. Each is deleted as its review ends, so they occupy the disk only while reviews are running, and
REVIEW_WORKSPACE_MAX_CONCURRENT_PREPARATIONSbounds how many are being written at once.REVIEW_WORKSPACE_RETENTION_MINUTESgoverns how long a checkout directory that no running review holds waits before the sweep removes it. That covers a checkout whose preparation failed before the review that would release it began, one belonging to a review the process did not finish, one left by a delete that failed, and after a restart every checkout on the disk, because which reviews were running is not carried across a restart. Budget for those where restarts or failures are frequent: they occupy the disk for the retention window.
The disk therefore has to hold the mirrors of the repositories under review at once, at their real size, plus what the sweep has trimmed the unreferenced mirrors down to, plus one checkout per running review, plus the checkouts awaiting the retention window. Size the volume from the repositories you review, not from the target, and keep the target below the space available. Eviction starts only once the mirrors together pass the target, so a target larger than the volume starts it after the disk is already full, and reviews fail during checkout with “No space left on device”. See review workspace.
Installing an AI provider add-in
An AI provider family can be added as a built assembly the API host runs. The API image contains a
directory for the families ProPR ships, which are always loaded. A provider add-in you built yourself goes
in a second directory, which AI_PLUGIN_DIRECTORY names and which defaults to plugins beside the
application. A platform administrator activates an add-in in that second directory before the host runs
it. The runner image carries neither: a runner relays model calls and resolves no driver.
An add-in runs with the host’s full authority. The families in the image’s directory load on every start. One in your directory loads once an administrator activates it. Either way it runs in the API host’s own process, with its configuration, its network and its database connection, and it is not sandboxed. Write access to the add-in directory is deploy access. See AI provider add-ins.
Each family occupies its own folder, named after its assembly, so the folder holds that family’s assembly and that family’s own dependencies:
/provider-add-ins/
Contoso.Ai.Providers.Acme/
Contoso.Ai.Providers.Acme.dll
Contoso.Ai.Providers.Acme.deps.json
<that family's own dependencies>
Two ways to install one: mount the directory into the API container, as the commented-out block in
example/docker-compose/docker-compose.yml shows, or add it as a layer on top of the published image.
Mount it read-only, and own it on the host as a principal other than the container’s app user. An
add-in runs inside the API process with the host’s full authority and nothing sandboxes it, so the
directory holds code you have decided to run. Neither the read-only mount nor the separate owner is a
security boundary. They keep the process that loads the code from changing it, so the content hashes on
the inventory page can be compared against what you shipped.
The host reads both directories once, while it starts. A file added or removed afterwards changes nothing until the host restarts. An assembly that cannot be loaded is skipped and does not stop the host starting: the host starts with whatever loaded, and records the skip with a category and a reason.
The host treats the two directories differently. It loads every family in the image’s directory. It reads a file in your directory without running it: the assembly states its identity, label, version, contract version, reached hosts and required capability, and the host reads that statement and executes nothing in the file.
An administrator opens the AI provider add-ins page under Administration. The page lists both directories, and for each file its path, its content hash and what the assembly states about itself. The administrator activates the add-ins this installation is to run. Activating loads the add-in, so the family serves from that moment. Every later start loads the activated add-ins again, once the database schema is up to date.
An activation is bound to the content hash. Replacing the file brings it back for a fresh decision, and moving it changes nothing. The page refuses an activation when the file changed since the host read it, when the identity is one this host already serves, and when the loaded driver declares an identity, a required capability or a host that the assembly does not state. A driver contacting fewer hosts than the assembly states is accepted, so a family whose endpoints come from local configuration states every host it may reach and is activated once.
Withdrawing an activation records the decision and stops the next start loading the add-in. The family keeps serving until the host restarts, because an assembly cannot be unloaded from under the reviews using it.
The host measures every family it loads against the shared driver checks and skips one that fails,
recording it as non-conforming with the check that failed. The checks read what the family declares and
replay the usage payload it recorded; none of them resolves a name or reaches the network. They catch an
authentication mode that is not the family’s to name, credential fields declared for a mode the family does
not offer, a field reference naming a field that does not exist, and a usage mapping that would misprice
every review the family serves. A family you build runs the same checks in its own build, which reports the
same failures before you deploy it.
Two rules decide a family that is installed twice. The directory inside the image wins over the one you configured, so a patched copy of a shipped family is recorded as a duplicate and the shipped copy keeps serving; changing a shipped family means a product release. Two copies inside one directory are both skipped, since neither has precedence over the other.
ProCursor as a separate service
ProCursor runs as a separate internal service and is never exposed publicly. The API is the public
control plane, and the two services authenticate to each other with PROCURSOR_SHARED_KEY. ProCursor
keeps its operational data - indexes, snapshots, token usage - in its own database, and reports health on
a separate endpoint, which the API surfaces as the procursor-remote check - see
what the health checks mean.
Point both services at the same encryption key ring, and give them the same key-ring protector and its settings. See the encryption key ring. A service reading a ring protected differently from the way it was written cannot open it.
Key-ring compatibility during upgrades
Preserve the database, the existing key ring, and the MeisterProPR data-protection application identity
when upgrading. An existing installation does not need a certificate or Azure Key Vault configuration
unless the operator selects that protector. Keep the configured key path unchanged. Relative paths resolve
against the process working directory, and surrounding whitespace is part of the path. Keep the working
directory unchanged or configure the absolute path to the existing ring.
The built-in certificate protector is available in both service images. Keep the previous certificates or Azure Key Vault key versions needed to decrypt existing keys when rotating protection settings. Selecting a protector or a different key repository is an explicit deployment operation.
Protecting the key ring with Azure Key Vault
Both service images carry the azure-key-vault key-ring protector in their own add-in directory, so an
installation on Azure selects it with MEISTER_DATA_PROTECTION_PROTECTOR=azure-key-vault and needs no
rebuild. Set MEISTER_DATA_PROTECTION_AZURE_KEY_VAULT_KEY_ID to the key identifier on both services and leave
the key path as it is. Wrap and unwrap on that key go to the identity each service runs under, which is not
the deployment identity where the two differ; both services open the ring at start-up and both need it. With
MEISTER_DATA_PROTECTION_AZURE_BLOB_URI set as well, the services use the blob as their key repository; the same runtime identities need read and write on the blob. Each URL you set must use
https. The variables are under
the Azure Key Vault protector.
Setting the blob URI on an installation that already holds keys does not transfer them. An empty destination creates a new ring, which cannot decrypt values protected with the existing ring. A destination containing another installation’s ring selects those keys. For an upgrade, transfer the existing ring before changing the repository setting.
For a new installation, use a dedicated blob. To transfer an existing filesystem ring, stop both services, build
one XML document whose <repository> root element holds the <key> element of every file in the key
directory, and upload it to the blob the variable names. Check two things before you start the services
again: the uploaded document carries one <key> element per file in the key directory, with the same id
attributes, and the identity both services run under can read and write the blob. Read access alone passes
this check and starts the services, then fails when Data Protection creates or rotates a key. Keep the key
directory until a review has run on the new setting, so a key the upload omitted can still be recovered.
Then set the variable and start both services.
To restore such an installation: restore the databases, restore the key ring from the volume or from the blob where the ring lives there, and give the identity both running services use wrap and unwrap on the same vault key, and read and write on the blob where the ring is blob-backed. The key files alone read nothing. A vault key that was deleted takes the key ring with it, so treat the key’s own backup and its soft-delete and purge-protection settings as part of the installation backup.
For what ProCursor does and what indexing costs, see ProCursor.
Running without ProCursor
To deploy ProPR without the code-knowledge service, set PROCURSOR_REMOTE_MODE=disabled and leave
PROCURSOR_SERVICE_BASE_URL and PROCURSOR_SHARED_KEY unset. ProPR then omits the ProCursor review
tools, and the procursor-remote check drops out of /healthz. Reviews still run - for what they lose,
see ProCursor.
This applies to a deployment you assemble yourself, not to the bundled compose stack. That stack
always defines the ProCursor service and will not start without it: the API waits for ProCursor to
report healthy, and ProCursor itself will not start without the shared key it
requires. Leaving the key unset there gets you a crash-looping
ProCursor and an API that never comes up. To evaluate without ProCursor on the example stack, delete the
procursor service and the procursor entry under the API service’s depends_on before setting the
mode to disabled.