Skip to content

Configuration

Pia Server uses standard ASP.NET Core configuration: layered sources with environment variables overriding appsettings.json. Sensitive values (secrets, master key) must come from environment variables in production.

  1. appsettings.json
  2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
  3. Environment variables
  4. Command-line arguments
Key Env Purpose
Database:Provider Database__Provider sqlite (default in dev) or postgresql
Database:ConnectionString Database__ConnectionString EF Core connection string for the chosen provider — the role every request uses
Database:MigrationConnectionString Database__MigrationConnectionString Owner role. Read only by the --migrate-only entry point and by local development; production leaves it unset on the server so the application process holds no owner credential
Database:MaintenanceConnectionString Database__MaintenanceConnectionString Bypass role for the admin roll-up and the retention sweep, which must read every user’s rows

PostgreSQL example:

Host=postgres;Database=pia;Username=pia;Password=<db-password>

Migrations are applied on startup only when the process has a credential that can run DDL: always under SQLite, and under PostgreSQL only when Database:MigrationConnectionString is set. Production leaves it unset and runs dotnet Pia.Server.dll --migrate-only from a one-shot container instead; the server then asserts at boot that no migration is pending and refuses to start with PIA-DB-001 if one is.

Database:MaintenanceConnectionString enables the PostgreSQL database role split, which puts row-level security on assignments and assignment_events. Leave it unset and the split is not adopted: everything runs on Database:ConnectionString exactly as before.

Key Env Purpose
Jwt:SecretKey JWT_SECRET_KEY HMAC signing key, ≥ 32 chars
Jwt:Issuer Default pia-server
Jwt:Audience Default pia-client
Jwt:AccessTokenLifetime Default 15 minutes
Jwt:RefreshTokenLifetime Default 30 days
{
"OAuth": {
"Google": { "ClientId": "...", "ClientSecret": "..." },
"Microsoft": { "ClientId": "...", "ClientSecret": "..." }
}
}

In production, prefer environment variables: OAUTH_GOOGLE_CLIENT_ID / _SECRET, OAUTH_MICROSOFT_CLIENT_ID / _SECRET. See Microsoft Entra ID OAuth for the full Entra setup.

Key Env Purpose
Encryption:MasterKey ENCRYPTION_MASTER_KEY 64 hex chars (32 bytes) — used to derive per-user keys via HKDF

The default upstream AI provider, plus optional per-mode overrides selected by the X-Pia-Mode header.

Key Env Purpose
Ai:ProviderType Ai__ProviderType One of the seven chat types: OpenAI, AzureOpenAI, Ollama, OpenRouter, OpenAICompatible, Mistral, VLlm (default OpenAI). Linkup is a web search provider, not a chat model — naming it here or under Ai:Modes stops the server at startup
Ai:ApiKey Ai__ApiKey Upstream API key
Ai:Model Ai__Model e.g. gpt-4o-mini
Ai:Endpoint Ai__Endpoint Upstream base URL
Ai:CreditRates:InputPerMillion Ai__CreditRates__InputPerMillion Credits charged per million input tokens against a member’s group credit ceiling. Default 1000 — the anchor, where one credit is 1,000 tokens. Also valid under Ai:Modes:<Mode>: and per catalog entry below. Omit it or give it a number — never an empty value, which fails the bind at startup the same way an empty Ai:EnableWebSearch does
Ai:CreditRates:OutputPerMillion Ai__CreditRates__OutputPerMillion The same, per million output tokens. Default 1000
Ai:CreditRates:CachedInputPerMillion Ai__CreditRates__CachedInputPerMillion The same, for the input tokens the provider served from its cache. Default 1000 (no discount); must not exceed the input rate
Ai:CreditRates:ConnectorPerMillion Ai__CreditRates__ConnectorPerMillion The same, for provider-side connector tokens such as Mistral web search. Default 0, which keeps them out of the budget
TokenUsage:WeekStartTimeZone TokenUsage__WeekStartTimeZone IANA zone the weekly credit window is anchored in — the bucket starts Monday 00:00 there. Default Europe/Berlin. An id the host does not know is logged and falls back to UTC rather than failing a request

