Deploy the front door with Helm
Run pistra as the gateway your cluster’s AI traffic enters through: three replicas forming one raft cluster, TLS terminated by pistra, keys and budgets replicated, behind a cloud load balancer.
This assumes you have run pistra once outside a cluster, and that you
have an image. The ten-minute version is
Govern your first provider.
The repository publishes no image. make image at its root builds one
from the Dockerfile there, including the tokenizer shim the native
detector tier needs, and you tag and push it. docker build --target slim . builds the same
gateway without that tier, and needs nothing staged first.
Install
Section titled “Install”Provider keys come from a Secret, never from values:
$ kubectl create namespace pistra-system$ kubectl create secret generic pistra-secrets -n pistra-system \ --from-literal=OPENAI_API_KEY=sk-... \ --from-literal=CLOUDFLARE_API_TOKEN=...That Secret reaches the config as environment variables through
envFrom, and ${OPENAI_API_KEY} below reads one. The same Secret can
instead be mounted as files with secretsDir.secretName and read with
${secret:OPENAI_API_KEY}, with the key mounted as a file. A rotated
file is picked up on its own within seconds, where a rotated variable
needs a roll. The chart’s README has both spellings side by side.
Write pistra.yaml:
image: repository: your.registry/pistra
envFrom: - secretRef: name: pistra-secrets
deployment: auth: virtual_keys: true providers: - preset: openai api_key: ${OPENAI_API_KEY}
config: tls: domains: [ai.example.com] email: ops@example.com dns: provider: cloudflare api_token: ${CLOUDFLARE_API_TOKEN}
admin: enabled: trueThere is no admin secret to generate, so admin.enabled needs
somewhere to get callers from. Turn on admin.workloadIdentity to
accept this cluster’s own service account tokens, or write an issuer
under config.issuers and bind it in config.admin.issuers for a
directory. The chart refuses to render an admin Service nobody could
authenticate to. It also renders admin.policy beside the config,
granting Role::"admin" the run of the API until you narrow it. See
Restrict what an admin caller can do.
To reach the admin API with no identity provider at all, day one, and
the day the identity provider is what broke, use the local socket. The
chart turns it on by default, on an emptyDir at /run/pistra, and
the way in is kubectl exec:
$ kubectl exec pistra-0 -n pistra-system -- pistra admin /admin/v1/configUse pistra admin rather than curl, because the image is distroless
and has neither. The socket is a second listener rather than a mode, so
it survives turning admin.enabled on. That matters, because turning
it on is when the network door starts depending on an issuer. Grant it
in admin.policy, where the chart’s default already does, and narrow
it first. See
Reach the admin API without your identity provider.
$ helm install pistra deploy/charts/pistra \ --namespace pistra-system --values pistra.yamlThat is a path into a checkout, because pistra is pre-release and nothing is tagged yet. From the first tagged release the chart is also pushed beside the image, and the install becomes a registry reference with a version to pin:
$ helm install pistra oci://ghcr.io/pistra-dev/pistra/charts/pistra \ --version <release> --namespace pistra-system --values pistra.yamlA node runs from two documents, and the chart renders both.
config: is the node file, verbatim, except the blocks the chart
writes for you: listen, cluster, and
admin.listen/admin.policy_file/admin.local_socket. Set any of
them and the chart refuses at template time rather than silently
overwriting. A deployment key under config: is refused too, and the
refusal names where it goes.
deployment: is the deployment document, verbatim: providers,
credentials, budgets, profiles, guardrails. It is optional. The chart
ships it empty. A fresh install boots with no providers and is
configured through the console, the admin API or the operator, which
is where the configuration then lives. What you put under
deployment: becomes the default source the first time the cluster
boots, and the chart stamps the release revision on it as the
document’s version, so a later edit lands too. helm upgrade rolls
the pods, the first one back finds its document newer than what is
stored and applies it, and the rest find it current. That source has
one writer, this document: the API refuses every other writer on it,
so an edit made through the console or the API goes in a source of its
own. GET /admin/v1/config reports file_status either way. See
manage the configuration through the
API.
What you get
Section titled “What you get”| Object | What it is |
|---|---|
StatefulSet/pistra |
Three replicas, pistra-0..2, each with a 1 GiB volume for its raft log |
Service/pistra |
LoadBalancer on 443, externalTrafficPolicy: Local, the front door |
Service/pistra-headless |
How the replicas name each other; never for clients |
Service/pistra-admin |
ClusterIP on 8485, the control plane, inside the cluster only |
ConfigMap/pistra |
One node file per replica, and the one deployment document they all name |
Secret/pistra-cluster |
The one secret the chart owns: what the replicas authenticate to each other with. Generated once, kept across upgrades |
PodDisruptionBudget/pistra |
One voter down at a time |
Watch the address arrive, then point DNS at it:
$ kubectl get svc pistra -n pistra-system -wOnce ai.example.com resolves to it, the raft leader completes the
DNS-01 challenge and the certificate replicates to the other two. There
is no inbound HTTP path to open for the CA.
If your CA is not Let’s Encrypt
Section titled “If your CA is not Let’s Encrypt”Point ca at its ACME directory. Most commercial and enterprise CAs
will not create an account without External Account Binding, a key id
and MAC key you get from the CA out of band. That covers ZeroSSL,
Google Trust Services, Sectigo, DigiCert and SSL.com, and step-ca or
Vault when their operator requires it:
config: tls: domains: [ai.example.com] email: ops@example.com ca: https://acme.zerossl.com/v2/DV90 eab: key_id: ${ACME_EAB_KEY_ID} mac_key: ${ACME_EAB_MAC_KEY}The MAC key is a password for the CA account, so the chart refuses it as
a literal for the same reason it refuses a literal api_key. Put both
in the Secret that envFrom already mounts. A private CA also needs
its chain in trusted_roots, which takes the PEM itself rather than a
path. Use ${PISTRA_ACME_ROOTS} if you would rather not paste it into
values.
Only the raft leader registers the account, so a three-replica cluster
makes one EAB registration, not three. The binding is used at that
registration and never again, because ACME cannot rebind an existing
account. So new credentials from the CA need a new account. The stored
one is keyed by ca and email, so change email and restart the
pods.
Editing eab on its own has no effect, and a reload will not do it
either, because the certificate manager is built at startup.
Check the cluster formed
Section titled “Check the cluster formed”Run this command:
$ kubectl logs pistra-0 -n pistra-system | grep 'cluster joined'$ kubectl logs pistra-2 -n pistra-system | grep 'cluster joined'Every pod should name the same leader. The same facts are gauges on
each pod’s /metrics (the metrics container port, 9464, which
metrics.podMonitor.enabled scrapes), and they are the ones to alert
on:
| Series | Healthy | Otherwise |
|---|---|---|
pistra_cluster_leader_known |
1 on every pod | 0: this pod is partitioned, or the cluster has lost quorum |
pistra_cluster_is_leader |
sums to 1 | 0: no leader; 2: a partition, briefly |
pistra_cluster_members{suffrage="voter"} |
equals replicaCount |
higher: a departed pod still counted toward quorum |
pistra_cluster_members{suffrage="nonvoter"} |
0 | a join that has not finished, or never will |
Then mint a key and use it. The request will land wherever the balancer sends it. There is no admin token to fetch, so the first key is minted over the local socket, the same way everything else is on day one:
$ kubectl exec pistra-0 -n pistra-system -- \ pistra admin POST /admin/v1/keys -d '{"name":"first"}'The token comes back once and is not retrievable again. Mint, rotate and revoke a virtual key covers what to do with it after that, rotating it without an outage, and the four different ways it can stop working.
$ curl -s https://ai.example.com/v1/chat/completions \ -H "Authorization: Bearer <the minted key>" \ -H 'content-type: application/json' \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'Sit behind a gateway instead
Section titled “Sit behind a gateway instead”If the cluster already has a front door, Istio, a cloud gateway, an ingress controller, pistra is a backend of it:
service: type: ClusterIPtls: secretName: pistra-tls # pistra still terminates: the Gateway passes TLS throughroute: kind: TLSRoute # or HTTPRoute, with tls left empty, when the Gateway owns TLS parentRef: name: platform namespace: gateways hostnames: [llm.example.com]With a Gateway API gateway, route.kind writes the route. TLSRoute
keeps the front-door posture, the Gateway routes by SNI and pistra
keeps its certificate. HTTPRoute lets the Gateway terminate and
speak plain HTTP to pistra on 80. Consent for a route from another
namespace is on the Gateway’s listener (allowedRoutes), which its
owner sets. With an ingress controller, point its Ingress at
pistra.pistra-system.svc:80 yourself. The chart ships no Ingress
template, because the Ingress API is frozen in favour of Gateway API
and a second proxy in front of pistra is a second place that buffers
bodies and times out streams. If that gateway is going to carry traffic
pistra does not route and you want the same guardrails on it, use the
guardrails-only role. That
is its own chart by default, or this chart’s inspector.enabled when
one team owns both and would rather run one release.
Up is replicaCount: 5, and the new ordinals join. Down is
replicaCount: 3. Before the StatefulSet shrinks, a hook the chart
runs on every upgrade tells the cluster that its members are exactly
the ordinals that remain, so the departed pods stop counting toward
quorum. Keep the count odd. The chart refuses an even one unless told
otherwise.
The hook needs the cluster reachable and led. If the upgrade is the one
meant to rescue a cluster that is neither, disable it for that upgrade
(hooks.prune.enabled: false) and remove members by hand:
$ kubectl exec pistra-0 -n pistra-system -- \ pistra admin DELETE /admin/v1/cluster/members/pistra-4A pod that loses its volume is fine. It comes back blank, finds the
cluster through the other ordinals, and is re-admitted, without a vote
until it has caught up. That includes pistra-0, whose bootstrap: true is a fallback for when nobody answers, not an order. A pod that
was removed from the membership while it was down notices it has no
leader when it returns, and asks back in after cluster.rejoinAfter.
A member in good standing that has merely lost quorum asks the same
question and gets no answer, so nothing changes for it. A pod removed
while it was running was removed on purpose and is told so. It stays
out until it restarts.
If enough pods are gone that the survivors can never elect, neither the hook nor the API can help. Both need a leader. That is recovery, and it runs offline against a stopped pod’s volume. See Recover a cluster that has lost quorum.
Related
Section titled “Related”- The chart’s README, every decision in the defaults, and how the cluster forms.
- Recover a cluster that has lost quorum, when enough ordinals are gone for good that no leader can be elected.
- Deploy guardrails beside your gateway, guardrails for traffic you don’t route.
- Configuration reference, the
clusterandtlsblocks the chart writes.