<?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: Guy Weitzman</title>
    <description>The latest articles on DEV Community by Guy Weitzman (@guyweitzman).</description>
    <link>https://dev.to/guyweitzman</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%2F4041993%2F4eff41c2-5a54-47ed-b786-9632dd4cb182.png</url>
      <title>DEV Community: Guy Weitzman</title>
      <link>https://dev.to/guyweitzman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/guyweitzman"/>
    <language>en</language>
    <item>
      <title>Bootstrapping HashiCorp Vault on Kubernetes the production way</title>
      <dc:creator>Guy Weitzman</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:59:41 +0000</pubDate>
      <link>https://dev.to/guyweitzman/bootstrapping-hashicorp-vault-on-kubernetes-the-production-way-4364</link>
      <guid>https://dev.to/guyweitzman/bootstrapping-hashicorp-vault-on-kubernetes-the-production-way-4364</guid>
      <description>&lt;p&gt;Every "Vault on Kubernetes" tutorial gets you to a running pod in ten minutes. Almost none of them tell you that the pod you just started will lose every secret the moment it restarts, that its &lt;code&gt;Ready&lt;/code&gt; probe is lying to you, or that the auth setup you copied grants every workload in the cluster access to every secret in Vault.&lt;/p&gt;

&lt;p&gt;I run Vault on Kubernetes in production. The gap between a demo install and something you can put on-call for is almost entirely in the parts the tutorials skip: the unseal strategy, the identity model for workloads, the policy layout for multiple teams, and how you rotate a secret without a redeploy. The rest of this is the actual commands and output for each of those, and a checklist at the end to measure your own cluster against.&lt;/p&gt;

&lt;p&gt;Everything below was run on a local &lt;code&gt;kind&lt;/code&gt; cluster (Kubernetes 1.29.14) with the official HashiCorp Vault Helm chart &lt;code&gt;0.34.0&lt;/code&gt; (Vault &lt;code&gt;2.0.3&lt;/code&gt;). No dev mode, no shortcuts that don't survive a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dev mode is a trap
&lt;/h2&gt;

&lt;p&gt;The single most common way people get Vault onto Kubernetes is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;vault hashicorp/vault &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="s2"&gt;"server.dev.enabled=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev mode boots Vault already initialized, already unsealed, with an in-memory storage backend and a fixed root token. It is genuinely useful for a five-minute demo. It is also a trap, because it teaches you a mental model that is wrong in three ways that matter in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is unsealed automatically.&lt;/strong&gt; Real Vault comes up &lt;em&gt;sealed&lt;/em&gt;. Its storage is encrypted with a key that Vault itself does not have on disk. Until you provide the unseal material, Vault can start, serve health checks, and do nothing else. Dev mode hides the single most important operational fact about Vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage is in memory.&lt;/strong&gt; Restart the pod and every secret, policy, and auth mount is gone. You will never notice in a demo and you will always notice in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The root token is printed to the log.&lt;/strong&gt; Fine for a laptop, a disaster anywhere else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So we skip dev mode. Everything dev mode does for you automatically is the part you actually have to get right in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the chart (without dev mode)
&lt;/h2&gt;

&lt;p&gt;Here is the install, and the one design decision baked into it. I run a single Vault node backed by &lt;strong&gt;integrated storage (Raft)&lt;/strong&gt;. Raft is what HashiCorp recommends for HA today, it keeps Vault's data on a PersistentVolume so it survives restarts, and it scales to a 3-node quorum by bumping one number. On a laptop I run one replica so the unseal flow is demonstrable without three times the memory. The unseal &lt;em&gt;mechanics&lt;/em&gt; are identical at one node or three.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;values.yaml&lt;/code&gt;:&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;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tlsDisable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;          &lt;span class="c1"&gt;# TLS is off for this walkthrough; see the checklist. false in prod&lt;/span&gt;

