<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Secrets on Canh Dinh</title><link>https://blog.canhdinh.com/tags/secrets/</link><description>Recent content in Secrets on Canh Dinh</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 28 Jun 2026 16:10:00 +0700</lastBuildDate><atom:link href="https://blog.canhdinh.com/tags/secrets/index.xml" rel="self" type="application/rss+xml"/><item><title>Seamless Secret Management in GitOps With SOPS and age</title><link>https://blog.canhdinh.com/posts/seamless-secret-management-with-sops-and-age/</link><pubDate>Sun, 28 Jun 2026 16:10:00 +0700</pubDate><guid>https://blog.canhdinh.com/posts/seamless-secret-management-with-sops-and-age/</guid><description>&lt;p&gt;GitOps has a famously awkward edge case: you want &lt;em&gt;everything&lt;/em&gt; in Git, but you can&amp;rsquo;t commit a plaintext database password to a repository. The usual workarounds — a separate secrets manager, a wall of &lt;code&gt;kubectl create secret&lt;/code&gt; commands, a shared password vault that nobody keeps in sync — all break the &amp;ldquo;Git is the source of truth&amp;rdquo; promise.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/getsops/sops"&gt;SOPS&lt;/a&gt; (Secrets OPerationS) and &lt;a href="https://github.com/FiloSottile/age"&gt;age&lt;/a&gt; solve this neatly. Together they let you commit &lt;strong&gt;encrypted&lt;/strong&gt; secrets straight into Git, review them in pull requests, and decrypt them only where and when they&amp;rsquo;re actually needed. The plaintext never touches the repo; the ciphertext lives right next to the code it belongs to.&lt;/p&gt;</description><content:encoded><![CDATA[<p>GitOps has a famously awkward edge case: you want <em>everything</em> in Git, but you can&rsquo;t commit a plaintext database password to a repository. The usual workarounds — a separate secrets manager, a wall of <code>kubectl create secret</code> commands, a shared password vault that nobody keeps in sync — all break the &ldquo;Git is the source of truth&rdquo; promise.</p>
<p><a href="https://github.com/getsops/sops">SOPS</a> (Secrets OPerationS) and <a href="https://github.com/FiloSottile/age">age</a> solve this neatly. Together they let you commit <strong>encrypted</strong> secrets straight into Git, review them in pull requests, and decrypt them only where and when they&rsquo;re actually needed. The plaintext never touches the repo; the ciphertext lives right next to the code it belongs to.</p>
<p>In this post I&rsquo;ll cover what each tool is, how the encryption actually works under the hood (including the role of public keys), why the combination fits GitOps so well, how it enables team collaboration, a fully runnable example using <a href="https://mise.jdx.dev/"><code>mise</code></a> to install both tools, and the limitations you should know before adopting it.</p>
<h2 id="what-are-sops-and-age">What are SOPS and age?</h2>
<p>They solve two different halves of the same problem.</p>
<p><strong>age</strong> is a modern, opinionated file-encryption tool — think &ldquo;GPG without the footguns&rdquo;. It has no configuration knobs, no cipher negotiation, and tiny keys. A public key looks like <code>age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</code> and a private key is a single line you can paste anywhere. age encrypts a whole file (or stdin) for one or more recipients. That&rsquo;s it.</p>
<p><strong>SOPS</strong> is an <em>editor</em> for structured secret files — YAML, JSON, ENV, INI, or binary. Instead of encrypting the whole file into an opaque blob, SOPS encrypts only the <strong>values</strong>, leaving keys, structure, and comments readable. It delegates the actual cryptography to a backend: AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault, PGP — or <strong>age</strong>.</p>
<p>The combination is powerful precisely because each tool stays in its lane: age provides simple, auditable encryption with portable keys, and SOPS provides a structure-aware, Git-friendly workflow on top of it. No cloud account required.</p>
<h2 id="how-it-works-internally">How it works internally</h2>
<p>This is the part worth understanding, because it explains every design decision that follows.</p>
<h3 id="age-envelope-encryption-with-x25519">age: envelope encryption with X25519</h3>
<p>age uses a classic <strong>envelope (hybrid) encryption</strong> scheme. When you encrypt a file for a recipient&rsquo;s public key:</p>
<ol>
<li>age generates a random 128-bit (16-byte) symmetric <strong>file key</strong> for this one file.</li>
<li>The file body is encrypted with a <strong>payload key</strong> derived from the file key (via <code>HKDF-SHA-256</code>, salted with a random nonce) using <strong>ChaCha20-Poly1305</strong>, an authenticated cipher (confidentiality <em>and</em> integrity). The body is split into 64 KiB chunks so it can be streamed.</li>
<li>For each recipient, age <strong>wraps</strong> (encrypts) the file key so only that recipient&rsquo;s private key can unwrap it. This wrapped copy is stored in a per-recipient <em>stanza</em> in the file header.</li>
</ol>
<p>The wrapping for an <code>age1...</code> recipient uses <strong>X25519</strong>, an Elliptic-Curve Diffie–Hellman function over Curve25519:</p>
<ul>
<li>age generates an <strong>ephemeral keypair</strong> just for this encryption.</li>
<li>It combines the ephemeral private key with the recipient&rsquo;s public key via Diffie–Hellman to derive a shared secret.</li>
<li>It runs that shared secret through <code>HKDF-SHA-256</code> to get a <em>wrap key</em>, which encrypts the file key into the recipient&rsquo;s stanza. The stanza also stores the ephemeral <em>public</em> key.</li>
</ul>
<p>To decrypt, the recipient combines their <strong>private</strong> key with the stored ephemeral public key, derives the <em>same</em> shared secret, unwraps the file key, and decrypts the body. The math of Diffie–Hellman guarantees both sides arrive at the same secret without it ever crossing the wire.</p>
<p>The key insight: <strong>the public key only lets you wrap (encrypt) the file key; it cannot unwrap it.</strong> So a public key is safe to share, commit, and put in a PR. You can encrypt <em>for</em> someone without being able to decrypt what you just wrote — and that&rsquo;s exactly what makes multi-recipient, GitOps-friendly secrets possible. Because the file key is wrapped once per recipient, encrypting for ten teammates just means ten small stanzas in front of one shared ciphertext body.</p>
<p>An age file header is plainly visible:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">-----BEGIN AGE ENCRYPTED FILE-----
</span></span><span class="line"><span class="cl">-&gt; X25519 kR7t8qV5zwYtVRhb...       # ephemeral pubkey + wrapped file key (recipient 1)
</span></span><span class="line"><span class="cl">-&gt; X25519 9aB2cD...                 # recipient 2
</span></span><span class="line"><span class="cl">--- &lt;header MAC, HMAC-SHA-256 over the header&gt;
</span></span><span class="line"><span class="cl">&lt;ciphertext body, ChaCha20-Poly1305 in 64 KiB chunks&gt;
</span></span><span class="line"><span class="cl">-----END AGE ENCRYPTED FILE-----
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="sops-a-data-key-on-top-of-age">SOPS: a data key on top of age</h3>
<p>SOPS adds one more layer so it can encrypt values <em>individually</em> while still only doing public-key crypto once:</p>
<ol>
<li>SOPS generates a random <strong>data key</strong> (an AES-256 key) for the file.</li>
<li>It encrypts <strong>each secret value</strong> in place with that data key using <strong>AES256-GCM</strong> — so <code>password: hunter2</code> becomes <code>password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]</code>. Keys and structure stay in cleartext, which is what makes diffs reviewable.</li>
<li>The data key itself is then encrypted <strong>for every configured recipient</strong>. With the age backend, that means handing the data key to age, which wraps it for each <code>age1...</code> public key and stores the resulting age blob in the file&rsquo;s <code>sops:</code> metadata block.</li>
<li>SOPS computes a <strong>MAC</strong> — a <code>SHA-512</code> hash over all the plaintext values — and stores it <em>encrypted with the data key</em> (AES256-GCM), which is the <code>mac: ENC[AES256_GCM,...]</code> field you&rsquo;ll see in the file. On decryption SOPS recomputes the hash over the decrypted values and compares; if they differ (e.g. someone added, removed, or swapped a ciphertext value), decryption fails.</li>
</ol>
<p>So there are two nested envelopes: <strong>age wraps the SOPS data key</strong> (per recipient, via X25519), and <strong>the data key encrypts each value</strong> (via AES-GCM). To decrypt, SOPS asks age to unwrap the data key with your private key, then decrypts every <code>ENC[...]</code> value and verifies the MAC.</p>
<p>You&rsquo;ll see this structure directly in an encrypted file&rsquo;s footer:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">sops</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">age</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">enc</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            -----BEGIN AGE ENCRYPTED FILE-----   # the data key, wrapped by age
</span></span></span><span class="line"><span class="cl"><span class="sd">            ...
</span></span></span><span class="line"><span class="cl"><span class="sd">            -----END AGE ENCRYPTED FILE-----</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">recipient</span><span class="p">:</span><span class="w"> </span><span class="l">age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">encrypted_regex</span><span class="p">:</span><span class="w"> </span><span class="l">^(data|stringData)$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mac</span><span class="p">:</span><span class="w"> </span><span class="l">ENC[AES256_GCM,data:...]                </span><span class="w"> </span><span class="c"># integrity check</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">3.13.1</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h2 id="why-this-combination-and-common-use-cases">Why this combination, and common use cases</h2>
<p>A few properties fall out of the design above:</p>
<ul>
<li><strong>Secrets live in Git.</strong> The ciphertext is committed alongside the manifests it configures. One repo, one source of truth — the GitOps ideal.</li>
<li><strong>Diffs stay meaningful.</strong> Because only values are encrypted, a PR shows <em>which</em> keys changed, even if it can&rsquo;t show the new plaintext. Reviewers see structure and intent.</li>
<li><strong>No central secret server required.</strong> Decryption needs only a private key file. Great for homelabs, edge, air-gapped, or &ldquo;I don&rsquo;t want to pay for a KMS&rdquo; setups. (You <em>can</em> still use KMS — SOPS supports mixing backends.)</li>
<li><strong>Asymmetric trust.</strong> Anyone with the public key can add or update secrets; only holders of a private key can read them. CI can encrypt without being able to decrypt.</li>
</ul>
<p>Typical use cases:</p>
<ul>
<li><strong>Kubernetes secrets in GitOps.</strong> Commit encrypted <code>Secret</code> manifests and let <a href="https://fluxcd.io/flux/guides/mozilla-sops/">Flux&rsquo;s SOPS integration</a> or the <a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/">SOPS operator / <code>ksops</code> for Argo CD</a> decrypt them in-cluster using a private key stored once as a cluster secret.</li>
<li><strong>App config / <code>.env</code> files.</strong> Encrypt <code>config.prod.yaml</code> or <code>.env.production</code> and decrypt at deploy time.</li>
<li><strong>Terraform / Ansible variables.</strong> Keep <code>secrets.auto.tfvars</code> or Ansible vault-style data encrypted in the repo.</li>
<li><strong>CI/CD pipelines.</strong> Store the age private key as a single CI secret; pipelines decrypt everything else from the repo on demand.</li>
</ul>
<h2 id="how-it-enables-team-collaboration">How it enables team collaboration</h2>
<p>This is age&rsquo;s quiet superpower. Because you encrypt for a <em>list</em> of public keys, onboarding a teammate is a metadata change, not a secret re-share.</p>
<ol>
<li>Each engineer (and each environment, and CI) generates their own age keypair and <strong>publishes only the public key</strong> — in the repo, a wiki, or chat. Private keys never leave their owner&rsquo;s machine.</li>
<li>A <code>.sops.yaml</code> file in the repo lists which public keys may decrypt which paths.</li>
<li>To add a new member, you append their public key to <code>.sops.yaml</code> and run <code>sops updatekeys</code> on the affected files. SOPS unwraps the data key, re-wraps it for the new recipient list, and writes the file back — <strong>without ever exposing the plaintext values</strong>. The change is a reviewable diff in the <code>sops:</code> block.</li>
<li>To off-board someone, remove their key and run <code>updatekeys</code> again, then rotate the underlying secrets.</li>
</ol>
<p>You can even encrypt to <em>different</em> recipient sets per path — say, dev secrets readable by the whole team but prod secrets restricted to the CI key and two leads — all declared in one <code>.sops.yaml</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">creation_rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">path_regex</span><span class="p">:</span><span class="w"> </span><span class="l">secrets/dev/.*\.ya?ml$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">age</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;age1dev...,age1alice...,age1bob...&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">path_regex</span><span class="p">:</span><span class="w"> </span><span class="l">secrets/prod/.*\.ya?ml$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">age</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;age1ci...,age1lead...&#34;</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>No shared master password, no &ldquo;DM me the prod creds&rdquo;, no secret that&rsquo;s only in one person&rsquo;s head.</p>
<h2 id="a-runnable-example">A runnable example</h2>
<p>Let&rsquo;s encrypt a Kubernetes <code>Secret</code> end to end. We&rsquo;ll use <a href="https://mise.jdx.dev/"><code>mise</code></a> to install <code>sops</code> and <code>age</code> so the versions are pinned and reproducible. (New to <code>mise</code>? See my <a href="../getting-started-with-mise/">getting started post</a>.)</p>
<h3 id="1-create-the-project-and-install-the-tools">1. Create the project and install the tools</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir sops-age-demo <span class="o">&amp;&amp;</span> <span class="nb">cd</span> sops-age-demo
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># install both tools, pinned in mise.toml</span>
</span></span><span class="line"><span class="cl">mise use sops@latest age@latest
</span></span></code></pre></td></tr></table>
</div>
</div><p>This writes a <code>mise.toml</code> and installs the binaries:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># mise.toml</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">tools</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">age</span> <span class="p">=</span> <span class="s2">&#34;latest&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">sops</span> <span class="p">=</span> <span class="s2">&#34;latest&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Confirm they&rsquo;re active:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops --version    <span class="c1"># sops 3.13.1 (latest)</span>
</span></span><span class="line"><span class="cl">mise <span class="nb">exec</span> -- age --version     <span class="c1"># v1.3.1</span>
</span></span></code></pre></td></tr></table>
</div>
</div><blockquote>
<p>Pin real versions (e.g. <code>sops@3.13.1</code>, <code>age@1.3.1</code>) and commit a <code>mise.lock</code> if you want byte-for-byte reproducibility — see the <a href="https://mise.jdx.dev/configuration/settings.html#lockfile">mise lockfile docs</a>.</p>
</blockquote>
<h3 id="2-generate-an-age-keypair">2. Generate an age keypair</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- age-keygen -o keys.txt
</span></span><span class="line"><span class="cl"><span class="c1"># Public key: age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>keys.txt</code> holds your <strong>private</strong> key — never commit it. The public key is printed and also stored as a comment inside the file. You can re-derive the public key from the private file at any time:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- age-keygen -y keys.txt
</span></span><span class="line"><span class="cl"><span class="c1"># age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Tell SOPS where your private key lives (SOPS reads this env var when decrypting):</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">SOPS_AGE_KEY_FILE</span><span class="o">=</span><span class="nv">$PWD</span>/keys.txt
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="3-declare-encryption-rules-in-sopsyaml">3. Declare encryption rules in <code>.sops.yaml</code></h3>
<p>Put your <strong>public</strong> key here. The <code>encrypted_regex</code> tells SOPS to only encrypt values under <code>data</code>/<code>stringData</code>, leaving the rest of the manifest readable:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .sops.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">creation_rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">path_regex</span><span class="p">:</span><span class="w"> </span><span class="l">secrets/.*\.ya?ml$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">encrypted_regex</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;^(data|stringData)$&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">age</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h3 id="4-write-and-encrypt-a-secret">4. Write and encrypt a secret</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p secrets
</span></span><span class="line"><span class="cl">cat &gt; secrets/db.yaml <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">apiVersion: v1
</span></span></span><span class="line"><span class="cl"><span class="s">kind: Secret
</span></span></span><span class="line"><span class="cl"><span class="s">metadata:
</span></span></span><span class="line"><span class="cl"><span class="s">  name: db-credentials
</span></span></span><span class="line"><span class="cl"><span class="s">stringData:
</span></span></span><span class="line"><span class="cl"><span class="s">  username: app
</span></span></span><span class="line"><span class="cl"><span class="s">  password: s3cr3t-p@ssw0rd
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops encrypt --in-place secrets/db.yaml
</span></span></code></pre></td></tr></table>
</div>
</div><p>The result is safe to commit. Note that <code>metadata</code> and the keys stay readable — only the values are <code>ENC[...]</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Secret</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">db-credentials</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">stringData</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l">ENC[AES256_GCM,data:/UNE,iv:JpHY...,tag:pEdX...,type:str]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">password</span><span class="p">:</span><span class="w"> </span><span class="l">ENC[AES256_GCM,data:lk6v7...,iv:NflK...,tag:d9sz...,type:str]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">sops</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">age</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">enc</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            -----BEGIN AGE ENCRYPTED FILE-----
</span></span></span><span class="line"><span class="cl"><span class="sd">            ...the data key, wrapped for your public key...
</span></span></span><span class="line"><span class="cl"><span class="sd">            -----END AGE ENCRYPTED FILE-----</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">recipient</span><span class="p">:</span><span class="w"> </span><span class="l">age1nwhnh2qv6yealq4npum4tlzl0uyev5haa7y355znqjhwuxu8l3qsc4h8mc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">encrypted_regex</span><span class="p">:</span><span class="w"> </span><span class="l">^(data|stringData)$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mac</span><span class="p">:</span><span class="w"> </span><span class="l">ENC[AES256_GCM,data:...]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">3.13.1</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h3 id="5-decrypt-edit-and-extract">5. Decrypt, edit, and extract</h3>
<p>Decrypt the whole file (needs your private key via <code>SOPS_AGE_KEY_FILE</code>):</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops decrypt secrets/db.yaml
</span></span><span class="line"><span class="cl"><span class="c1"># ...stringData.password: s3cr3t-p@ssw0rd</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Edit in place — SOPS decrypts into a temp editor session and re-encrypts on save:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops edit secrets/db.yaml
</span></span></code></pre></td></tr></table>
</div>
</div><p>Pull out a single value (handy in deploy scripts):</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops decrypt --extract <span class="s1">&#39;[&#34;stringData&#34;][&#34;password&#34;]&#39;</span> secrets/db.yaml
</span></span><span class="line"><span class="cl"><span class="c1"># s3cr3t-p@ssw0rd</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Pipe a decrypted manifest straight to your cluster, so plaintext never lands on disk:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops decrypt secrets/db.yaml <span class="p">|</span> kubectl apply -f -
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="6-add-a-teammate-key-rotation">6. Add a teammate (key rotation)</h3>
<p>Append a second public key to the <code>age:</code> line in <code>.sops.yaml</code>, then re-wrap the data key for the new recipient list — no plaintext exposure:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mise <span class="nb">exec</span> -- sops updatekeys secrets/db.yaml
</span></span></code></pre></td></tr></table>
</div>
</div><p>The only change in the diff is a new stanza in the <code>sops:</code> block. The teammate can now decrypt with <em>their</em> private key, and the original <code>ENC[...]</code> values are untouched.</p>
<h3 id="7-what-to-commit-and-what-not-to">7. What to commit (and what not to)</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;keys.txt&#39;</span> &gt;&gt; .gitignore   <span class="c1"># NEVER commit private keys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git add mise.toml .sops.yaml secrets/db.yaml .gitignore
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;Add encrypted db credentials&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Commit the encrypted secret, the <code>.sops.yaml</code>, and <code>mise.toml</code>. Keep <code>keys.txt</code> out of Git — distribute private keys out-of-band (a password manager, your CI&rsquo;s secret store, or a sealed cluster secret).</p>
<h3 id="8-clean-up-optional">8. Clean up (optional)</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> .. <span class="o">&amp;&amp;</span> rm -rf sops-age-demo
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="limitations-and-gotchas">Limitations and gotchas</h2>
<p>No tool is free of trade-offs. Know these before you commit:</p>
<ul>
<li><strong>Key distribution is on you.</strong> age has no PKI, no web of trust, no revocation lists. Getting private keys safely onto machines/CI and proving a public key really belongs to a teammate is a manual, out-of-band process.</li>
<li><strong>Removing a recipient doesn&rsquo;t un-leak a secret.</strong> <code>sops updatekeys</code> stops <em>future</em> access, but anyone who already decrypted (or kept an old Git revision) still has the plaintext. Off-boarding means <strong>rotating the actual secret values</strong>, not just the keys.</li>
<li><strong>Git history is forever.</strong> If you ever commit a plaintext secret by mistake, it lives in history until you rewrite it. Encrypt <em>before</em> the first commit.</li>
<li><strong>Metadata isn&rsquo;t secret.</strong> Keys, structure, and comments stay in cleartext by design. Don&rsquo;t put sensitive data in a <em>key name</em> like <code>aws_root_password_for_acme_corp</code>. The value lengths are also roughly observable.</li>
<li><strong>Per-value encryption only suits structured files.</strong> SOPS shines on YAML/JSON/ENV. For arbitrary binaries it falls back to whole-file encryption, losing the nice diffs — at which point plain <code>age</code> may be simpler.</li>
<li><strong>Decryption needs the private key present at runtime.</strong> In Kubernetes that usually means storing the age key as an in-cluster secret for Flux/Argo to use — so your cluster&rsquo;s secret store becomes the new root of trust you must protect.</li>
<li><strong>No built-in auditing or rotation policy.</strong> Unlike a managed KMS, there&rsquo;s no access log, automatic rotation, or fine-grained IAM. You build process around it yourself. For high-compliance environments, SOPS can target KMS backends instead of (or alongside) age.</li>
<li><strong>Whitespace/formatting churn.</strong> SOPS rewrites files on encrypt and may normalize formatting, which can produce noisier diffs than expected.</li>
</ul>
<p>None of these are dealbreakers — they&rsquo;re the cost of a serverless, Git-native model. For most teams, homelabs, and GitOps setups, the simplicity is well worth it.</p>
<h2 id="wrapping-up">Wrapping up</h2>
<p>SOPS + age turns &ldquo;secrets in Git&rdquo; from an oxymoron into a clean workflow. age gives you small, portable keys and a dead-simple envelope-encryption scheme where public keys safely wrap a file key for any number of recipients; SOPS layers a structure-aware, per-value editor on top so your encrypted secrets diff and review like normal config. Pin both with <code>mise</code>, declare recipients in <code>.sops.yaml</code>, and your secrets become just another reviewable, version-controlled, GitOps-friendly file — with the plaintext staying exactly where it belongs: nowhere near the repo.</p>
<h2 id="useful-links">Useful links</h2>
<ul>
<li><a href="https://github.com/getsops/sops">SOPS — getsops/sops</a></li>
<li><a href="https://github.com/FiloSottile/age">age — FiloSottile/age</a></li>
<li><a href="https://age-encryption.org/v1">age design &amp; specification</a></li>
<li><a href="https://fluxcd.io/flux/guides/mozilla-sops/">Flux: Manage Kubernetes secrets with SOPS</a></li>
<li><a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/">Argo CD secret management</a></li>
<li><a href="../getting-started-with-mise/">Getting started with mise</a></li>
</ul>
]]></content:encoded></item></channel></rss>