Skip to content

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.

Provider keys come from a Secret, never from values:

Terminal window
$ 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: true

There 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:

Terminal window
$ kubectl exec pistra-0 -n pistra-system -- pistra admin /admin/v1/config

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

Terminal window
$ helm install pistra deploy/charts/pistra \
--namespace pistra-system --values pistra.yaml

That 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:

Terminal window
$ helm install pistra oci://ghcr.io/pistra-dev/pistra/charts/pistra \
--version <release> --namespace pistra-system --values pistra.yaml

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

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:

Terminal window
$ kubectl get svc pistra -n pistra-system -w

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

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.

Run this command:

Terminal window
$ 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:

Terminal window
$ 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.

Terminal window
$ 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"}]}'

If the cluster already has a front door, Istio, a cloud gateway, an ingress controller, pistra is a backend of it:

service:
type: ClusterIP
tls:
secretName: pistra-tls # pistra still terminates: the Gateway passes TLS through
route:
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:

Terminal window
$ kubectl exec pistra-0 -n pistra-system -- \
pistra admin DELETE /admin/v1/cluster/members/pistra-4

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