Per-mode overrides take the same keys under a mode name:

Key Mode
Ai:Modes:Assistant:* (Ai__Modes__Assistant__*) Assistant
Ai:Modes:Research:* (Ai__Modes__Research__*) Research
(default Ai:*) Optimize

If a mode override isn’t set, the default provider is used.

The provider catalog: a named list of upstreams that a group’s provider override, a guardrail’s classifier and answer models, Embeddings:ProviderId and Chunking:Context:ProviderId all reference by id. Entries defined here belong to the deploy: read-only in the admin console, and read once at startup. The same catalog also holds rows added at runtime on AI Providers, which is where the merge rules live.

Key (per entry) Purpose
Id Required. The stable reference group settings store
Name The label shown wherever a provider is picked
ProviderType As above, plus Linkup for a web search provider — an entry a chat model delegates searching to, never one that answers a chat. Defaults to OpenAI
Endpoint / ApiKey / Model The upstream, its credential, and the one model this entry serves. On a Linkup entry Model is the search depthfast, standard or deep — and anything else stops the server at startup
AzureDeploymentName Required for AzureOpenAI
MistralAgentId Mistral only, optional
ReasoningEffort None, Minimal, Low, Medium, High or XHigh. Omit to send no preference at all — None instead asks the upstream not to think, and only reaches types that own an off-switch. VLlm is the exception: it always pins enable_thinking, so omitted and None behave alike there
EnableWebSearch / EnableVision Capability switches. EnableWebSearch is read as off on AzureOpenAI, Ollama, OpenAICompatible, VLlm and Linkup, whose handlers have no native search — set it there and it is ignored rather than rejected, which is also what lets those entries use WebSearchToolProviderId
WebSearchToolProviderId Another catalog id — a Linkup entry or database row — this entry delegates web search to when it cannot search itself. The model’s query text is sent to that provider; see AI Providers
VisionToolProviderId The same shape for image reading: a vision-capable catalog id a text-only entry delegates to
SupportsReasoningEffort OpenAICompatible only: send the flat reasoning_effort field, none included. Off by default, since strict servers reject unknown fields — and not every server that takes low/medium/high takes none
CreditRates:* The four rates above, per entry: InputPerMillion, OutputPerMillion, CachedInputPerMillion, ConnectorPerMillion. The card is stamped onto each usage row at spend time, so changing it re-prices future traffic only. A rate outside 010000000, or a cached rate above the input rate, stops the server

An array takes a numeric segment in environment form, so Ai:Providers[0].Id is Ai__Providers__0__Id. Indices must start at 0 and stay contiguous — the binder silently ignores every entry past a gap.

Knowledge, Embeddings, Chunking (knowledge base)

Section titled “Knowledge, Embeddings, Chunking (knowledge base)”

The knowledge base requires both the Knowledge license feature and Knowledge:ConnectionString set — miss either and it stays off. See Admin → Knowledge bases for the full setup flow and Architecture → Knowledge base for how it works.

Key Env Purpose
Knowledge:ConnectionString Knowledge__ConnectionString pgvector DB connection — enables the KB when set
Knowledge:IngestApiKey Knowledge__IngestApiKey Instance-wide REST ingest key (X-Pia-Service-Key); empty disables the REST API
Embeddings:ProviderId Embeddings__ProviderId A provider catalog id — Ai:Providers[].Id or a database row — resolving the embedding endpoint/key
Embeddings:Model Embeddings__Model Embedding model (default bge-m3)
Embeddings:Dimension Embeddings__Dimension Must equal the compiled schema (1024); mismatch disables the KB
Embeddings:TimeoutSeconds Embeddings__TimeoutSeconds Ingestion upstream timeout (default 100)
Chunking:Context:Enabled Chunking__Context__Enabled Contextual retrieval, on by default (auto-disabled if no provider)
Chunking:Context:ProviderId Chunking__Context__ProviderId A provider catalog id — Ai:Providers[].Id or a database row — of a chat model for per-chunk context
Chunking:* Chunking__* Semantic double-pass splitter knobs (buffer size, thresholds, chunk-size bounds)

