Deployment
Server Mode
The gRPC and REST servers — starting them, connecting, authentication, TLS and health
#Server Mode
PhoenixmlDb has two servers built on ASP.NET Core:
-
the gRPC server (
PhoenixmlDb.Server), with a .NET client library (PhoenixmlDb.Client); -
the REST server, an HTTP API.
Both store data with the same engine, read the same PhoenixmlDb:* settings, and are configured
through standard ASP.NET Core configuration.
Availability. The servers and the client library are not yet published as NuGet packages, .NET tools or container images.
#Starting the server
Each server is an ASP.NET Core application. Settings come from appsettings.json, environment
variables (__ between levels) and the command line, for example:
dotnet PhoenixmlDb.Server.dll --PhoenixmlDb:Storage:DataPath=/var/lib/phoenixml
The gRPC server listens on 127.0.0.1 by default: a plaintext HTTP/2 port (5000) on loopback
only, and a TLS port (5001). See gRPC server for the endpoint settings and the
rules for listening on a network address.
#Server Configuration
Settings come from appsettings.json, environment variables (__ between levels) and the command
line, and are validated at startup. For example:
{
"PhoenixmlDb": {
"Storage": { "DataPath": "/var/lib/phoenixml", "MapSizeMb": 102400 }
}
}
export PhoenixmlDb__Storage__DataPath=/var/lib/phoenixml
Every setting, its default and its startup check is in the Server Configuration reference.
#Client connection
The .NET client connects to the gRPC server by address, including the scheme:
using PhoenixmlDb.Client;
await using var client = new PhoenixmlClient("http://localhost:5000");
var products = await client.CreateContainerAsync("products");
await products.PutDocumentAsync("p1.xml", "<product/>");
var result = await products.QueryAsync("collection()//product");
PhoenixmlClientOptions sets MaxMessageSize, TimeoutMs, retries (EnableRetry,
MaxRetries) and the gRPC Credentials.
#With an API key
The gRPC server expects authorization: Bearer <key> metadata (see gRPC server).
Supply it as call credentials over TLS:
using Grpc.Core;
using PhoenixmlDb.Client;
var apiKey = Environment.GetEnvironmentVariable("PHOENIXML_API_KEY");
var callCredentials = CallCredentials.FromInterceptor((context, metadata) =>
{
metadata.Add("authorization", $"Bearer {apiKey}");
return Task.CompletedTask;
});
await using var client = new PhoenixmlClient("https://db.example.com:5001", new PhoenixmlClientOptions
{
Credentials = ChannelCredentials.Create(new SslCredentials(), callCredentials),
});
The REST server takes the key in the X-Api-Key header (see Authentication):
using var http = new HttpClient { BaseAddress = new Uri("https://localhost:5001") };
http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("PHOENIXML_API_KEY"));
#Authentication
The REST server is secure by default: every endpoint requires credentials, and a production
host refuses to start until an operator configures a key. The settings section is Auth; a
section named Authentication makes the server refuse to start.
Both servers authenticate with API keys that you issue and store as hashes (issue #50). The REST server also accepts JWTs. The gRPC server is covered in gRPC server below.
Every REST endpoint requires either an API key in the X-Api-Key header or a JWT in
Authorization: Bearer <token>. Anonymous requests get 401. Only these are open:
-
/health,/health/live,/health/ready: status only; the detailed/health/detailsneeds an admin credential (see Health Endpoints) -
the Swagger UI, and only in the Development environment
curl -H "X-Api-Key: $PHOENIXML_API_KEY" https://localhost:5001/api/containers
curl -H "Authorization: Bearer $TOKEN" https://localhost:5001/api/containers
#Settings
Settings live in the Auth section. As environment variables, use __ between levels, for
example Auth__Jwt__SecretKey. Supply secrets through dotnet user-secrets or the environment,
never in a committed appsettings.json.
|
Setting |
Default |
Notes |
|---|---|---|
|
|
|
Set |
|
|
|
|
|
|
|
Case-insensitive. Must not be |
|
|
(empty) |
Empty disables query-string keys, which leak into request logs. |
|
|
(empty) |
The accepted keys, one list entry per key. See API keys. |
|
|
|
|
|
|
Secret. At least 32 bytes UTF-8, and not a placeholder value. |
|
|
|
Required when JWT is enabled. |
|
|
|
Required when JWT is enabled. |
|
|
|
Not supported yet (no OpenID Connect): setting it fails startup. |
#API keys
Each entry in Auth:ApiKey:Clients describes one key:
|
Field |
Notes |
|---|---|
|
|
Required and unique. Logs and revocation refer to the entry by its |
|
|
Who holds the key. Several entries may share a |
|
|
The hex SHA-256 of the key, in either letter case. Set exactly one of |
|
|
The key itself, supplied as a configuration value (a Kubernetes |
|
|
|
|
|
Default |
|
|
Optional. A date-time in one of the accepted forms; without a timezone it is UTC. Absent: never expires. Empty or not in an accepted form: startup stops. |
|
|
Must be empty: per-container permissions aren't implemented yet, and startup fails if it's set. |
Store the hash, not the key, wherever configuration gets committed or backed up:
# Linux
KEY="phx_$(openssl rand -hex 24)"; printf '%s' "$KEY" | sha256sum | cut -d' ' -f1
# macOS
KEY="phx_$(openssl rand -hex 24)"; printf '%s' "$KEY" | shasum -a 256 | cut -d' ' -f1
$k = "phx_" + [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLower()
[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($k)))
Use printf '%s', not echo: a trailing newline changes the hash. Give $KEY to the caller and
put the hash in configuration:
{
"Auth": {
"ApiKey": {
"Clients": [
{ "Id": "reporting-2026-10", "Name": "reporting", "Sha256": "<hex sha-256>", "Permission": "read" }
]
}
}
}
As environment variables, index the list: Auth__ApiKey__Clients__0__Id,
Auth__ApiKey__Clients__0__Sha256 (or __Key), Auth__ApiKey__Clients__0__Permission. Entries
from different configuration sources merge by list index, so give each source its own indexes.
Send the key in the X-Api-Key header (Auth:ApiKey:HeaderName). An unknown, disabled or
expired key gets 401 with the same message; the server logs the real reason against the entry's
Id.
Rotating a key: add an entry with the same Name and a new Id and key, move callers over,
then disable or remove the old entry. Setting Expires on the old entry retires it on schedule.
Deprecated: the older
Auth:ApiKey:Keys:<key>shape, where the key itself was the setting name, is still accepted for one release, with a startup warning naming the entries. It put the key in configuration paths and environment-variable names, which tooling prints. Move each entry toClients.
#Startup checks
The server validates its configuration at startup and refuses to start, with a message naming the setting, when:
-
Auth:RequireAuthenticationis true and nothing could authenticate: no enabled API key and no JWT; -
outside Development, an API key is shorter than 32 characters, or is a development key;
-
a key has no
Permission, or hasContainerPermissions; -
two entries share an
Id, or two entries hold the same key (compared by hash, so aKeyentry and aSha256entry can collide); -
a
Keyis empty or whitespace, or aSha256is the hash of the empty string."Key": ""fails startup; -
an entry sets both or neither of
Sha256andKey; -
JWT is enabled with a missing, placeholder or too-short
SecretKey, or withoutIssuer/Audience; -
Auth:Jwt:Authorityis set; -
an
Authenticationsection exists. The section isAuth.
An entry that has already expired at startup only logs a warning. Startup errors never contain a key or a hash.
#JWTs
Tokens must be HS256-signed with Auth:Jwt:SecretKey, carry exp, and match the configured
issuer and audience. They're validated with a 30-second clock skew. The caller's permission comes
from the token's permission claim.
#Permissions
Every REST route requires a permission level. A key's Permission, or a JWT's permission
claim, is read, write, admin or full; full is the same as admin, and each level
includes the ones below it.
|
Level |
Routes |
|---|---|
|
none |
|
|
|
Containers: list, get, statistics. Documents: list, get, content, metadata, versions. Every |
|
|
Containers: create, update, delete. Indexes: add, remove, rebuild. Documents: create, update, content, delete, metadata update, version restore. Stylesheets: register, update, delete, and clearing the transform cache. Schema registration: |
|
|
|
A credential without the level a route needs gets 403. When Auth:RequireAuthentication is
false, an anonymous caller gets 401 on any write or admin route.
Queries and transformations are read routes: updating XQuery expressions aren't applied to
stored documents, and XSLT has no path that writes to the database.
#Resource Access
Queries and stylesheets sent to either server can read only the stored documents, unless you list
directories in PhoenixmlDb:ResourceAccess:AllowedFileRoots or origins in
PhoenixmlDb:ResourceAccess:AllowedHttpOrigins. Both are empty by default and validated at
startup. See Resource Access and, for import module and
fn:load-xquery-module, XQuery Library Modules. The gRPC server has no
transform service, so the stylesheet rules apply to the REST server only.
#gRPC server
The gRPC server reads its keys from PhoenixmlDb:Auth:ApiKeys, a list with the same fields
and rules as above: Id, Name, Sha256 or Key, Enabled, Expires, plus Scopes in place
of Permission:
|
Scope |
Allows |
|---|---|
|
|
reading documents, containers, indexes and server status |
|
|
everything in |
|
|
everything in |
An entry with no Scopes gets read and write. Queries run with the read scope.
Updating expressions (XQuery Update Facility) are not applied to stored documents; use the
document APIs to write. Clients send the key as gRPC metadata,
authorization: Bearer <key>. A wrong key gets Unauthenticated. A valid key without the scope a
call needs gets PermissionDenied.
An entry without an Id still works for one release: it uses its Name as its Id, with a
startup warning. A Key must be at least 32 characters.
Where it listens: PhoenixmlDb:Endpoints:ListenAddress (default 127.0.0.1), Port (default
5000) and HttpsPort (default 5001). With no keys configured, the server accepts every call,
and so it refuses to start on a non-loopback address unless at least one key is configured.
A hostname counts as non-loopback.
The cluster's Raft traffic is authenticated separately, by the cluster secret and a TLS certificate, and needs no API key.
#Requests and errors
#REST server: queries
-
Query variables.
"variables": { … }inPOST /api/querybinds external variables. A JSON string binds asxs:string, a whole number asxs:integer, another number asxs:decimal,true/falseasxs:boolean, an array as a sequence, andnullas no binding. A JSON object is a400that names the variable. -
Query errors are HTTP
400with a problem body that carriesxqueryErrorCodeanddetail.POST /api/query/validatereports each static error with its own code, including errors in imported modules;POST /api/query/compileanswers400withxqueryErrorCode. -
Query limits. A query past its time limit is
504witherrorCodeQUERY_TIMEOUT. A query overPhoenixmlDb:Query:MaxConcurrentQueriesPerApiKeyis503witherrorCodeQUERY_LIMIT_API_KEY. A read refused at a reader limit is503witherrorCodeREADER_LIMITandsettingwith the key of the limit. See Reads During Writes. -
A document deleted during a request that reads it again is
404witherrorCodeNOT_FOUNDandresourceTypeDocument.
#REST server: transformations
This applies to /api/transform, /api/transform/raw, /api/transform/pipeline, and stylesheet
registration, update and validation.
|
Situation |
Answer |
|---|---|
|
Error in the stylesheet or in its run |
HTTP |
|
XPath syntax error ( |
HTTP |
|
Invalid |
HTTP |
|
Registration or update of such a stylesheet |
HTTP |
|
|
HTTP |
|
Pipeline with |
HTTP |
|
Past |
HTTP |
|
Past the concurrency limits ( |
HTTP |
The detail leads with the error code:
-
Standard codes:
FORG0001: [line 1, col 132] Cannot cast …,FOER0000: Error raised by fn:error. -
A code in no namespace:
MYCODE: …. -
A code given with
fn:error(QName(ns, 'p:local'), …):p:local: …; with no prefix,Q{ns}local: …. -
The XPath parser's list of expected tokens is left out of its message. Text a stylesheet writes in
fn:errororxsl:message terminate="yes"arrives unchanged.
A match pattern with a syntax error inside a predicate (a[, a[1 +]) is accepted by the
library.
#REST server: validation
-
An unknown schema
typeon/api/v1/validate,/upload,/xml(X-Schema-Type) and/batchis HTTP400:Unknown schema type '<value>'. Accepted: xsd, schematron, relaxng, dtd. -
/api/v1/validate/batch: a schema reference that is not registered fails every document withSCHEMA_NOT_FOUND. A schema that does not compile fails every document withVALIDATION_ERROR. A schemaUrithat is not served is a400for the whole request. -
xs:include,xs:importandxs:redefineof a registered schema name or a schema-directory file resolve; see Schema locations in the REST server validators. -
POST /api/v1/schemasanswers201with the schema info. -
A document nested more than 1024 elements deep is a validation error.
#gRPC server
-
For a coded query error,
errorbegins with the code (XQST0059: …). Unary calls answersuccess = false. The streaming query answers statusInvalidArgumentwith the same text. -
A regular-expression match that runs past
PhoenixmlDb:Query:RegexMatchTimeoutMsis answeredDeadlineExceeded. -
A query that runs past its limit (
timeout_ms, at mostPhoenixmlDb:Query:MaxTimeoutSeconds) is answeredDeadlineExceeded. A query overPhoenixmlDb:Query:MaxConcurrentQueriesPerApiKey, a read refused at a reader limit, and aBeginTransactionover the transaction limits are answeredResourceExhausted. See Reads During Writes. -
PutDocumentandUpdateDocumentanswer a refused document name withsuccess = falseanderrorbeginningINVALID_DOCUMENT_NAME:.BulkPutDocumentslists it in the per-documenterrors. See the name rule.
#TLS
The gRPC server's TLS port uses Kestrel's standard certificate configuration, for example
Kestrel:Certificates:Default:Path and Kestrel:Certificates:Default:Password. The TLS port accepts
HTTP/1.1 as well as HTTP/2, so HTTPS health probes work against it. The plaintext port is HTTP/2
only and is bound on loopback. The cluster's Raft port has its own certificate settings; see
Cluster Mode.
#Load balancing
A single server needs no special handling behind a TCP or HTTP/2 load balancer. In a cluster, only the leader accepts writes: a write sent to a follower is refused with the leader's id, and reads are served from the node that receives them. See Cluster Mode.
#Monitoring
#Health endpoints
Both servers expose the same endpoints. The public ones return status only: a plain-text body
of Healthy, Degraded or Unhealthy, with 200 for Healthy or Degraded and 503 for
Unhealthy.
|
Endpoint |
Auth |
Checks |
|---|---|---|
|
|
anonymous |
none: liveness only |
|
|
anonymous |
REST server: database, query engine, transform engine. gRPC server: storage, plus Raft when enabled. |
|
|
anonymous |
the same as |
|
|
admin |
a JSON report with a fixed code and data per check, never exception text |
curl -i https://localhost:5001/health/ready
# HTTP/1.1 200 OK
# Healthy
curl -H "X-Api-Key: $ADMIN_KEY" https://localhost:5001/health/details
/health/details returns 401 without a credential and 403 for a key without admin permission.
Point a liveness probe at /health/live, and a readiness probe or load balancer at
/health/ready. On the gRPC server, /healthz is a deprecated alias of /health for one release,
and the standard grpc.health.v1 service is available anonymously (overall service ""; Degraded
maps to SERVING, Unhealthy to NOT_SERVING). The Raft port refuses health requests.
Health codes in the detailed report:
|
Code |
Status |
|---|---|
|
|
Unhealthy |
|
|
Degraded, when storage map usage reaches |
|
|
Degraded: a candidate, or a follower with no known leader |
|
|
|
|
|
Unhealthy (REST server) |
A failed health check also logs event 3008 HealthCheckFailed (Error).
#Metrics and traces
The engine publishes metrics and traces through System.Diagnostics, under the names
PhoenixmlDb.Storage, PhoenixmlDb.Indexing and PhoenixmlDb.Cluster. See
Logging: metrics and traces.
#Containers
No container image is published. When you build one, configure the server through environment variables:
environment:
- PhoenixmlDb__Storage__DataPath=/data
- PhoenixmlDb__Endpoints__ListenAddress=0.0.0.0
# A non-loopback address needs at least one API key (PhoenixmlDb__Auth__ApiKeys__0__…).
#Best practices
-
Use TLS for anything that leaves the machine.
-
Configure API keys with the narrowest permission or scope each caller needs.
-
Probe
/health/readyand alert onDegradedas well asUnhealthy. -
Back up the data directory; replication is not a backup.
#Next Steps
|
High Availability |
Configuration |
Support |
|---|---|---|
|
Cluster Mode High availability |
Configuration All settings |
Troubleshooting Problem solving |