<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kubenest</title>
    <description>The latest articles on DEV Community by Kubenest (@kubenest_eu).</description>
    <link>https://dev.to/kubenest_eu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229239%2Fba0831ff-cd26-4e05-bc16-15ba2575df45.png</url>
      <title>DEV Community: Kubenest</title>
      <link>https://dev.to/kubenest_eu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kubenest_eu"/>
    <language>en</language>
    <item>
      <title>Rotating a database password without a restart: how our Redis and Valkey Kubernetes operator works</title>
      <dc:creator>Kubenest</dc:creator>
      <pubDate>Thu, 05 Nov 2026 21:07:25 +0000</pubDate>
      <link>https://dev.to/kubenest_eu/rotating-a-database-password-without-a-restart-how-our-redis-and-valkey-kubernetes-operator-works-2n11</link>
      <guid>https://dev.to/kubenest_eu/rotating-a-database-password-without-a-restart-how-our-redis-and-valkey-kubernetes-operator-works-2n11</guid>
      <description>&lt;p&gt;Our free plan has no disk, so Valkey runs with &lt;code&gt;appendonly no&lt;/code&gt; and &lt;code&gt;save ""&lt;/code&gt;, so when the pod restarts, the customer's data is gone.&lt;/p&gt;

&lt;p&gt;That &lt;strong&gt;one fact&lt;/strong&gt; shaped most of our Kubernetes operator. If rotating a password restarts the pod, a routine security task wipes someone's cache. So, as expected, we split configuration into two classes: changes that need a restart, and changes that must never cause one.&lt;/p&gt;

&lt;p&gt;This post walks through how the operator works and why it's built this way, including the parts that aren't finished.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this is the operator behind &lt;a href="https://kvnest.com" rel="noopener noreferrer"&gt;KVNest&lt;/a&gt;, the managed Redis and Valkey service we run. Versions and numbers are as of November 2026.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One cluster-scoped custom resource becomes up to nine child objects, nearly all in a dedicated namespace.&lt;/li&gt;
&lt;li&gt;Children are created in dependency order, so a pod never exists without its quota and NetworkPolicy.&lt;/li&gt;
&lt;li&gt;Server settings are hashed into a pod annotation and trigger a rollout. ACL users are left out of that hash and are applied live with &lt;code&gt;ACL SETUSER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each instance has a private internal user for probes, metrics and the operator, so customer password rotation can't break them.&lt;/li&gt;
&lt;li&gt;It's deliberately single-node. No failover, no backups yet, no SLA. More on that at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The API is one object
&lt;/h2&gt;

&lt;p&gt;When we started, the existing Valkey operators didn't fit a paid, externally exposed product, so we wrote a thin one with kubebuilder. The controller package is under 1,400 lines of Go 💙, not counting tests.&lt;/p&gt;

&lt;p&gt;Our web API writes exactly one object per instance. The Kubernetes API is the database for instances, so there's no second copy to drift out of sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache.kvnest.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ValkeyInstance&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inst-demo1&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kvnest.com/owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user-123&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;starter&lt;/span&gt;
  &lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;valkey&lt;/span&gt;   &lt;span class="c1"&gt;# or redis&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9.1"&lt;/span&gt;   &lt;span class="c1"&gt;# 8.10 for redis, etc...&lt;/span&gt;
  &lt;span class="na"&gt;allowedSources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;203.0.113.0/24&lt;/span&gt;
  &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;analytics&lt;/span&gt;
      &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;readonly&lt;/span&gt;
      &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events:*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It's cluster-scoped for a reason.&lt;/strong&gt; Kubernetes doesn't allow a namespaced owner to own cluster-scoped objects or objects in another namespace. A cluster-scoped CR can do both, so this one object can own its Namespace and a Secret in a separate credentials namespace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation lives in the CRD.&lt;/strong&gt; There's no webhook to keep available, because CEL rules and schema patterns run in the API server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan is immutable
