Skip to content

Deploy the front door with Helm

Run Gate as the gateway your cluster’s AI traffic enters through: three replicas forming one raft cluster, TLS terminated by Gate, keys and budgets replicated, behind a cloud load balancer.

This assumes you have run Gate 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 kirto-gate-system
$ kubectl create secret generic kirto-gate-secrets -n kirto-gate-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 kirto-gate.yaml:

image:
repository: your.registry/kirto-gate
envFrom:
- secretRef:
name: kirto-gate-secrets
deployment:
auth:
virtual_keys: true
providers:
- name: openai
base_url: https://api.openai.com
api_key: ${OPENAI_API_KEY}
config:
tls:
domains: [ai.example.com]
dns:
provider: cloudflare
api_token: ${CLOUDFLARE_API_TOKEN}
admin:
enabled: true
workloadIdentity:
enabled: true

There is no admin secret to generate, so admin.enabled needs somewhere to get callers from. admin.workloadIdentity accepts this cluster’s own service account tokens, and the release’s own apply hook is one such caller, which is why the example turns it on beside a deployment. An issuer under config.issuers bound in config.admin.issuers adds 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/kirto-gate, and the way in is kubectl exec:

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

Use kirto-gate 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 kirto-gate deploy/charts/kirto-gate \
--namespace kirto-gate-system --values kirto-gate.yaml

That is a path into a checkout, because Gate 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 kirto-gate oci://ghcr.io/kirto-dev/gate/charts/kirto-gate \
--version <release> --namespace kirto-gate-system --values kirto-gate.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 nothing stored, answers 503 on the data plane, and is configured through the console, the admin API or the operator, which is where the configuration then lives. What you put under deployment: is written to the cluster after every install and upgrade by the release’s apply hook, a Job that runs kirto-gate config apply against the admin Service with a token this cluster’s API server signed, as a source named for the release. An edit to it is a source write rather than a roll of the pods. The hook needs admin.enabled and admin.workloadIdentity.enabled, and the chart refuses a deployment it could not apply; hooks.apply.enabled: false leaves the document in the ConfigMap for you to apply from a pod. An edit made through the console or the API goes in a source of its own: the hook is its source’s manager, and an upgrade over a source somebody else wrote last fails naming the objects they changed, unless hooks.apply.force is set. See manage the configuration through the API.

Object What it is
StatefulSet/kirto-gate Three replicas, kirto-gate-0..2, each with a 1 GiB volume for its raft log
Service/kirto-gate LoadBalancer on 443, externalTrafficPolicy: Local, the front door
Service/kirto-gate-headless How the replicas name each other; never for clients
Service/kirto-gate-admin ClusterIP on 8485, the control plane, inside the cluster only
ConfigMap/kirto-gate One node file per replica, and the deployment document the release applies
Job/kirto-gate-apply The post-install and post-upgrade hook that writes deployment to the cluster, with a ServiceAccount of its own
Secret/kirto-gate-cluster The one secret the chart owns: what the replicas authenticate to each other with. Generated once, kept across upgrades
PodDisruptionBudget/kirto-gate One voter down at a time

Watch the address arrive, then point DNS at it:

Terminal window
$ kubectl get svc kirto-gate -n kirto-gate-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]
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 ${KIRTO_GATE_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, because the certificate manager is built at startup.

Run this command:

Terminal window
$ kubectl logs kirto-gate-0 -n kirto-gate-system | grep 'cluster joined'
$ kubectl logs kirto-gate-2 -n kirto-gate-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
kirto_gate_cluster_leader_known 1 on every pod 0: this pod is partitioned, or the cluster has lost quorum
kirto_gate_cluster_is_leader sums to 1 0: no leader; 2: a partition, briefly
kirto_gate_cluster_members{suffrage="voter"} equals replicaCount higher: a departed pod still counted toward quorum
kirto_gate_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 kirto-gate-0 -n kirto-gate-system -- \
kirto-gate 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, Gate is a backend of it:

service:
type: ClusterIP
tls:
secretName: kirto-gate-tls # kirto-gate 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 Gate keeps its certificate. HTTPRoute lets the Gateway terminate and speak plain HTTP to Gate 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 kirto-gate.kirto-gate-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 Gate is a second place that buffers bodies and times out streams. If that gateway is going to carry traffic Gate 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 kirto-gate-0 -n kirto-gate-system -- \
kirto-gate admin DELETE /admin/v1/cluster/members/kirto-gate-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 kirto-gate-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.