Per-group quotas (KnowledgeBases, KnowledgeDocuments, KnowledgeStorageBytes, MonthlyEmbeddingTokens) live in GroupSettings.Quotas and are edited in the group editor, not in config.

The Pia Mesh operator runtime. Off by default. Enabling it needs the AiProxy license feature and a reachable Temporal service — the .NET SDK ships only a worker, so the service is a separate container (both shipped compose files wire one).

Key Env Default Purpose
Operators:Enabled Operators__Enabled false Master switch — restart-only
Operators:MaxSteps Operators__MaxSteps 8 Steps in one assignment — must be at least the longest reachable skill (brief takes 3)
Operators:WallClockTimeoutSeconds Operators__WallClockTimeoutSeconds 900 Total run time
Operators:MaxConcurrentAssignments Operators__MaxConcurrentAssignments 4 Worker-wide concurrency (not per user)
Operators:MaxConcurrentAssignmentsPerUser Operators__MaxConcurrentAssignmentsPerUser 2 One user’s Queued + Running rows; a further POST gets 429. Clamped to MaxConcurrentAssignments
Operators:MaxActivityAttempts Operators__MaxActivityAttempts 3 Retries of a single step
Operators:PerAssignmentTokenCeiling Operators__PerAssignmentTokenCeiling 200000 LLM spend per assignment
Operators:QueuedGraceMinutes Operators__QueuedGraceMinutes 5 How long a Queued row waits before the reconcile pass owns it
Operators:MaxReconcileAttempts Operators__MaxReconcileAttempts 5 Reconcile tries before a row is force-failed
Operators:RetentionDays Operators__RetentionDays 30 Age at which a finished assignment and its events are deleted
Operators:PlaintextRetentionHours Operators__PlaintextRetentionHours 72 Age at which a finished assignment’s plaintext is dropped even if no client acknowledged it. Zero or negative falls back to 72 — see Retention for why, why the Temporal namespace retention is the other half of the number, and why moving it makes the desktop client’s consent copy (which names 72 hours as a constant) wrong
Temporal:Address Temporal__Address temporal:7233 Temporal service — restart-only
Temporal:Namespace Temporal__Namespace pia Restart-only
Temporal:TaskQueue Temporal__TaskQueue pia-operators Restart-only

Operators:Enabled and all three Temporal:* keys are read once at startup, before the configuration overlay the admin UI writes to is layered in. That is deliberate: a running worker must not have the address changed underneath it. Every other Operators:* number hot-reloads and takes effect on the next tick or the next assignment.

Connector (pod) settings are not configuration keys — they live on catalog rows and are edited from Admin → Connectors.

Key Purpose
License:Path Path to the signed .lic file (default ./license.lic next to the binary; both Compose files set /app/data/license.lic, on the mounted data volume)
Setup:TokenPath Where the setup token is written while the server is unlicensed (default ./.setup-token)

There is no License:PublicKey key — the verifying key is compiled into the binary.

See Licensing for how a licence is obtained and replaced, and Admin overview → Activating a new install for the setup token.

Key Purpose
OpenApi:Enabled Serve the generated API description at GET /openapi/v1.json. Default false

Off by default because the route is anonymous once on: anything that can reach the server can read the shape of your surface without an account. That is deliberate — an integrator has no token yet — but whether to publish it is the operator’s call, so it has to be made explicitly. The admin plane is never described either way, and an unlicensed server answers 403 no_license here like everywhere else outside the setup allowlist.

A licence carrying the PiaCloud feature refuses to serve the document whatever this key says — on the managed instance the API is the desktop client’s transport rather than an advertised contract.