engine is immutable
spec.users[0].name: Invalid value: "kvnest-x": name is reserved
spec.allowedSources[0]: Invalid value: "nope": must be a CIDR such as 203.0.113.0/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine is immutable because Redis and Valkey data files aren't interchangeable. Key patterns must match &lt;code&gt;^[^\s"'\\]{1,64}$&lt;/code&gt;, which stops a pattern from smuggling extra tokens into a generated &lt;code&gt;user&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  One CR, nine children
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdpawhw39hw54t17fh1c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdpawhw39hw54t17fh1c.png" alt="Diagram of a ValkeyInstance and the objects the operator creates for it, numbered in creation order: Namespace, ResourceQuota, NetworkPolicy, credentials Secret, config Secret, Service, StatefulSet, MiddlewareTCP and IngressRouteTCP" width="799" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order is deliberate. The quota and NetworkPolicy exist before anything can be scheduled, and the public route comes last. The allowlist middleware is created before the route references it, and it's deleted only after the route stops referencing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletion is one line of logic.&lt;/strong&gt; If the CR has a deletion timestamp, &lt;code&gt;Reconcile&lt;/code&gt; returns and garbage collection does the rest. There's no finalizer, because nothing lives outside the cluster. The StatefulSet's PVC retention policy is &lt;code&gt;WhenDeleted: Delete&lt;/code&gt;, so the volume goes too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traefik's CRDs are handled as unstructured objects,&lt;/strong&gt; so the operator doesn't depend on Traefik's Go module.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The informer cache is label-filtered.&lt;/strong&gt; The operator only caches objects carrying its &lt;code&gt;managed-by&lt;/code&gt; label, so it never holds every Secret and Service in the cluster in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconcile loop
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mkehvtf2yy2ysce1ema.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mkehvtf2yy2ysce1ema.png" alt="Flowchart of one reconcile pass: a watch event or timer leads to reading the CR and ensuring children, then a check for a ready replica and finished rollout. If not, progress is reported and the loop requeues in 5 seconds. If so, ACLs are synced, the phase becomes Ready, and the loop requeues in 10 minutes or at the next password expiryf" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few details here took more thought than the diagram suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ready replica doesn't mean the rollout is done.&lt;/strong&gt; During a rolling restart, the old pod still counts as ready while it shuts down. If we trusted &lt;code&gt;readyReplicas&lt;/code&gt; alone, we'd report Ready too early and send ACL commands to a pod on its way out. So there's a second check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;rolledOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;appsv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatefulSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ObservedGeneration&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;sts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Generation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateRevision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentRevision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateRevision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentRevision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;replicas&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Replicas&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;replicas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Replicas&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedReplicas&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;replicas&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;Failed&lt;/code&gt; isn't a terminal state.&lt;/strong&gt; An instance is marked Failed after 10 minutes without becoming Ready, but the loop keeps running and it recovers on its own if the pod comes up. The timer counts from when the Ready condition last turned False, so a restart of a six-month-old instance doesn't read as six months late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StatefulSet updates touch only what can drift.&lt;/strong&gt; Most fields are immutable or defaulted by the API server, so overwriting the whole spec would fight it. After creation, we update replicas, the config hash annotation, scheduling fields, and each container's image, resources, env and readiness probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing containers get added whole.&lt;/strong&gt; That's how the metrics sidecar reached instances created before it existed. It costs one restart per instance, which on a diskless plan means losing the cache, so that kind of change gets weighed carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two classes of configuration
&lt;/h2&gt;

&lt;p&gt;This is the core of the design. &lt;code&gt;valkey.conf&lt;/code&gt; is one file with two parts, but only one part counts. Condensed from the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;conf&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ValkeyConf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// The Secret holds both parts...&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"valkey.conf"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ACLConf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;internalPasswordKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// ...but users are left out of the hash: they change live, without a restart.&lt;/span&gt;
&lt;span class="n"&gt;configHash&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tpl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Annotations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;configHashAnno&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configHash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the eviction policy or turn on modules, and the hash changes, the pod template changes, and the StatefulSet rolls. Add a user or rotate a password, and the hash stays the same, so no pod restarts. The operator applies the change over the network instead.&lt;/p&gt;

