Install¶
This guide installs kubelatch on a management cluster with its Helm chart, with Postgres, TLS, the encryption key and the first administrator.
Every release is tested by repeating these steps on a disposable kind cluster, with the database in CloudNativePG.
Public preview
kubelatch is in public preview. It works, but pieces are still missing (they're on the roadmap), and there's no stability commitment between versions yet; only the latest release is supported.
Before you start¶
| Item | Detail |
|---|---|
| Management cluster | Kubernetes 1.30 or later, with an administrator kubectl. It can be one of the clusters kubelatch is going to manage. |
| Helm | 3.8 or later (OCI charts). |
| Postgres | Your own, version 17, the one kubelatch is tested with (RDS, Cloud SQL, Azure Database…), with a role that can create tables in the database: kubelatch creates the schema on startup. Or one the chart creates with the CloudNativePG operator: Database with CloudNativePG. |
| DNS name and certificate | A public or internal name (kubelatch.example.com) and a certificate for it: cert-manager (recommended) or your own. |
| Network | kubelatch's pods must reach each managed cluster's API server over https. People and CI runners must reach kubelatch over https. |
Every command in this guide uses two variables: the management cluster's kubeconfig and the version to install. Set them before you start:
export MGMT_KUBECONFIG=<path-to-the-management-cluster-kubeconfig>
export KUBELATCH_VERSION=<version> # without the v: 1.2.3 (https://github.com/picaportelabs/kubelatch/releases)
Always use an explicit kubeconfig
On a machine with several clusters, a kubectl or helm without --kubeconfig can apply changes to the wrong cluster. That's why every command in this guide carries --kubeconfig "$MGMT_KUBECONFIG".
What the chart installs¶
The chart is oci://ghcr.io/picaportelabs/charts/kubelatch. Its version is kubelatch's version, and it installs the image ghcr.io/picaportelabs/kubelatch of that same version. From the release after 0.43.0, both are signed: Verify a release. In the release's namespace it creates:
- A
ServiceAccountwith no permissions and no token mounted: kubelatch doesn't talk to the management cluster's API server with it. - A
Deploymentwith one replica:maxSurge: 1,maxUnavailable: 0,terminationGracePeriodSeconds: 60, non-root user, read-only filesystem and no capabilities. - The
ConfigMap kubelatch-config, from theconfigvalues. The pods carry its hash: changing it redeploys. - A
LoadBalancerServiceon port 443 withexternalTrafficPolicy: Local. - A
NetworkPolicythat only lets traffic into kubelatch's port.
The pod mounts, read-only, the kube-root-ca.crt ConfigMap that Kubernetes publishes in every namespace: the CA of the cluster kubelatch runs in, so enrolling that cluster with your identity needs nothing pasted. It grants no permission, and the pod still has no ServiceAccount token. clusterCA.mount: false turns it off.
Depending on the values it also creates an Ingress, a cert-manager Certificate, a PodDisruptionBudget, the generated encryption key, a CloudNativePG database and the hardening policy. All the values are in Helm values, and helm show values oci://ghcr.io/picaportelabs/charts/kubelatch --version "$KUBELATCH_VERSION" prints them with their comments.
By default it doesn't create the namespace, and it never creates the Secrets you bring: the encryption key, DATABASE_URL and the certificate.
Install step by step¶
-
Create the namespace with the
restrictedPod Security level: kubelatch runs under it, and nothing else belongs in this namespace.kubectl --kubeconfig "$MGMT_KUBECONFIG" create namespace kubelatch kubectl --kubeconfig "$MGMT_KUBECONFIG" label namespace kubelatch pod-security.kubernetes.io/enforce=restrictedWith Helm 3.17 or later, the chart can create it instead:
namespace.create: truein your values and--create-namespace --take-ownershipin step 5. The namespace then carries the label and stays afterhelm uninstall. Older versions can't install that option: Helm needs the namespace to store the release before creating anything, and without--take-ownershipit refuses to adopt the one--create-namespacemade. -
Create the secrets. The chart loads the
kubelatch-secretsSecret whole (secrets.existingSecret):kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch create secret generic kubelatch-secrets \ --from-literal=DATABASE_URL='postgres://kubelatch:<password>@<host>:5432/kubelatch?sslmode=require' \ --from-literal=KUBELATCH_ENCRYPTION_KEY="$(head -c32 /dev/urandom | base64)"sslmode=requireencrypts the connection but doesn't check the server's certificate. Usesslmode=verify-full, which also checks it and the host name. If the certificate comes from a public CA, that's all. With a private CA (RDS, Cloud SQL…), put the CA in a Secret, mount it withextraVolumesandextraVolumeMountsin your values (step 4), and name it withsslrootcertinDATABASE_URL:kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch create secret generic kubelatch-pg-ca --from-file=ca.crt=<ca-bundle>.pemextraVolumes: - name: pg-ca secret: secretName: kubelatch-pg-ca extraVolumeMounts: - name: pg-ca mountPath: /etc/kubelatch/pg-ca readOnly: trueDATABASE_URL=postgres://kubelatch:<password>@<host>:5432/kubelatch?sslmode=verify-full&sslrootcert=/etc/kubelatch/pg-ca/ca.crtkubelatch re-reads that file when it changes, so rotating the CA only means updating the Secret, with no restart. That works because the Secret is mounted as a directory: with
subPaththe kubelet never updates the file.With the database in CloudNativePG, leave
DATABASE_URLout: it comes from the operator's Secret, and one inkubelatch-secretsis ignored. SaveKUBELATCH_ENCRYPTION_KEYin your secret manager, apart from the Postgres backups: see The encryption key. -
Put the certificate in place. With your own certificate:
kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch create secret tls kubelatch-tls --cert=tls.crt --key=tls.keyWith cert-manager, the chart creates a
Certificatethat fillskubelatch-tls(next step). If you must go through an Ingress, read Exposing kubelatch first. -
Write your values in a
values.yaml. At a minimumconfig.baseURL: the exact public URL, with no trailing slash. Every value, with its default, is in Helm values, and what each kubelatch variable does is in Configuration. Don't turn on GitHub or OIDC login yet: that comes later, following GitHub login or OIDC login (SSO). The public repository has two complete examples, with the L4 LoadBalancer and with an Ingress, inexamples/helm.config: baseURL: https://kubelatch.example.com # With cert-manager instead of your own kubelatch-tls Secret: # tls: # certManager: # enabled: true # issuerRef: {name: letsencrypt, kind: ClusterIssuer} # With the database in CloudNativePG (install the operator first): # postgres: # mode: cnpg -
Install the chart. Keep the release name
kubelatch: the resources are then calledkubelatch, as in this documentation (with another name they are<release>-kubelatch).helm --kubeconfig "$MGMT_KUBECONFIG" install kubelatch oci://ghcr.io/picaportelabs/charts/kubelatch \ --version "$KUBELATCH_VERSION" -n kubelatch -f values.yamlIf a value is missing or doesn't fit (no
config.baseURL, an Ingress without a host,postgres.mode: cnpgwithout the operator…),helm installfails with a message that names it and installs nothing. -
Check that it starts up.
kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch rollout status deploy/kubelatch kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch logs deploy/kubelatch | head curl https://kubelatch.example.com/readyzThe log shows a
listeningline withversion,tlsandaudit_retention./readyzrespondsok. With CloudNativePG, the pod waits for the operator's Secrets and restarts until the database is ready: wait for the database first withkubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch wait --for=condition=Ready clusters.postgresql.cnpg.io/kubelatch-db --timeout=5m.
The probes work like this: the startupProbe gives migrations up to two minutes. /readyz pings Postgres. /healthz only checks the process: a Postgres outage doesn't restart the pod or cut open sessions, and the proxy responds 503 in the meantime.
The encryption key¶
KUBELATCH_ENCRYPTION_KEY encrypts each cluster's ServiceAccount tokens and signs sessions. It isn't in Postgres: if you lose it, a restored database can't use the clusters' tokens (If the encryption key is lost or changed). The chart takes it from one of two places:
| Option | How | When |
|---|---|---|
| Your Secret (default) | KUBELATCH_ENCRYPTION_KEY in kubelatch-secrets, as in step 2. |
Always valid. You generate it and keep it in your secret manager. |
| Generated by the chart | secrets.generateEncryptionKey: true. The first install generates it into the kubelatch-encryption-key Secret (key encryption-key); every upgrade reuses it, and helm uninstall leaves it in place. |
New installs only, when nobody should handle the key, and only with helm install and helm upgrade against the cluster. Copy it to your secret manager right after installing. |
To copy the generated key:
kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch get secret kubelatch-encryption-key -o jsonpath='{.data.encryption-key}' | base64 -d
Once kubelatch runs with a key, keep that key: turning on secrets.generateEncryptionKey later would give the pod a new one, and every cluster's tokens would have to be rotated. If kubelatch-secrets already has KUBELATCH_ENCRYPTION_KEY, the chart refuses to generate one (Troubleshooting). A key supplied through extraEnvFrom is not detected: the generated key would silently take its place, so never combine the two.
GitOps: bring your own key
The generated key relies on Helm reading the cluster while it renders. Tools that render the chart without a cluster (helm template, Argo CD, or any other that renders offline) would generate a different key on every render. The Secret is immutable, so applying a later render fails loudly instead of rotating the key. The chart's other checks on the key (reusing it, refusing a kubelatch-encryption-key Secret without its field, refusing to generate one when kubelatch-secrets already has a key) don't run either. Flux runs Helm against the cluster, so the lookup works there. Even so, with any GitOps tool, create the key Secret yourself and use secrets.existingSecret, never secrets.generateEncryptionKey: the key then lives in your secret manager, not only in the cluster. The objects Helm keeps on helm uninstall (the database, the generated key, the namespace) also carry Argo CD's Delete=false,Prune=false; with another tool, make sure it never prunes them.
Create the first administrator¶
The image has no shell: kubectl exec runs the binary directly, with the same variables as the server.
kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch exec -it deploy/kubelatch -- /kubelatch-server user create admin --admin --display-name "Admin"
It asks for the password twice, with no echo (12 characters minimum). With no terminal, pipe it through standard input:
printf '%s' '<long-password>' | kubectl --kubeconfig "$MGMT_KUBECONFIG" -n kubelatch exec -i deploy/kubelatch -- /kubelatch-server user create admin --admin --password-stdin
If you're going to turn on GitHub or OIDC login, mark this account as a break-glass account now (--break-glass). Accounts and recovery explains why.
Check that it works¶
- Open
https://kubelatch.example.comand sign in asadmin. - Register a cluster in Clusters: Register a cluster.
- Invite people in Users (Users and bots) and grant them tiers in Permissions (Grant permissions).
- Each person signs in to Home and issues their credential: Get a credential.
Exposing kubelatch: L4 LoadBalancer or Ingress¶
kubelatch carries long, unusual connections: watch and logs -f running for hours with almost no traffic, exec/attach/port-forward as SPDY or WebSocket upgrades over HTTP/1.1, and multi-megabyte applys. Any intermediary with timeouts or application buffers breaks them. That's why the default option (exposure: loadBalancer) is L4.
L4 LoadBalancer (default)¶
The load balancer passes the TLS bytes through as-is and kubelatch terminates TLS on port 8443 with the kubelatch-tls certificate. The source IP arrives intact (audit logging and per-IP limits with no X-Forwarded-For needed), with no application timeouts or size limits. Only the load balancer's TCP idle timeout can cut a connection. kubelatch sends TCP keepalives every 15 s, which all three clouds count as activity. Each cloud's settings go in service.annotations:
| Cloud | Idle timeout | What to do |
|---|---|---|
| AWS NLB | 350 s, fixed | Nothing: the keepalives reset it. For an NLB: service.beta.kubernetes.io/aws-load-balancer-type: "external", service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "instance" and service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing". |
| Azure Load Balancer | 4 min by default | service.beta.kubernetes.io/azure-load-balancer-tcp-idle-timeout: "30" (the maximum, in minutes). |
| GCP (Network LB passthrough) | No timeout | Nothing. |
| On-prem (MetalLB, kube-vip) | No timeout | Nothing. Without a load balancer, service.type: NodePort (and service.nodePort). |
With externalTrafficPolicy: Local only nodes with a kubelatch pod pass the health check. That's what you want. Leave config.trustedProxies empty.
Ingress (alternative)¶
If your organization requires going through an Ingress, use exposure: ingress. It's built for ingress-nginx and isn't part of the release tests: validate it on your cluster. TLS terminates at the controller (the kubelatch-tls Secret, or cert-manager with tls.certManager.enabled) and kubelatch listens on plain HTTP on :8080, behind a ClusterIP Service.
exposure: ingress
config:
baseURL: https://kubelatch.example.com
trustedProxies: 10.244.0.0/16 # the controller pods' IPs: narrow it for your cluster
ingress:
host: kubelatch.example.com
className: nginx
networkPolicy:
ingressControllerNamespace: ingress-nginx
The chart puts the three settings the proxy needs on the Ingress:
proxy-read-timeoutandproxy-send-timeoutset to3600. The 60 s default cutswatch,logs -fand an idleexec.proxy-body-size: "0". With the 1 MiB default, a largekubectl applygets a413before it reaches kubelatch.- HTTP/1.1 backend (
backend-protocol: HTTP). Never HTTP/2 or gRPC toward the backend: SPDY/WebSocket upgrades travel over HTTP/1.1.
It also turns off response buffering (proxy-buffering: "off"). ingress.annotations adds your own (for example cert-manager.io/cluster-issuer).
You have to adjust these:
ingress.host: the host ofconfig.baseURL, exactly. Otherwise the chart stops: kubelatch only knows one public host.- The certificate: either
tls.certManager.enabled(the chart'sCertificate) or acert-manager.io/cluster-issuer(orcert-manager.io/issuer) annotation iningress.annotations, never both: with both, the chart stops. config.trustedProxies: the controller pods' IPs and nothing else (the narrowest range that covers them, or the nodes' CIDR if it runs withhostNetwork). The chart refuses to install without it. The example value,10.244.0.0/16, is the pod CIDR for an entire kind cluster: it's not right for production. Details in Trusted proxies.networkPolicy.ingressControllerNamespace: the chart'sNetworkPolicyonly lets traffic reach:8080from that namespace (ingress-nginxby default). Egress isn't restricted: kubelatch needs Postgres, the clusters, and GitHub or your identity provider. KeepnetworkPolicy.enabledon: withexposure: ingressand noNetworkPolicy, the chart stops (Troubleshooting).
The NetworkPolicy needs a CNI that enforces it
Without a CNI that enforces NetworkPolicy (Calico, Cilium…), it's silently ignored. kubelatch is then exposed on plain HTTP to any pod, and anyone inside the trusted range can spoof the audit log's IP.
Other controllers (Traefik, HAProxy, AWS ALB) have equivalent settings: ingress.className and ingress.annotations. Whichever you use, ingress-nginx included, run three tests before signing off on it: kubectl get pods -w for more than 2 minutes, a kubectl exec left idle for 5 minutes, and a 2 MB kubectl create -f.
kubelatch treats any Authorization header on /api as a CLI token, so a proxy in front that injects one (Ingress basic-auth, oauth2-proxy with pass-authorization-header) breaks the web UI: don't add it on kubelatch's host.
Trusted proxies and the source IP¶
The audit log, the control-plane log and the per-IP rate limiters (login, CLI sign-in, CI exchange and proxy authentication failures) use the TCP connection's IP. Behind an L7 proxy that IP is the proxy's. config.trustedProxies (KUBELATCH_TRUSTED_PROXIES) says which networks X-Forwarded-For is trusted from:
- It's read right to left. The first hop that isn't trusted is the client; anything a client spoofs ends up to its left and is ignored.
- Only CIDRs are accepted (
10.244.0.0/16,10.0.0.1/32). A bare IP prevents startup. - A prefix wider than
/8in IPv4 or/16in IPv6 also prevents startup. - IPv4 addresses mapped into IPv6 (
::ffff:10.0.0.1) are compared unmapped.
Never set a range that reaches a client (for example 0.0.0.0/0): it could pick its own IP in the audit log and dodge the limits. If it's missing behind an Ingress, everything shows up as coming from the controller and the login limit applies to everyone at once.
Certificates and a private CA¶
kubelatch reads the certificate from /etc/kubelatch/tls and reloads the pair when it changes on disk: it checks the dates at most every 10 s. A cert-manager renewal doesn't need a restart. If the new pair isn't valid (a key from another certificate, a half-written file), it keeps serving the previous one and logs it (tls: reload failed). Every hour it warns if fewer than 30 days remain.
With tls.certManager.enabled the chart's Certificate reproduces the recommended one: ECDSA P-256, 90 days, renewed 30 days before, for the host of config.baseURL (tls.certManager.dnsNames, duration and renewBefore change them).
With a public CA (Let's Encrypt), kubeconfigs don't carry a CA: kubectl trusts the system store. With a private CA, point config.kubeconfigCA (KUBELATCH_KUBECONFIG_CA) at the PEM bundle and every issued kubeconfig will carry certificate-authority-data. With a private cert-manager issuer, the Secret already has ca.crt: use /etc/kubelatch/tls/ca.crt (with exposure: loadBalancer only: behind an Ingress, kubelatch doesn't mount the certificate).
The file must be a regular file, at most 1 MiB, and contain only CERTIFICATE blocks. A key pasted in by mistake or another PEM block prevents startup.
kubelatch never puts a managed cluster's CA into kubeconfigs. kubectl verifies kubelatch, and kubelatch verifies each API server with the CA it was enrolled with.
Several replicas¶
Two replicas keep kubelatch serving while a node is drained or a pod restarts. Spread them over two nodes with affinity, since the chart doesn't do it on its own. Use the preferred form: with the required form on a cluster of two nodes, the extra pod of a rollout (maxSurge: 1) has nowhere to go.
replicas: 2
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: kubelatch
app.kubernetes.io/instance: kubelatch
With replicas above 1 the chart adds a PodDisruptionBudget with pdb.minAvailable (1). The load balancer or the Ingress needs no session affinity: any replica answers any request.
What two replicas share, because it lives in Postgres or is signed with the encryption key both of them read:
- Sign-ins. A web session, a
kubelatch loginin the browser or with a device code, an agent's OAuth sign-in, a GitHub login or an OIDC login can start on one replica and finish on the other. The account lockout after 5 failed logins counts across both. - Agent approvals. An agent's call can wait for the decision on one replica while an administrator approves it through the other. The call sees the decision within a second.
- Revocation. Revoking a credential or disabling an account cuts its open
exec,port-forwardandwatchwithin 10 s, on whichever replica holds them. - Background jobs. Each replica runs the reconciler, the audit retention and the GitHub sync, and a Postgres lock keeps two passes from overlapping. Reconcile answers that a pass is already running while the other replica's pass holds the cluster. Migrations run on one replica while the other waits.
What each replica keeps to itself:
| What | With two replicas |
|---|---|
Rate limits: login, opening invitation and reset links, CLI sign-in, CI exchange, agents' OAuth, /mcp requests per credential, credential lookups, and the per-IP limit on requests the proxy rejects |
Each replica counts its own, so the effective limit is twice the configured one. |
| A license key pasted or removed in Edition | The replica that took it applies it at once, and the other one at its hourly check. Until then, Edition, the Free limits and the audit window depend on the replica that answers. Set the key in KUBELATCH_LICENSE instead (the Secret), or restart the pods after pasting it. |
| The last GitHub sync shown in Users | Each replica shows its own last pass. |
| The watch on each cluster's namespaces, which reconciles a cluster when a namespace with permissions changes its Pod Security label or goes (Reconciliation) | Each replica watches every cluster and logs its own namespace watch down and namespace watch up. A cluster enrolled, given new tokens or deleted through one replica reaches the other's watches within a minute. |
| The OIDC provider's discovery document (kept for a day) and keys (for an hour), and their state in Users | Each replica fetches its own; Users shows the state of the replica that answers. |
| Check now in Edition | Only the replica that answers checks. |
What isn't supported:
- Two installations on the same tables. Two releases need their own database, or at least their own schema. Replicas of one release are supported.
- Moving a stream to the other replica. A rollout or a restarted pod cuts the
exec,port-forwardandwatchit holds. kubectl reopens awatchon its own, on the replica still serving, but anexecor aport-forwardhas to be run again: see What happens during a rollout.
Next steps¶
- GitHub login, if your organization uses GitHub.
- OIDC login (SSO), if your company signs in with Google Workspace, Microsoft Entra ID, Okta or another identity provider.
- Register a cluster and, optionally, harden it.
- Database with CloudNativePG, if the chart runs your Postgres.
- Upgrades and backups, before going to production.