&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;standalone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;ha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;raft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;setNodeId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;ui = true&lt;/span&gt;
        &lt;span class="s"&gt;listener "tcp" {&lt;/span&gt;
          &lt;span class="s"&gt;tls_disable     = 1&lt;/span&gt;
          &lt;span class="s"&gt;address         = "[::]:8200"&lt;/span&gt;
          &lt;span class="s"&gt;cluster_address = "[::]:8201"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;storage "raft" {&lt;/span&gt;
          &lt;span class="s"&gt;path = "/vault/data"&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;service_registration "kubernetes" {}&lt;/span&gt;
  &lt;span class="na"&gt;dataStorage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;

&lt;span class="na"&gt;injector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;            &lt;span class="c1"&gt;# keep the focus on the k8s auth method itself&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add hashicorp https://helm.releases.hashicorp.com
helm &lt;span class="nb"&gt;install &lt;/span&gt;vault hashicorp/vault &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; vault &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 0.34.0 &lt;span class="nt"&gt;--values&lt;/span&gt; values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few seconds later the pod is running but &lt;strong&gt;not ready&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="go"&gt;NAME      READY   STATUS    RESTARTS   AGE
vault-0   0/1     Running   0          6s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0/1&lt;/code&gt; is intentional. Vault's readiness probe reports &lt;em&gt;not ready&lt;/em&gt; while it is sealed, and it is sealed because we have not initialized it yet. If you look at what the probe is actually checking, Vault tells you exactly what state it is in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Readiness probe failed:
Key                     Value
---                     -----
Seal Type               shamir
Initialized             false
Sealed                  true
Version                 2.0.3
Storage Type            raft
HA Enabled              true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Initialized: false, Sealed: true&lt;/code&gt;. Kubernetes will keep this pod out of the Service endpoints until Vault is unsealed, which means a sealed Vault takes itself out of rotation instead of serving errors. That's the behavior you want. Now initialize it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initializing and the unseal strategy
&lt;/h2&gt;

&lt;p&gt;Initialization generates Vault's &lt;strong&gt;root key&lt;/strong&gt; (the key that ultimately protects storage) and returns the &lt;strong&gt;unseal key&lt;/strong&gt; split into shares using Shamir's Secret Sharing, plus a root token. The shares reconstruct the unseal key, which decrypts the root key, which decrypts everything else. The two numbers you choose here are a real operational decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; vault vault-0 &lt;span class="nt"&gt;--&lt;/span&gt; vault operator init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-key-shares&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="nt"&gt;-key-threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-key-shares=5 -key-threshold=3&lt;/code&gt; means the key is split into 5 shares and any 3 of them reconstruct it. This is the classic setup for a reason: you can distribute one share each to five trusted operators, tolerate losing two of them (a lost laptop, someone on leave), and still never let a single person unseal Vault alone. One share is useless; three is a quorum. Choose these numbers to match your on-call reality, not the tutorial's.&lt;/p&gt;

&lt;p&gt;I ask for JSON output so the result is machine-parseable. The structure looks like this (values redacted - losing these means losing the data):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unseal_keys_b64"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;share 1&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;share 2&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;share 5&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unseal_keys_hex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unseal_shares"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unseal_threshold"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recovery_keys_b64"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"root_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s2"&gt;"hvs.&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This output appears exactly once and is never recoverable.&lt;/strong&gt; If you lose all copies of enough shares to reach the threshold, the data in Vault is cryptographically gone - there is no reset. In production these shares should be encrypted to individual operators' PGP keys (&lt;code&gt;vault operator init&lt;/code&gt; supports &lt;code&gt;-pgp-keys&lt;/code&gt;) so that no plaintext share ever touches the operator's terminal or your CI logs. For this walkthrough they live in a scratch file that is deleted with the cluster.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the pod is initialized but still sealed. We unseal by feeding it shares one at a time. Watch the progress counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 1&amp;gt;
&lt;span class="go"&gt;Sealed              true
Unseal Progress     1/3

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 2&amp;gt;
&lt;span class="go"&gt;Sealed              true
Unseal Progress     2/3

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 3&amp;gt;
&lt;span class="go"&gt;Sealed              false
HA Enabled          true
HA Mode             active
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third share crosses the threshold, Vault reconstructs the unseal key in memory, uses it to decrypt the root key, decrypts its storage, and comes alive. Confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault status
&lt;span class="go"&gt;Key                     Value
---                     -----
Seal Type               shamir
Initialized             true
Sealed                  false
Total Shares            5
Threshold               3
Storage Type            raft
Cluster Name            vault-cluster-4aa18d6b
HA Mode                 active
Active Since            2026-07-22T09:44:29Z
Raft Applied Index      38
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now the pod flips to ready on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="go"&gt;NAME      READY   STATUS    RESTARTS   AGE
vault-0   1/1     Running   0          8m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What "restart-safe" actually costs you
&lt;/h3&gt;

&lt;p&gt;Here is the fact that dev mode hides and that you must design around: &lt;strong&gt;Shamir unseal is manual.&lt;/strong&gt; The reconstructed key lives only in the pod's memory. Delete the pod - a node reboot, an eviction, a chart upgrade, a spot instance reclaim - and Vault comes back sealed. Watch it happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl delete pod vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="go"&gt;pod "vault-0" deleted

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get pod vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="go"&gt;NAME      READY   STATUS    RESTARTS   AGE
vault-0   0/1     Running   0          6s

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault status
&lt;span class="go"&gt;Initialized             true      &amp;lt;- data survived on the Raft PVC
Sealed                  true      &amp;lt;- but it will NOT unseal itself
Unseal Progress         0/3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Initialized: true&lt;/code&gt; (the data persisted, thanks to Raft on the PV) but &lt;code&gt;Sealed: true&lt;/code&gt;. Until a human - or automation - supplies three shares again, every secret in Vault is unreadable and every workload depending on it is broken. Re-supplying the three shares brings it straight back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 1&amp;gt;   &lt;span class="c"&gt;# 1/3&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 2&amp;gt;   &lt;span class="c"&gt;# 2/3&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault operator unseal &amp;lt;share 3&amp;gt;
&lt;span class="go"&gt;Sealed        false
HA Mode       active
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the crux of your unseal-strategy decision, and it is a real fork:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual Shamir + operators on call&lt;/strong&gt; - most secure (no external system holds your key), most painful (a 3 AM reboot pages a human to type shares).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KMS auto-unseal&lt;/strong&gt; - a &lt;code&gt;seal&lt;/code&gt; stanza points at a cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key Vault) or a Transit engine on another Vault. The KMS wraps Vault's root key directly; Vault unwraps it on boot and a restarted pod unseals itself in seconds with no human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under auto-unseal there is no Shamir unseal key at all - that is the whole point. Instead Vault issues &lt;strong&gt;recovery keys&lt;/strong&gt; (also Shamir-split) that authorize break-glass operations like root-token generation and rekey, rather than unsealing. That is also why the &lt;code&gt;recovery_keys_b64&lt;/code&gt; array was empty in our Shamir init above: it only populates under auto-unseal. Migrating an already-initialized Shamir cluster to a KMS seal is a deliberate, one-node-at-a-time operation using &lt;code&gt;vault operator unseal -migrate&lt;/code&gt;, after which the old unseal shares become the recovery keys.&lt;/p&gt;

&lt;p&gt;Auto-unseal needs cloud credentials, so it is out of scope to configure here - but decide between these two &lt;em&gt;before&lt;/em&gt; you go to production, not after your first unplanned reboot. The &lt;code&gt;seal&lt;/code&gt; stanza goes in the same &lt;code&gt;raft&lt;/code&gt; config block shown above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workload identity: the Kubernetes auth method
&lt;/h2&gt;

&lt;p&gt;Vault is now running, unsealed, and restart-aware. It holds nothing useful yet, and no workload can talk to it. The next three sections build that out: how a pod proves who it is, what each identity is allowed to read, and how you rotate what is inside.&lt;/p&gt;

&lt;p&gt;A workload should never carry a long-lived Vault token in an environment variable or a mounted file. The whole reason to run Vault on Kubernetes is that Kubernetes already issues every pod a verifiable identity - its ServiceAccount token - and Vault can trust that identity directly. That is the &lt;strong&gt;Kubernetes auth method&lt;/strong&gt;: a pod presents its ServiceAccount JWT, Vault asks the Kubernetes API "is this token real?" via the TokenReview API, and if so it mints a short-lived Vault token scoped to whatever policy you mapped that ServiceAccount to.&lt;/p&gt;

&lt;p&gt;Enable it and point Vault at the in-cluster API server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault auth &lt;span class="nb"&gt;enable &lt;/span&gt;kubernetes
&lt;span class="go"&gt;Success! Enabled kubernetes auth method at: kubernetes/

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault write auth/kubernetes/config &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    kubernetes_host=https://$&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;KUBERNETES_PORT_443_TCP_ADDR&lt;span class="o"&gt;}&lt;/span&gt;:443
&lt;span class="go"&gt;Success! Data written to: auth/kubernetes/config
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I passed no CA certificate and no reviewer token, on purpose. Running in-cluster, Vault reads the CA and its own ServiceAccount token from the standard in-pod mount (&lt;code&gt;/var/run/secrets/kubernetes.io/serviceaccount/&lt;/code&gt;) and uses its own token as the reviewer JWT to call TokenReview. Reading the config back confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault &lt;span class="nb"&gt;read &lt;/span&gt;auth/kubernetes/config
&lt;span class="go"&gt;Key                        Value
---                        -----
disable_local_ca_jwt       false
kubernetes_host            https://10.96.0.1:443
&lt;/span&gt;&lt;span class="gp"&gt;token_reviewer_jwt_set     false            &amp;lt;- no reviewer token passed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Vault uses its own
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Vault to be &lt;em&gt;allowed&lt;/em&gt; to call TokenReview, its own ServiceAccount needs the &lt;code&gt;system:auth-delegator&lt;/code&gt; ClusterRole. The Helm chart wires this up for you - it is worth knowing it exists, because if you deploy Vault without the chart this is the RBAC people forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get clusterrolebindings | &lt;span class="nb"&gt;grep &lt;/span&gt;vault
&lt;span class="go"&gt;vault-server-binding   ClusterRole/system:auth-delegator   19m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Policy layout for a multi-team cluster
&lt;/h2&gt;

&lt;p&gt;This is where most setups quietly become insecure. It is tempting to write one policy that grants broad access and map every workload to it. Do that and a compromised pod in one team can read every other team's secrets. The fix is &lt;strong&gt;path-scoped, least-privilege policies, one per workload identity&lt;/strong&gt;, laid out along a path convention you enforce cluster-wide.&lt;/p&gt;

&lt;p&gt;Pick a path layout up front. A common one is &lt;code&gt;secret/data/&amp;lt;team&amp;gt;/&amp;lt;app&amp;gt;/...&lt;/code&gt;. Then each team's policy only ever references its own subtree. Here is a read-only policy for a &lt;code&gt;payments&lt;/code&gt; team:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# payments-ro.hcl&lt;/span&gt;
&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="s2"&gt;"secret/data/payments/*"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;capabilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="s2"&gt;"secret/metadata/payments/*"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;capabilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"list"&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;Two details that trip people up on the KV v2 engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API path has &lt;code&gt;data/&lt;/code&gt; (and &lt;code&gt;metadata/&lt;/code&gt;) injected into it - &lt;code&gt;secret/payments/db&lt;/code&gt; on the CLI is really &lt;code&gt;secret/data/payments/db&lt;/code&gt; in a policy. Get this wrong and your reads 403 with a policy that &lt;em&gt;looks&lt;/em&gt; correct.&lt;/li&gt;
&lt;li&gt;Read and list live on separate paths. A workload that reads a known key needs only &lt;code&gt;data/...&lt;/code&gt;; a workload that enumerates keys needs &lt;code&gt;list&lt;/code&gt; on &lt;code&gt;metadata/...&lt;/code&gt;. Grant the narrower set.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault policy write payments-ro payments-ro.hcl
&lt;span class="go"&gt;Success! Uploaded policy: payments-ro

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault kv put secret/payments/db &lt;span class="nv"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments_app &lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3cr3t-from-vault
&lt;span class="go"&gt;======= Secret Path =======
secret/data/payments/db
Key                Value
---                -----
version            1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the binding - a Vault &lt;strong&gt;role&lt;/strong&gt; in the Kubernetes auth mount that says "the ServiceAccount &lt;code&gt;payments-api&lt;/code&gt; in namespace &lt;code&gt;apps&lt;/code&gt; gets the &lt;code&gt;payments-ro&lt;/code&gt; policy, and its tokens live for 20 minutes":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault write auth/kubernetes/role/payments-api &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    bound_service_account_names=payments-api \
    bound_service_account_namespaces=apps \
    policies=payments-ro \
    audience=vault \
    ttl=20m
Success! Data written to: auth/kubernetes/role/payments-api
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bound_service_account_names&lt;/code&gt; and &lt;code&gt;bound_service_account_namespaces&lt;/code&gt; are the security boundary. Only that exact ServiceAccount in that exact namespace can assume this role. A pod in a different namespace, or the same namespace with a different ServiceAccount, is rejected before any secret is in play. This is how you keep team A out of team B's secrets: their identities map to different roles, and each role maps to a policy scoped to a different path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it from a workload
&lt;/h2&gt;

&lt;p&gt;Now run a pod as the &lt;code&gt;payments-api&lt;/code&gt; ServiceAccount and have it authenticate with only its projected token, then read the secret.&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;v1&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;Pod&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;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&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;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;containers&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;app&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/vault:2.0.3&lt;/span&gt;
      &lt;span class="na"&gt;command&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;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3600"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;env&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;VAULT_ADDR&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://vault.vault.svc:8200"&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&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;vault-token&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/run/secrets/tokens&lt;/span&gt;
  &lt;span class="na"&gt;volumes&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;vault-token&lt;/span&gt;
      &lt;span class="na"&gt;projected&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;serviceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-token&lt;/span&gt;
              &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&lt;/span&gt;           &lt;span class="c1"&gt;# must match the role's audience&lt;/span&gt;
              &lt;span class="na"&gt;expirationSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use a &lt;strong&gt;projected ServiceAccount token with an explicit &lt;code&gt;audience: vault&lt;/code&gt;&lt;/strong&gt; rather than the legacy auto-mounted token. Audience-bound, short-lived tokens are the current best practice: the token is only valid for Vault, and it expires in ten minutes whether or not anyone uses it.&lt;/p&gt;

&lt;p&gt;Inside that pod, the login is one call. The workload reads its own token off disk and exchanges it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;JWT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/run/secrets/tokens/vault-token&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault write auth/kubernetes/login &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments-api &lt;span class="nv"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vault verifies the JWT against the Kubernetes API and returns a token. The metadata on that token is the proof that identity flowed correctly end to end (client token redacted):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"client_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"policies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payments-ro"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payments-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"service_account_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payments-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"service_account_namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apps"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lease_duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"renewable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;policies: [default, payments-ro]&lt;/code&gt;, stamped with the ServiceAccount's own name and namespace, leased for 1200 seconds (the 20m we set). Now use it to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;vault write &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;token auth/kubernetes/login &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments-api &lt;span class="nv"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault kv get secret/payments/db
&lt;span class="go"&gt;====== Data ======
Key         Value
---         -----
password    s3cr3t-from-vault
username    payments_app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workload never held a Vault credential of its own. It presented a Kubernetes identity and got exactly the access we granted that identity, nothing more. The denied case confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault kv get secret/other-team/db
&lt;span class="go"&gt;Error reading secret/data/other-team/db: Error making API request.
URL: GET http://vault.vault.svc:8200/v1/secret/data/other-team/db
Code: 403. Errors:

* permission denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;403 permission denied&lt;/code&gt; on a path outside its policy. The boundary is enforced by Vault, not by convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe secret rotation
&lt;/h2&gt;

&lt;p&gt;In KV v2 a write creates a new version instead of overwriting the old one, which is what makes rotation safe to do while workloads are live. Writing a new value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vault kv put secret/payments/db &lt;span class="nv"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments_app &lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;new-password&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...creates version 2 and leaves version 1 intact and readable at &lt;code&gt;-version=1&lt;/code&gt;. The safe rotation sequence for a database credential is therefore:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Provision the new credential on the backing system (create the new DB password) &lt;em&gt;before&lt;/em&gt; touching Vault.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vault kv put&lt;/code&gt; the new value - now version 2 exists, version 1 still works.&lt;/li&gt;
&lt;li&gt;Let workloads pick up version 2. If they read Vault on a schedule or on restart, this is automatic; if they cache at startup, roll them.&lt;/li&gt;
&lt;li&gt;Only once nothing is using version 1, revoke the old credential on the backing system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because both versions coexist, there is no window where the secret is half-rotated and something is broken. And if the new value is bad, &lt;code&gt;vault kv rollback -version=1&lt;/code&gt; restores the previous one instantly.&lt;/p&gt;

&lt;p&gt;Better still: stop rotating static secrets by hand. Vault's &lt;strong&gt;dynamic secrets&lt;/strong&gt; engines (database, AWS, PKI, and more) generate a fresh, short-lived credential per lease and revoke it automatically when the lease ends. A workload asks for a database credential, gets one that is valid for an hour, and Vault deletes it afterward. So instead of rotating a shared password every 90 days and hoping nothing cached it, every workload gets its own credential that expires in an hour. Static KV is the right tool for genuinely static config; dynamic secrets are the right tool for anything that grants access to another system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production checklist
&lt;/h2&gt;

&lt;p&gt;If you are holding a Vault install up against production, this is the line between a demo and something you can put on-call for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Not dev mode.&lt;/strong&gt; No &lt;code&gt;server.dev.enabled&lt;/code&gt;. Vault comes up sealed and persists to a real volume.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Integrated storage (Raft) or a real backend&lt;/strong&gt;, on a PersistentVolume, so restarts keep their data. For real HA, 3 or 5 Raft nodes, not 1.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;An unseal strategy you have decided on purpose:&lt;/strong&gt; manual Shamir with shares distributed to operators (PGP-encrypted at init), or KMS auto-unseal for hands-off restarts. Test a pod delete and confirm it recovers the way you expect.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Unseal shares and the root token stored offline and split&lt;/strong&gt;, never in CI logs or a wiki. The root token is revoked after setup (&lt;code&gt;vault token revoke&lt;/code&gt;) and re-minted only when needed.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;TLS enabled&lt;/strong&gt; end to end (&lt;code&gt;global.tlsDisable=false&lt;/code&gt; with real certs). This walkthrough left it off to stay focused; production does not.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Kubernetes auth method&lt;/strong&gt; for workload identity - no static Vault tokens in pods. Projected ServiceAccount tokens with an explicit audience.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;One least-privilege policy per workload identity&lt;/strong&gt;, scoped to a path convention (&lt;code&gt;secret/data/&amp;lt;team&amp;gt;/&amp;lt;app&amp;gt;/...&lt;/code&gt;). No shared broad-grant policy. Verify a cross-team read returns 403.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Short token TTLs&lt;/strong&gt; on auth roles so a leaked token expires on its own.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Audit device enabled&lt;/strong&gt; (&lt;code&gt;vault audit enable&lt;/code&gt;) so every access is logged - the first thing an incident responder will ask for.&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rotation done additively&lt;/strong&gt; (KV v2 versions), and dynamic secrets engines for anything that grants access to another system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Standing Vault up is the easy part. The unseal strategy, the identity model, the policy boundaries, and the rotation discipline are what decide whether it holds up at 3 AM. Get those right and you stop hoping Vault survives the next node reboot.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>vault</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