&lt;p&gt;Users still go in the file because a new pod has to start with the right ones. Here's a real rendered line for a read-only user during a rotation, with the hashes shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user analytics on resetpass #97b056…f88191 #6da063…f9de0d resetkeys ~events:* resetchannels &amp;amp;* nocommands -@all +@read +@connection +info -@admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every user definition starts from a clean slate.&lt;/strong&gt; &lt;code&gt;resetpass&lt;/code&gt;, &lt;code&gt;resetkeys&lt;/code&gt;, &lt;code&gt;resetchannels&lt;/code&gt; and &lt;code&gt;nocommands&lt;/code&gt; come first, so applying the rules again fully replaces the user. The same rules work in the file and in &lt;code&gt;ACL SETUSER&lt;/code&gt;, and applying them twice is harmless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passwords are stored as SHA-256 hashes.&lt;/strong&gt; Plaintext never reaches &lt;code&gt;valkey.conf&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sync itself is short. It opens a connection with a 5-second budget, runs &lt;code&gt;ACL SETUSER&lt;/code&gt; for each expected user, lists what the server has with &lt;code&gt;ACL USERS&lt;/code&gt;, and deletes anything extra. Redefining a user keeps their open connections, while deleting one closes them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 10-minute resync heals a real race.&lt;/strong&gt; Secret volumes update eventually, so a pod that restarts right after a user change can load an older file. The next reconcile corrects it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed sync doesn't take the instance down.&lt;/strong&gt; The server is still serving traffic, so it stays Ready. What doesn't advance is &lt;code&gt;status.observedAccessGeneration&lt;/code&gt;. The API compares that to &lt;code&gt;metadata.generation&lt;/code&gt; and tells the customer their access change is still pending. The readiness of the database and the convergence of its config are separate signals, and we report them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Password rotation is declarative
&lt;/h2&gt;

&lt;p&gt;Rotation is a spec change, like everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;passwordRotation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;generation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;previousValidUntil&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-11-06T12:00:00Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising &lt;code&gt;generation&lt;/code&gt; above the value stored in the credentials Secret issues a new password. Because that comparison uses stored state, a retried reconcile can't generate a second one.&lt;/p&gt;

&lt;p&gt;During the grace period, the server accepts both passwords. That's what the two hashes in the line above are. The API caps the grace period at 7 days.&lt;/p&gt;

&lt;p&gt;Revocation needs no cron job. The requeue is set for just after the earliest expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;requeue&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;aclResync&lt;/span&gt; &lt;span class="c"&gt;// 10 minutes&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nextExpiry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;requeue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requeue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nextExpiry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one behavior worth knowing as an operator: revoking a password stops new authentications, but it doesn't close connections that are already authenticated. Those last until the client disconnects or hits the 300-second idle &lt;code&gt;timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why there's a separate internal user
&lt;/h3&gt;

&lt;p&gt;The readiness probe, the exporter and the operator all authenticate as &lt;code&gt;kvnest&lt;/code&gt;, an ACL user whose password never leaves the instance namespace and never rotates. Without it, rotating a customer password could make the pod unready.&lt;/p&gt;

&lt;p&gt;Protecting that user meant carving subcommands out of the customer's &lt;code&gt;+@all&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+@all
-acl|setuser -acl|deluser -acl|load -acl|save
-acl|list -acl|getuser -acl|dryrun
-client|kill -client|pause -client|unpause
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CLIENT KILL&lt;/code&gt; is the interesting one. ACL rules can't scope it to the caller's own connections, so allowing it would let a customer drop the probe's connection and make their own pod look unhealthy. No common client library sends it, so removing it costs little. &lt;code&gt;CLIENT PAUSE&lt;/code&gt; goes for the same reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CONFIG&lt;/code&gt;, &lt;code&gt;DEBUG&lt;/code&gt;, &lt;code&gt;MODULE&lt;/code&gt;, &lt;code&gt;SHUTDOWN&lt;/code&gt;, &lt;code&gt;REPLICAOF&lt;/code&gt; and the replication commands are disabled for everyone with &lt;code&gt;rename-command&lt;/code&gt;. That includes the internal user, so the exporter runs with &lt;code&gt;--config-command=-&lt;/code&gt;, and modules are loaded from the config file at startup.&lt;/p&gt;

