<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>Kuben | Blog</title><description>Kuben is a Kubernetes PaaS in a single binary: deploy container images into isolated environments from a web UI or a REST API, with zero-downtime rollouts, automatic HTTPS, teams and an audit log.</description><link>https://kuben.teamtem.com/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://kuben.teamtem.com/blog/rss.xml"/><item><title>Introducing Kuben: a Kubernetes PaaS in a single binary</title><link>https://kuben.teamtem.com/blog/introducing-kuben/</link><guid isPermaLink="true">https://kuben.teamtem.com/blog/introducing-kuben/</guid><description>Kuben 1.0 is out. One Rust binary turns a Kubernetes cluster into a platform your team can use. Here is what it is, what it is not, and why we built it this way.</description><pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Today we are releasing &lt;strong&gt;Kuben 1.0&lt;/strong&gt;, a platform as a service that runs on your own Kubernetes cluster and ships as a single binary.&lt;/p&gt;
&lt;p&gt;You give it a container image. It gives your team an isolated environment with a public HTTPS address, zero-downtime rollouts, autoscaling, logs, a numbered release history with one-click rollback, and an audit log that records who changed what. Everything it creates is an ordinary Kubernetes object, labelled and visible, so &lt;code dir=&quot;auto&quot;&gt;kubectl&lt;/code&gt; and GitOps keep working next to it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;helm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;kuben&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;oci://ghcr.io/teamtem-dev/charts/kuben&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--namespace&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;kuben-system&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--create-namespace&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That is the whole install. No operator to deploy first, no message queue, no external database unless you want several replicas, no build farm.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-another-paas&quot;&gt;Why another PaaS&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Small teams end up in one of two places. Either they run a hosted platform and pay per seat and per gigabyte for the privilege of not thinking about infrastructure, or they get a Kubernetes cluster and discover that “deploy a service” now means a Deployment, a Service, an HPA, an Ingress or HTTPRoute, a Certificate, a NetworkPolicy, a ResourceQuota, some RBAC, and a wiki page nobody updates.&lt;/p&gt;
&lt;p&gt;The self-hosted PaaS projects that fill this gap are good products, and we learned from them. But when we read their code closely, the same class of problems appeared again and again: a JWT in &lt;code dir=&quot;auto&quot;&gt;localStorage&lt;/code&gt;, a default secret in the repository, a build pod that carries a service-account token while running user code, log streams that leak, a “current cluster” global that races between requests. Not because the authors were careless, but because those systems grew fast in dynamic languages without a place where invariants live.&lt;/p&gt;
&lt;p&gt;Kuben is our attempt to make that class of problem &lt;strong&gt;structurally impossible&lt;/strong&gt; rather than merely avoided. We wrote it in Rust, kept it to one process, and wrote down eighteen invariants that every change is reviewed against. The &lt;a href=&quot;https://kuben.teamtem.com/docs/operations/security/&quot;&gt;security model&lt;/a&gt; page says what those choices buy you and, just as important, what they do not.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-10-does&quot;&gt;What 1.0 does&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Projects → environments → apps.&lt;/strong&gt; Every environment is its own namespace with a resource quota, a NetworkPolicy and Pod Security labels.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy any image&lt;/strong&gt; with readiness and startup probes, CPU autoscaling, environment variables and write-only secrets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Day-2 operations built in:&lt;/strong&gt; logs, restarts, releases with rollback, promotion between environments with a diff preview, persistent volumes, cron jobs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom domains with automatic HTTPS&lt;/strong&gt; through Gateway API and cert-manager. One listener per hostname; a domain belongs to exactly one app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One-click templates&lt;/strong&gt; for PostgreSQL, Redis, MariaDB, n8n, Uptime Kuma, Vaultwarden and Gitea, with generated credentials that never appear in a spec.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Teams:&lt;/strong&gt; four roles, invitations, API tokens capped at a role and scoped to a project or environment, and an append-only audit log of every change and every denial.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operations:&lt;/strong&gt; several replicas on PostgreSQL with Lease-based leader election, backups, boot-time migrations and CRD updates, Prometheus metrics, &lt;code dir=&quot;auto&quot;&gt;kuben doctor&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-is-not-yet&quot;&gt;What it is not, yet&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Kuben does not build images in 1.0. Your CI builds and pushes; Kuben rolls out with one &lt;code dir=&quot;auto&quot;&gt;PATCH&lt;/code&gt;. Git builds with BuildKit, preview environments per pull request, scale-to-zero and SSO are the &lt;a href=&quot;https://kuben.teamtem.com/docs/roadmap/&quot;&gt;roadmap&lt;/a&gt;, in roughly that order. The custom resources for builds and previews already exist so that the schema is stable when the features land.&lt;/p&gt;
&lt;p&gt;Kuben is also not a sandbox for hostile tenants. Environments isolate teams from each other’s mistakes; untrusted code needs a stronger boundary than a namespace.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-shape-of-the-thing&quot;&gt;The shape of the thing&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A few numbers, because footprint was a design goal and not an afterthought:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Release binary, console included&lt;/td&gt;
&lt;td&gt;≤ 26 MiB, and CI fails if it grows past that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container image&lt;/td&gt;
&lt;td&gt;≤ 30 MiB, distroless, non-root, read-only filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory requested by the pod&lt;/td&gt;
&lt;td&gt;64 Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console JavaScript&lt;/td&gt;
&lt;td&gt;≤ 200 kB brotli&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;Linux and macOS on x86_64 and arm64, Windows x86_64&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every release is checksummed, attested with build provenance and scanned before the image is pushed, and every binary embeds its own dependency list so you can audit it in place months later.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href=&quot;https://kuben.teamtem.com/docs/getting-started/quickstart/&quot;&gt;quickstart&lt;/a&gt; takes about five minutes on any cluster from 1.29 up. On a single server, &lt;code dir=&quot;auto&quot;&gt;k3s&lt;/code&gt; plus the &lt;a href=&quot;https://kuben.teamtem.com/docs/getting-started/binary/&quot;&gt;binary install&lt;/a&gt; works well. The &lt;a href=&quot;https://kuben.teamtem.com/docs/guides/deploy-from-ci/&quot;&gt;user guides&lt;/a&gt; walk through the everyday scenarios: deploying from CI, inviting a team, rolling back, promoting, custom domains.&lt;/p&gt;
&lt;p&gt;Kuben is Apache-2.0 and built by &lt;a href=&quot;https://teamtem.com&quot;&gt;Teamtem&lt;/a&gt;. Issues, feature requests and pull requests are welcome on &lt;a href=&quot;https://github.com/Teamtem-dev/kuben&quot;&gt;GitHub&lt;/a&gt;. We would love to hear what you deploy with it.&lt;/p&gt;</content:encoded><category>release</category><category>announcement</category></item><item><title>Why Kuben ships as one binary</title><link>https://kuben.teamtem.com/blog/one-binary/</link><guid isPermaLink="true">https://kuben.teamtem.com/blog/one-binary/</guid><description>A PaaS is usually a fleet of services. Kuben is one process with roles. This post explains the architecture behind that choice, and what happens when you want three replicas.</description><pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A platform that deploys other people’s software tends to become a small distributed system of its own: an API, a controller, a worker, a scheduler, a database, a message queue, a cache, and a reverse proxy in front. Each piece is reasonable. Together they are the reason “self-hosted PaaS” usually means “a second platform to operate”.&lt;/p&gt;
&lt;p&gt;Kuben is one process. This post is about how that works without becoming a monolith that falls over at the first real load, and what we gave up to get there.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;roles-not-services&quot;&gt;Roles, not services&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;kuben serve&lt;/code&gt; runs a set of &lt;strong&gt;roles&lt;/strong&gt; in one process: &lt;code dir=&quot;auto&quot;&gt;api&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;controller&lt;/code&gt;, and (reserved) &lt;code dir=&quot;auto&quot;&gt;activator&lt;/code&gt;. By default a process runs all of them. Each role is a supervised subsystem with its own lifecycle and health, so a panic in a controller restarts that controller, not the API.&lt;/p&gt;
&lt;p&gt;The interesting part is that the roles talk through Kubernetes and the database, not through in-process channels. The API writes an &lt;code dir=&quot;auto&quot;&gt;App&lt;/code&gt; custom resource; the controller watches it and reconciles. That is the same path a &lt;code dir=&quot;auto&quot;&gt;kubectl apply&lt;/code&gt; takes, which means GitOps tools and the console are peers. It also means that splitting the roles across processes later requires no code change; the Helm chart already runs several API replicas next to one active controller.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;kubernetes-holds-desired-state&quot;&gt;Kubernetes holds desired state&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The first design decision, and the one everything else follows from: &lt;strong&gt;Kubernetes is the source of truth for what should exist.&lt;/strong&gt; Projects, environments, apps and releases are custom resources in &lt;code dir=&quot;auto&quot;&gt;kuben.dev&lt;/code&gt;. SQL holds what Kubernetes has no home for: users, sessions, tokens, memberships, login throttling and the audit log, referring to cluster objects only by &lt;code dir=&quot;auto&quot;&gt;uid&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is the opposite of the “store everything in our database and sync to the cluster” approach. It costs us some convenience (a list of apps is a watch, not a &lt;code dir=&quot;auto&quot;&gt;SELECT&lt;/code&gt;), and it buys a lot: no reconciliation drift between two sources of truth, backups that are &lt;code dir=&quot;auto&quot;&gt;kubectl get -o yaml&lt;/code&gt;, and a console that can never show an app the cluster does not have.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;projections-instead-of-caches&quot;&gt;Projections instead of caches&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If the API reads from Kubernetes, doesn’t every page load hit the API server? No. Each process runs informers over the resources it cares about and keeps &lt;strong&gt;projections&lt;/strong&gt;: small in-memory read models with only the fields the console needs. The API answers from those, and pushes deltas to browsers over server-sent events.&lt;/p&gt;
&lt;p&gt;We considered caching full objects the way &lt;code dir=&quot;auto&quot;&gt;kube-rs&lt;/code&gt; reflectors do. On a cluster with a few hundred pods that is hundreds of megabytes, mostly &lt;code dir=&quot;auto&quot;&gt;managedFields&lt;/code&gt; and status nobody reads. Projections are why the Kuben pod requests 64 Mi of memory and why the pod reports ready only after every informer has listed once: a fresh replica must never answer &lt;code dir=&quot;auto&quot;&gt;404&lt;/code&gt; for an app that exists.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;sqlite-by-default-postgresql-when-you-grow&quot;&gt;SQLite by default, PostgreSQL when you grow&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;SQLite on a persistent volume is the default database. It is not a toy here: WAL mode, a single-writer pool, embedded migrations under a lock, foreign keys always on, and a &lt;code dir=&quot;auto&quot;&gt;Recreate&lt;/code&gt; deployment strategy because a &lt;code dir=&quot;auto&quot;&gt;ReadWriteOnce&lt;/code&gt; volume cannot be mounted by two pods.&lt;/p&gt;
&lt;p&gt;The same repositories run on PostgreSQL through &lt;code dir=&quot;auto&quot;&gt;sqlx&lt;/code&gt;, and CI runs the store tests against both backends on every change so they cannot drift. The moment you set &lt;code dir=&quot;auto&quot;&gt;database.url&lt;/code&gt; to a PostgreSQL DSN, the chart drops the volume, switches to rolling updates with &lt;code dir=&quot;auto&quot;&gt;maxUnavailable: 0&lt;/code&gt;, and lets you raise &lt;code dir=&quot;auto&quot;&gt;replicaCount&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;three-replicas-one-controller&quot;&gt;Three replicas, one controller&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Several replicas of one process that includes a controller would reconcile the same objects concurrently. The fix is not “run the controller elsewhere”; it is a Kubernetes &lt;code dir=&quot;auto&quot;&gt;Lease&lt;/code&gt;. Every replica serves the API from its own projections; only the Lease holder runs the controllers, and the rest report &lt;code dir=&quot;auto&quot;&gt;controllers: standby&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The election never compares timestamps with the local clock, so skew between nodes cannot produce two leaders. That deserves its own post: &lt;a href=&quot;https://kuben.teamtem.com/blog/leader-election-without-clocks/&quot;&gt;Leader election without trusting clocks&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;embedding-the-console&quot;&gt;Embedding the console&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The web console is a React application built with Vite and embedded into the binary at compile time, pre-compressed, served with a strict Content Security Policy and an immutable cache header. There is no Node in the image, and there is no image layer for the frontend. The &lt;code dir=&quot;auto&quot;&gt;/api&lt;/code&gt; the console talks to is the same one your CI uses, and its TypeScript client is generated from the OpenAPI document the binary serves; CI fails if either drifts.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-we-gave-up&quot;&gt;What we gave up&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A second Tokio runtime as a bulkhead&lt;/strong&gt; between the API and the controllers was in the original design. We shipped one runtime and kept the flag (&lt;code dir=&quot;auto&quot;&gt;runtime.bulkhead&lt;/code&gt;) for when a measurement, not a hunch, says we need it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Builds&lt;/strong&gt; are not in the binary yet. When they arrive, they will run as Kubernetes Jobs against a persistent BuildKit daemon, never inside the Kuben process and never with cluster credentials.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An in-process queue&lt;/strong&gt; for things like log fan-out was replaced, for now, by bounded REST reads and SSE. The bounded, reference-counted log hub is on the roadmap.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;the-result&quot;&gt;The result&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;One binary you can &lt;code dir=&quot;auto&quot;&gt;curl | bash&lt;/code&gt; onto a server next to k3s, or one Helm release inside any cluster. A footprint you can put on a dashboard. And a platform whose every object is a Kubernetes object, which is the part we care about most. If you want to see it, the &lt;a href=&quot;https://kuben.teamtem.com/docs/getting-started/concepts/&quot;&gt;concepts page&lt;/a&gt; is the map, and the &lt;a href=&quot;https://kuben.teamtem.com/docs/contributing/architecture-decisions/&quot;&gt;architecture decisions&lt;/a&gt; are the reasons.&lt;/p&gt;</content:encoded><category>architecture</category><category>engineering</category></item><item><title>Leader election without trusting clocks</title><link>https://kuben.teamtem.com/blog/leader-election-without-clocks/</link><guid isPermaLink="true">https://kuben.teamtem.com/blog/leader-election-without-clocks/</guid><description>Running several Kuben replicas meant deciding which one reconciles. We built the election on a Kubernetes Lease and compare-and-swap, and made sure clock skew can never produce two leaders.</description><pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When the Helm chart first allowed &lt;code dir=&quot;auto&quot;&gt;replicaCount &gt; 1&lt;/code&gt; on PostgreSQL, every replica ran the full controller set. Two replicas reconciled the same &lt;code dir=&quot;auto&quot;&gt;App&lt;/code&gt;, both server-side-applied with &lt;code dir=&quot;auto&quot;&gt;force&lt;/code&gt;, and the objects flapped. The chart also used the &lt;code dir=&quot;auto&quot;&gt;Recreate&lt;/code&gt; strategy regardless of database, so every upgrade had downtime. And login throttling lived in each process’s memory, so three replicas gave an attacker three times the budget.&lt;/p&gt;
&lt;p&gt;This post is about the first problem and its fix, recorded as &lt;a href=&quot;https://kuben.teamtem.com/docs/contributing/architecture-decisions/&quot;&gt;ADR-023&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-requirement&quot;&gt;The requirement&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Exactly one replica runs the controllers at any time. Every replica keeps serving the API, because the API reads from its own projections and does not need the controllers. Failover after a crash should take seconds, not minutes, and a graceful restart should barely be noticeable.&lt;/p&gt;
&lt;p&gt;That is a leader election, and Kubernetes has a primitive for it: the &lt;code dir=&quot;auto&quot;&gt;coordination.k8s.io/v1&lt;/code&gt; &lt;strong&gt;Lease&lt;/strong&gt;, an object with a holder identity, a lease duration, and a renew time. The usual implementation, in client-go and in kube-rs helpers, renews the Lease periodically and treats it as expired when &lt;code dir=&quot;auto&quot;&gt;now - renewTime &gt; leaseDuration&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem-with-now&quot;&gt;The problem with &lt;code dir=&quot;auto&quot;&gt;now&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;“Now” on the candidate’s node and &lt;code dir=&quot;auto&quot;&gt;renewTime&lt;/code&gt; written by the leader’s node are two different clocks. Skew of a few seconds between nodes is ordinary; a paused VM or a clock step can produce much more. If a candidate’s clock runs ahead, it sees a perfectly healthy Lease as expired, takes it, and for a while &lt;strong&gt;two processes believe they lead&lt;/strong&gt;. The leader finds out only at its next renewal, when its compare-and-swap fails. In that window both reconcile.&lt;/p&gt;
&lt;p&gt;Most systems accept this window and make reconciliation idempotent enough that it rarely matters. We wanted the window to not exist.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;never-compare-a-remote-timestamp-with-a-local-clock&quot;&gt;Never compare a remote timestamp with a local clock&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The rule we adopted: a candidate treats the Lease as expired only after it has observed the Lease record &lt;strong&gt;unchanged for the full lease duration, measured on its own clock&lt;/strong&gt;. It reads the Lease, remembers &lt;code dir=&quot;auto&quot;&gt;resourceVersion&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;renewTime&lt;/code&gt;, and starts a local timer. If the leader renews, &lt;code dir=&quot;auto&quot;&gt;resourceVersion&lt;/code&gt; changes and the timer restarts. If nothing changes for 15 seconds, the Lease is dead by any clock, because a live leader would have renewed it several times in that interval.&lt;/p&gt;
&lt;p&gt;Every write is a compare-and-swap on &lt;code dir=&quot;auto&quot;&gt;resourceVersion&lt;/code&gt;. Two candidates that both decide the Lease is dead race on that write and exactly one wins; the other reads the new version and goes back to waiting.&lt;/p&gt;
&lt;p&gt;The numbers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lease duration&lt;/td&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leader renewal interval&lt;/td&gt;
&lt;td&gt;2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leader gives up if renewal fails for&lt;/td&gt;
&lt;td&gt;10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last row is the other half of the safety argument. If the leader cannot renew for 10 seconds, it &lt;strong&gt;stops its controllers before&lt;/strong&gt; the 15-second mark at which a candidate could take over. The leader’s own clock is enough for that decision, because it only compares its own timestamps. So the leader has always stepped down before anyone else can step up.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;graceful-and-ungraceful&quot;&gt;Graceful and ungraceful&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;On shutdown, the leader releases the Lease by clearing the holder identity in one more compare-and-swap. Candidates notice the change immediately and the next one takes over within a couple of seconds. This is the path a rolling upgrade takes, and the chart’s &lt;code dir=&quot;auto&quot;&gt;maxUnavailable: 0&lt;/code&gt; plus a &lt;code dir=&quot;auto&quot;&gt;PodDisruptionBudget&lt;/code&gt; mean that a node drain never removes more than one pod.&lt;/p&gt;
&lt;p&gt;After a crash there is nobody to release the Lease, so the controllers pause for up to 15 seconds. That is the trade: a slightly longer failover for the guarantee that there is never a second leader.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-runs-under-the-lease&quot;&gt;What runs under the Lease&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Anything that must happen exactly once per cluster runs only on the leader: the reconcilers, applying the CRDs at boot, and the Gateway listener management. Anything that must happen on every replica, informers, projections, the API, session caching, runs everywhere. The rule is written into the review checklist so that new once-per-cluster work does not end up on every replica by accident.&lt;/p&gt;
&lt;p&gt;The Lease right is a namespaced &lt;code dir=&quot;auto&quot;&gt;Role&lt;/code&gt; on that one object, not part of the &lt;code dir=&quot;auto&quot;&gt;ClusterRole&lt;/code&gt;, and the metric &lt;code dir=&quot;auto&quot;&gt;kuben_leader&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;1&lt;/code&gt; on exactly one pod; alert if &lt;code dir=&quot;auto&quot;&gt;sum(kuben_leader) != 1&lt;/code&gt; for more than a minute.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-other-two-fixes&quot;&gt;The other two fixes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Since the same decision touched replica behaviour, we fixed the two related problems at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rolling updates on PostgreSQL.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;Recreate&lt;/code&gt; stays for SQLite, whose volume is &lt;code dir=&quot;auto&quot;&gt;ReadWriteOnce&lt;/code&gt;; PostgreSQL deployments roll one pod at a time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Login throttling in the database.&lt;/strong&gt; The three counters (email + IP, IP, account) are rows keyed by SHA-256 hashes, shared by all replicas, so the budget is the same with one replica or ten.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is one thing that stays eventually consistent by design: a revoked session is cached per replica for up to 5 seconds (&lt;code dir=&quot;auto&quot;&gt;KUBEN_SECURITY__SESSION_CACHE_TTL_SECS&lt;/code&gt;). We judged that a bounded, documented window there beats a database round-trip on every request.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;helm upgrade … --set database.existingSecret=kuben-db --set replicaCount=3&lt;/code&gt;, then &lt;code dir=&quot;auto&quot;&gt;kubectl -n kuben-system get lease kuben-controller -w&lt;/code&gt; and delete the leader pod. The &lt;a href=&quot;https://kuben.teamtem.com/docs/operations/high-availability/&quot;&gt;high availability&lt;/a&gt; page has the full setup and the table of what to expect in each failure mode.&lt;/p&gt;</content:encoded><category>architecture</category><category>kubernetes</category><category>engineering</category></item><item><title>How we release Kuben: attested, scanned, and built from the bytes we checked</title><link>https://kuben.teamtem.com/blog/how-we-release/</link><guid isPermaLink="true">https://kuben.teamtem.com/blog/how-we-release/</guid><description>Kuben&apos;s release pipeline builds five binaries without a cache, checksums and attests them, builds the image from those exact bytes, scans it before pushing, and embeds a dependency list in every binary. Here is why each step is there.</description><pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A platform that deploys your software runs with a service account that can read every Secret in the cluster. Its release process should be at least as careful as the code. This post walks through Kuben’s, step by step, with the reasoning for each.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-shape&quot;&gt;The shape&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tag v1.0.2 ─► plan (tag == Cargo version)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;├─► web (bun: vite build + size-limit) ─► build ×5 (cargo auditable) ─┬─► publish (checksums, attestations, GitHub Release)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│                                                                     │        └─► verify-install ×3&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│                                                                     └─► image (budget → Trivy → push, SBOM, provenance)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│                                                                              └─► chart (helm push)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A release starts with a signed git tag. Nothing else can trigger it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;1-the-tag-must-match-the-code&quot;&gt;1. The tag must match the code&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;plan&lt;/code&gt; job checks that the tag equals &lt;code dir=&quot;auto&quot;&gt;[workspace.package] version&lt;/code&gt; in &lt;code dir=&quot;auto&quot;&gt;Cargo.toml&lt;/code&gt;. If someone tags &lt;code dir=&quot;auto&quot;&gt;v1.0.3&lt;/code&gt; on a commit that says &lt;code dir=&quot;auto&quot;&gt;1.0.2&lt;/code&gt;, nothing is built. It sounds trivial; it prevents the class of “the binary says one version and the release page another” confusion that makes incident timelines painful.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;2-build-without-a-cache&quot;&gt;2. Build without a cache&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;CI uses a Rust cache and a Turborepo remote cache to stay fast, and pull requests can only &lt;strong&gt;read&lt;/strong&gt; those caches; writes happen on &lt;code dir=&quot;auto&quot;&gt;main&lt;/code&gt;. Releases go further: they use no cache at all. The web bundle is built by calling the package scripts directly with Bun rather than through the task runner, and the Rust builds and the Zig toolchain run cache-free. A poisoned cache, however it got there, cannot reach a published artifact.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;3-five-targets-tested-on-four-operating-systems&quot;&gt;3. Five targets, tested on four operating systems&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Binaries ship for Linux and macOS on x86_64 and arm64 and for Windows x86_64. Linux builds are static musl binaries, cross-compiled with &lt;code dir=&quot;auto&quot;&gt;cargo-zigbuild&lt;/code&gt; so that the arm64 build does not need QEMU. The same five targets are exercised by the test matrix on every pull request; a platform we ship is a platform we test.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;4-every-binary-carries-its-dependency-list&quot;&gt;4. Every binary carries its dependency list&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The builds run under &lt;code dir=&quot;auto&quot;&gt;cargo auditable&lt;/code&gt;, which embeds the exact list of crates (about 6 KiB for Kuben’s 378 crates) in a linker section of the binary. Months later, &lt;code dir=&quot;auto&quot;&gt;trivy&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;grype&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;osv-scanner&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;cargo audit bin&lt;/code&gt; can point at a deployed binary or image and tell you whether any of its dependencies has an advisory. We scan the Linux binaries this way before they are packaged, and fail on any HIGH or CRITICAL vulnerability with a fix available.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;5-checksums-and-attestations&quot;&gt;5. Checksums and attestations&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each archive gets a &lt;code dir=&quot;auto&quot;&gt;.sha256&lt;/code&gt; file, and &lt;code dir=&quot;auto&quot;&gt;checksums.txt&lt;/code&gt; lists all of them. The publish job also generates GitHub build provenance attestations, so &lt;code dir=&quot;auto&quot;&gt;gh attestation verify kuben-x86_64-unknown-linux-musl.tar.gz --repo Teamtem-dev/kuben&lt;/code&gt; proves the archive was built by this workflow from this commit.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;install.sh&lt;/code&gt; reads &lt;code dir=&quot;auto&quot;&gt;checksums.txt&lt;/code&gt; and verifies the archive &lt;strong&gt;before&lt;/strong&gt; extracting anything. It downloads over HTTPS only, wraps its whole body in a &lt;code dir=&quot;auto&quot;&gt;main()&lt;/code&gt; called on the last line so that a truncated download runs nothing, and never calls the GitHub API. After every release, a job runs the script on Ubuntu x64, Ubuntu arm64 and macOS against the real assets and checks &lt;code dir=&quot;auto&quot;&gt;kuben --version&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;6-the-image-is-a-copy&quot;&gt;6. The image is a &lt;code dir=&quot;auto&quot;&gt;COPY&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The release image is not built from source. &lt;code dir=&quot;auto&quot;&gt;deploy/release.Dockerfile&lt;/code&gt; copies the already-checksummed musl binary into &lt;code dir=&quot;auto&quot;&gt;distroless/static:nonroot&lt;/code&gt;. The multi-arch build takes seconds, needs no emulation, and, more to the point, the bytes in the image are the exact bytes that were checksummed, attested and scanned in the previous steps.&lt;/p&gt;
&lt;p&gt;The Dockerfile sets &lt;code dir=&quot;auto&quot;&gt;USER 65532:65532&lt;/code&gt; numerically. Kubernetes cannot verify &lt;code dir=&quot;auto&quot;&gt;runAsNonRoot: true&lt;/code&gt; against a user &lt;em&gt;name&lt;/em&gt;, and rejects the pod; a number it can check.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;7-gates-before-the-push&quot;&gt;7. Gates before the push&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;linux/amd64&lt;/code&gt; image is loaded locally, measured against its 30 MiB budget and scanned by Trivy. Only if both pass is the multi-arch image pushed, with an SBOM and provenance attached. Then the Helm chart is pushed to the same OCI registry. Tags such as &lt;code dir=&quot;auto&quot;&gt;v1.1.0-rc.1&lt;/code&gt; become pre-releases and never move the &lt;code dir=&quot;auto&quot;&gt;latest&lt;/code&gt; image tag or the &lt;code dir=&quot;auto&quot;&gt;X.Y&lt;/code&gt; tag.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;8-then-every-day&quot;&gt;8. Then, every day&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A lockfile that passed yesterday can be vulnerable today; advisories are published after code is merged. A scheduled workflow runs daily: &lt;code dir=&quot;auto&quot;&gt;cargo deny check advisories&lt;/code&gt; on &lt;code dir=&quot;auto&quot;&gt;main&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;trivy image&lt;/code&gt; on the published &lt;code dir=&quot;auto&quot;&gt;latest&lt;/code&gt;. A failure opens one issue, &lt;em&gt;Scheduled security check failed&lt;/em&gt;, and later failures comment on it instead of opening more. The fix is always the same: update, merge, cut a patch release so the image follows.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-ci-side&quot;&gt;The CI side&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The release pipeline inherits the rules of the CI workflow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every action is pinned to a &lt;strong&gt;commit SHA&lt;/strong&gt;, with the tag in a comment, and Dependabot keeps them current with a 7-day cooldown against freshly published malicious packages.&lt;/li&gt;
&lt;li&gt;Rust is installed with the runner’s own &lt;code dir=&quot;auto&quot;&gt;rustup&lt;/code&gt;; third-party tools are installed at exact versions by a checksum-verifying installer. We deliberately do not use Trivy’s own GitHub Actions after their tags were hijacked in March 2026.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;permissions: contents: read&lt;/code&gt; is the default; each job requests what it needs; &lt;code dir=&quot;auto&quot;&gt;persist-credentials: false&lt;/code&gt; on every checkout.&lt;/li&gt;
&lt;li&gt;The workflows themselves are audited with zizmor on every pull request for template injection, excessive permissions and cache poisoning.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;why-write-this-down&quot;&gt;Why write this down&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;None of this is novel. All of it is easy to skip, and each skipped step is a way for someone else’s code to end up in your cluster with cluster-admin. Writing the pipeline down, in the &lt;a href=&quot;https://kuben.teamtem.com/docs/contributing/ci-cd/&quot;&gt;CI/CD page&lt;/a&gt; and here, is how we keep ourselves from skipping steps when a release is urgent. If you see a gap, we would like to hear about it, &lt;a href=&quot;https://github.com/Teamtem-dev/kuben/security/policy&quot;&gt;privately&lt;/a&gt; if it is a vulnerability.&lt;/p&gt;</content:encoded><category>security</category><category>engineering</category><category>ci</category></item></channel></rss>