Two switches make the server do one job and exit instead of starting the web host. Both run ahead of the host builder, so each can be given strictly less than the web container holds — neither needs Jwt:SecretKey or Encryption:MasterKey. They are refused together rather than running only the first: they are meant to carry different credentials, so asking for both is a mistake about which container is being run.

Switch Reads Does
--migrate-only Database:Provider, Database:MigrationConnectionString (falling back to Database:ConnectionString), Knowledge:ConnectionString Applies pending migrations and exits. The deploy-time path for a server whose runtime role holds no DDL rights
--provision-pod Database:Provider, Database:ConnectionString Provisions a Pia Mesh pod: creates the connector row, mints its token, optionally adds the operator row, and grants both to groups
Terminal window
--provision-pod create --name <name> [--grant-group <guid>]... [--operator-skill <skill>]
--provision-pod issue-token --connector <guid>
Terminal window
docker compose -f docker-compose.prod.yml run --rm pia-server \
--provision-pod create --name "Invoices" --operator-skill invoices --grant-group <group-id>

Every prerequisite for a running pod was otherwise reachable only by clicking through Admin → Connectors, which is why this exists. stdout carries exactly one JSON objectconnectorId, operatorId, token, grantedGroupIds, resolvedMemberCount — and every log line, warning and refusal goes to stderr, so a script’s parse cannot be corrupted by an interleaved log.

What it refuses, and why each refusal beats the alternative:

  • An unknown --grant-group id. A typo would otherwise produce a pod that is online, granted on paper, and routable to nobody.
  • Granting without the GroupManagement licence feature — the same gate every other writer of a group’s plugin allowlist honours.
  • A skill name that the Operators page would also reject, using the same rules, so the two writers cannot drift apart.

It also reports what it cannot refuse. resolvedMemberCount is the number of users in the granted groups: zero means the grant is written and no chat request can reach the pod yet. With no --grant-group at all it warns that the pod will connect and serve nothing. And whether the pod actually advertises the skill named by --operator-skill is not checked here — that needs the pod card, which does not exist until the pod connects — so a mismatch surfaces as a run failing operator_pod_plane_mismatch.

Writing the pod itself is Writing a pod.

The keys most commonly set in production:

Required Variable
ASPNETCORE_ENVIRONMENT (Production)
Database__Provider and Database__ConnectionString
JWT_SECRET_KEY
ENCRYPTION_MASTER_KEY
✓ (Postgres) POSTGRES_PASSWORD (the owner role’s password — matches the one in Database__ConnectionString until the role split is adopted)
optional AI_*
optional OAUTH_*_CLIENT_ID / _SECRET
optional Knowledge__ConnectionString + Embeddings__ProviderId (enable the knowledge base)
optional Operators__Enabled + Temporal__Address (enable the Mesh operator runtime)
optional OpenApi__Enabled (false withdraws the generated API description)
optional FreeTier__Suspended (true freezes new account creation and free-group AI spend). Never set it before the deployment’s first admin has signed in: account creation is the only way an admin comes into being, so the console becomes unreachable — see Limits & Policies

appsettings.json is committed; .env.prod is not (and should be chmod 600).

Most operator-tunable settings are read via IOptionsMonitor<T> and re-bind on file change — for example, rate limit and quota policies on the /admin/limits page apply immediately.

These settings only take effect after a restart:

  • Database:Provider and all three Database:*ConnectionString keys
  • Jwt:SecretKey (changing it invalidates all outstanding tokens)
  • Encryption:MasterKey (changing it strands all existing ciphertext — see Encryption at rest)
  • Operators:Enabled and every Temporal:* key (captured before the admin override overlay, so a running worker cannot have its target changed underneath it)

The admin UI surfaces a banner when a change requires a restart, with a Restart button that delegates to RestartCapability. In environments where a process restart isn’t safe (e.g. a single-replica deployment without a supervising orchestrator), the capability is no-op and the operator must restart the container manually.