&lt;p&gt;Moving to this design did have a cost. Existing instances restarted once when the probes switched users, and Starter instances lost their cached data at that point. That's the same trade-off as the sidecar, and it's why we think hard before changing a pod template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolation and the network path
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsoy3vqtkezvv8rywxz83.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsoy3vqtkezvv8rywxz83.png" alt="Network diagram: client traffic goes over TLS through a load balancer to Traefik, then to Valkey on port 6379. The operator pod can reach port 6379 and Prometheus can reach the exporter on port 9121. Any other pod and all egress are blocked by the NetworkPolicy" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routing.&lt;/strong&gt; Clients connect to &lt;code&gt;inst-&amp;lt;id&amp;gt;.eu.kvnest.com:6380&lt;/code&gt; over TLS. Traefik terminates TLS with a wildcard certificate and routes by SNI to the instance's ClusterIP Service. The load balancer sends PROXY protocol headers, so Traefik's &lt;code&gt;ipAllowList&lt;/code&gt; sees the client's real address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NetworkPolicy.&lt;/strong&gt; There are exactly three ingress rules: the Traefik namespace on 6379, the Prometheus namespace on 9121, and the operator's pod on 6379, matched by both namespace and pod labels. The policy declares &lt;code&gt;Egress&lt;/code&gt; with no rules, so all outbound traffic is denied. Valkey doesn't need to call anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ResourceQuota.&lt;/strong&gt; One pod, one PVC if the plan has one, no LoadBalancer or NodePort Services, and CPU and memory equal to the plan plus the sidecar's 50m and 32Mi. The sidecar's share is added on top, so metrics never come out of the memory a customer pays for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pod spec.&lt;/strong&gt; The namespace enforces the &lt;code&gt;restricted&lt;/code&gt; Pod Security Standard. Requests equal limits on both containers for Guaranteed QoS. The pod runs as uid 999 with a read-only root filesystem, all capabilities dropped, the &lt;code&gt;RuntimeDefault&lt;/code&gt; seccomp profile, no ServiceAccount token, and &lt;code&gt;enableServiceLinks: false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing around failure modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Memory limit&lt;/th&gt;
&lt;th&gt;&lt;code&gt;maxmemory&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;&lt;code&gt;maxclients&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;256Mi&lt;/td&gt;
&lt;td&gt;192 MB&lt;/td&gt;
&lt;td&gt;100m&lt;/td&gt;
&lt;td&gt;emptyDir, no persistence&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Casual&lt;/td&gt;
&lt;td&gt;1Gi&lt;/td&gt;
&lt;td&gt;768 MB&lt;/td&gt;
&lt;td&gt;250m&lt;/td&gt;
&lt;td&gt;10Gi PVC, AOF&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;4Gi&lt;/td&gt;
&lt;td&gt;3000 MB&lt;/td&gt;
&lt;td&gt;1000m&lt;/td&gt;
&lt;td&gt;20Gi PVC, AOF&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;maxmemory&lt;/code&gt; sits at about 70% of the container limit.&lt;/strong&gt; An AOF rewrite forks the process, and copy-on-write can touch a lot of pages under write load. Set &lt;code&gt;maxmemory&lt;/code&gt; close to the limit, and the rewrite is what gets you OOM-killed. A unit test fails if any plan goes above 80%, or if its storage is under 5x &lt;code&gt;maxmemory&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readiness and liveness ask different questions.&lt;/strong&gt; Readiness runs &lt;code&gt;PING&lt;/code&gt; through the CLI and requires &lt;code&gt;PONG&lt;/code&gt;. Liveness is only a TCP check. While a large AOF loads, the server replies &lt;code&gt;LOADING&lt;/code&gt;, so the pod is correctly unready. If liveness used the same check, Kubernetes would kill the pod during a long load, and the next attempt would start over and die the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The probe authenticates through &lt;code&gt;REDISCLI_AUTH&lt;/code&gt;,&lt;/strong&gt; so the internal password never appears in the process arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring time-to-ready without lying
&lt;/h2&gt;

&lt;p&gt;The operator exposes &lt;code&gt;kvnest_instance_time_to_ready_seconds{engine, plan}&lt;/code&gt;, a histogram from CR creation to the first Ready condition. The hard part is observing each instance only once across operator restarts and later pod restarts, since metrics live in memory and reconciles repeat.&lt;/p&gt;

&lt;p&gt;So the state lives on the object, in an annotation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first reconcile sets &lt;code&gt;kvnest.com/time-to-ready: pending&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Once the stored status says Ready, the operator replaces it with the measured duration, and then records the sample.&lt;/li&gt;
&lt;li&gt;Instances that predate the metric never get &lt;code&gt;pending&lt;/code&gt;, so they're never observed with a made-up value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ordering is intentional. Annotating first means a failed write retries without a duplicate sample, and the worst case is losing one sample. For a number we'd quote publicly, undercounting beats double-counting. The duration comes from the persisted &lt;code&gt;lastTransitionTime&lt;/code&gt;, and a merge patch keeps the annotation from bumping &lt;code&gt;generation&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# p95 (use 0.5 for p50) over the last 30 days
histogram_quantile(0.95, sum by (le) (rate(kvnest_instance_time_to_ready_seconds_bucket[30d])))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-instance metrics come from the &lt;code&gt;redis_exporter&lt;/code&gt; sidecar through one &lt;code&gt;PodMonitor&lt;/code&gt; that spans all namespaces and relabels each series with the instance name. The exporter only reads server stats, never keys or values.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we test it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure builders get unit tests.&lt;/strong&gt; The functions that render config, quotas, policies and routes take a plan and a spec and return data, so tests can assert invariants like "the quota fits the pod."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reconciler runs against envtest,&lt;/strong&gt; a real API server and etcd. Eighteen specs cover things like restoring a deleted child, moving old pods to new settings, and rejecting a plan change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Envtest has no kubelet, so pods never run.&lt;/strong&gt; Those specs mark readiness by hand and use a fake ACL syncer, which leaves a gap. A separate test runs the real syncer against a live server, including checks that the &lt;code&gt;default&lt;/code&gt; user gets &lt;code&gt;NOPERM&lt;/code&gt; on &lt;code&gt;ACL SETUSER&lt;/code&gt; and that read-only users can't write. It only runs when you point it at a server, so it isn't part of the default CI run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;If you're evaluating this as an SRE, here's what we'd want to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single node.&lt;/strong&gt; Each instance is one replica with no failover. A node failure or pod restart is an outage for that instance, and on Starter it's also data loss.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups during the preview, and no SLA yet.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the CR deletes the data,&lt;/strong&gt; volume included. Nothing is retained.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No plan resizing.&lt;/strong&gt; &lt;code&gt;spec.plan&lt;/code&gt; is immutable for now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS ends at Traefik.&lt;/strong&gt; Traffic from Traefik to the pod is plain TCP inside the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation is at the namespace and container level.&lt;/strong&gt; There's no sandboxed runtime, and tenants don't get dedicated nodes. The operator supports a tenant node pool, but it isn't enabled yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The operator is part of security enforcement.&lt;/strong&gt; If it's down, running instances keep serving, but a password in its grace period won't be revoked until the operator is back. The server only drops it when told to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are real limits, and some workloads should go elsewhere because of them. They also explain why the operator is small: most of the complexity in a database operator comes from replication and failover, and we don't have those yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd reuse elsewhere
&lt;/h2&gt;

&lt;p&gt;If you're building an operator for something stateful, these are the ideas we'd take with us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide which changes are allowed to restart the workload,&lt;/strong&gt; then enforce it in what you hash.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't equate a ready replica with a finished rollout.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Report "serving" and "converged" as separate signals.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give your own tooling its own identity,&lt;/strong&gt; so customer actions can't break your probes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep exactly-once state on the object,&lt;/strong&gt; not in the process.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can try it on the &lt;a href="https://kvnest.com" rel="noopener noreferrer"&gt;free Starter plan&lt;/a&gt;, with no card required. Connect with &lt;code&gt;valkey-cli --tls&lt;/code&gt; and see for yourself.&lt;/p&gt;

&lt;p&gt;How do you handle credential rotation for stateful workloads on Kubernetes? We'd especially like to hear from anyone who has run a Redis or Valkey operator with replication in production, since that's the part we haven't built yet.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>devops</category>
      <category>sre</category>
    </item>
  </channel>
</rss>
