<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Parker Jones Dev Blog - security</title>
    <subtitle>Dev Blog of Parker Jones</subtitle>
    <link rel="self" type="application/atom+xml" href="https://parkerjones.dev/tags/security/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://parkerjones.dev"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-06-26T00:00:00+00:00</updated>
    <id>https://parkerjones.dev/tags/security/atom.xml</id>
    <entry xml:lang="en">
        <title>Nix Fleet, Part 3: Fleet-Wide Secrets with agenix</title>
        <published>2026-06-26T00:00:00+00:00</published>
        <updated>2026-06-26T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://parkerjones.dev/posts/nix-fleet-agenix-secrets/"/>
        <id>https://parkerjones.dev/posts/nix-fleet-agenix-secrets/</id>
        
        <content type="html" xml:base="https://parkerjones.dev/posts/nix-fleet-agenix-secrets/">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nix Fleet, part 3.&lt;&#x2F;strong&gt; &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-one-flake&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt; covered the architecture; &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-remote-builders&#x2F;&quot;&gt;Part 2&lt;&#x2F;a&gt; made builds painless. This part handles the thing you must &lt;em&gt;not&lt;&#x2F;em&gt; commit to a public config repo: secrets.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;My whole system is declared in a Git repository, and most of it is public. But a fleet needs secrets — API keys, SMB credentials, a WireGuard config — and those obviously can&#x27;t sit in plaintext next to the rest of the config. The constraint that shapes everything here: I want secrets to be &lt;strong&gt;versioned, shared across every machine, and reproducible&lt;&#x2F;strong&gt;, with the same Git-reviewable discipline as the rest of the system — without any secret ever appearing in plaintext, in the repo, or in the Nix store.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ryantm&#x2F;agenix&quot;&gt;agenix&lt;&#x2F;a&gt; gets me there. Here&#x27;s the setup.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-repos-one-boundary&quot;&gt;Two repos, one boundary&lt;&#x2F;h2&gt;
&lt;p&gt;The cleanest decision I made was to put secrets in their &lt;strong&gt;own separate repo&lt;&#x2F;strong&gt; (&lt;code&gt;nix-secrets&lt;&#x2F;code&gt;) and pull it into the system flake as an input. I keep my real one private, but I&#x27;ve published a &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;parallaxisjones&#x2F;nix-secrets&quot;&gt;public example repo&lt;&#x2F;a&gt; so you can see the shape:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;nix&quot;&gt;# flake.nix
inputs.secrets = {
  url = &amp;quot;git+ssh:&#x2F;&#x2F;git@github.com&#x2F;parallaxisjones&#x2F;nix-secrets.git&amp;quot;;
  flake = false;
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That boundary pays off operationally: I can lock or revoke access to the secrets repo independently of the (public) config, rotate recipients without touching application code, and grant a machine read-only deploy access to just the secrets it needs. The public config can stay public because the sensitive bits live behind a separate door.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-agenix-encrypts&quot;&gt;How agenix encrypts&lt;&#x2F;h2&gt;
&lt;p&gt;agenix is &lt;code&gt;age&lt;&#x2F;code&gt; encryption wired into Nix. You declare, per secret, &lt;em&gt;which identities are allowed to decrypt it&lt;&#x2F;em&gt;. That declaration lives in &lt;code&gt;secrets.nix&lt;&#x2F;code&gt; in the private repo:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;nix&quot;&gt;let
  # macOS (pjones) and NixOS (parallaxis) decrypt with the
  # same ~&#x2F;.ssh&#x2F;parallaxis identity.
  pjones = &amp;quot;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...oqt7Aq&amp;quot;;
  users  = [ pjones ];
in
{
  &amp;quot;openai-key.age&amp;quot;.publicKeys        = users;
  &amp;quot;anthropic-api-key.age&amp;quot;.publicKeys = users;
  &amp;quot;datadog-api-key.age&amp;quot;.publicKeys   = users;
  &amp;quot;datadog-app-key.age&amp;quot;.publicKeys   = users;
  &amp;quot;smb-credentials.age&amp;quot;.publicKeys   = users;
  &amp;quot;protonvpn-wireguard.age&amp;quot;.publicKeys = users;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each &lt;code&gt;.age&lt;&#x2F;code&gt; file is encrypted to the public keys listed. The matching private identity — and &lt;em&gt;only&lt;&#x2F;em&gt; that identity — can decrypt it. The encrypted files are safe to commit (to the private repo); the identity never is.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;one-identity-for-the-whole-fleet&quot;&gt;One identity for the whole fleet&lt;&#x2F;h2&gt;
&lt;p&gt;Notice that &lt;code&gt;users&lt;&#x2F;code&gt; is a single key. That&#x27;s deliberate: &lt;strong&gt;both my macOS and NixOS machines decrypt with the same &lt;code&gt;~&#x2F;.ssh&#x2F;parallaxis&lt;&#x2F;code&gt; ed25519 identity.&lt;&#x2F;strong&gt; agenix can use an &lt;code&gt;age&lt;&#x2F;code&gt;-native key (&lt;code&gt;age-keygen&lt;&#x2F;code&gt;) or reuse an existing SSH ed25519 key via &lt;code&gt;age-plugin-ssh&lt;&#x2F;code&gt;; I reuse the SSH key so there&#x27;s exactly one secret-decrypting identity to manage across the fleet.&lt;&#x2F;p&gt;
&lt;p&gt;The trade-off is real and worth stating: one identity is one thing to protect and one thing to rotate, but it&#x27;s also a single point of compromise. For a personal fleet that&#x27;s the right call — fewer keys means I actually keep the rotation story straight instead of letting five per-host keys drift. If this were a team, I&#x27;d encrypt each secret to multiple recipients (one per person&#x2F;host) so revoking one doesn&#x27;t force re-keying everyone. agenix supports exactly that; it&#x27;s just a longer &lt;code&gt;publicKeys&lt;&#x2F;code&gt; list.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-decryption-works-at-runtime&quot;&gt;How decryption works at runtime&lt;&#x2F;h2&gt;
&lt;p&gt;This is the part that makes agenix better than &quot;stash a &lt;code&gt;.env&lt;&#x2F;code&gt; somewhere.&quot; At activation time, NixOS&#x2F;nix-darwin decrypts each declared secret to a path under &lt;code&gt;&#x2F;run&lt;&#x2F;code&gt; (tmpfs), owned by the user&#x2F;service that needs it, with the permissions you specify. The decrypted value:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;never lands in the Nix store&lt;&#x2F;strong&gt; (which is world-readable and gets copied to your binary cache),&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;never sits in the repo&lt;&#x2F;strong&gt; in plaintext,&lt;&#x2F;li&gt;
&lt;li&gt;exists only on the machine authorized to decrypt it, only while the system is running.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;So a service reads its API key from a file at &lt;code&gt;&#x2F;run&#x2F;agenix&#x2F;...&lt;&#x2F;code&gt;, that file only exists because &lt;em&gt;this&lt;&#x2F;em&gt; machine holds the identity that can decrypt it, and the policy for who-can-read-what is reviewable in &lt;code&gt;secrets.nix&lt;&#x2F;code&gt;. Secrets management becomes declarative and auditable, like the rest of the config.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bootstrapping-a-brand-new-machine&quot;&gt;Bootstrapping a brand-new machine&lt;&#x2F;h2&gt;
&lt;p&gt;The chicken-and-egg problem: a fresh machine needs secrets to be useful, but doesn&#x27;t yet have the identity to decrypt them. My flake exposes helper apps for exactly this lifecycle:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;text&quot;&gt;nix run .#create-keys          # generate keys on a new machine
nix run .#copy-keys            # place an identity where agenix expects it
nix run .#check-keys           # verify the identity can decrypt
nix run .#install-with-secrets # first install WITH secrets provisioned
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;install-with-secrets&lt;&#x2F;code&gt; is the interesting one. On a first NixOS install it runs &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-helios64-disko&#x2F;&quot;&gt;disko&lt;&#x2F;a&gt; to partition, stages the repo to the new root, and installs via the flake — and crucially, it relies on &lt;strong&gt;SSH agent forwarding&lt;&#x2F;strong&gt; so the installer can fetch the private &lt;code&gt;secrets&lt;&#x2F;code&gt; input without ever copying a key onto the installer media. Nothing persistent is left behind on the installer once it&#x27;s done. The machine comes up on first boot already matching repo state, with its secrets provisioned where they&#x27;re declared.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-beats-the-alternatives&quot;&gt;Why this beats the alternatives&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;vs. plaintext &lt;code&gt;.env&lt;&#x2F;code&gt; &#x2F; committed config:&lt;&#x2F;strong&gt; secrets are encrypted at rest, never in the repo, never in the store.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;vs. a cloud secrets manager:&lt;&#x2F;strong&gt; no external dependency, no per-machine bootstrapping against a third-party API, and the policy is in Git where I review everything else.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;vs. copying keys around by hand:&lt;&#x2F;strong&gt; the recipient list is declarative; adding or removing who-can-decrypt is a reviewable diff, not a tribal-knowledge ritual.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The throughline of this whole series is &quot;manage it declaratively, in Git, reproducibly.&quot; Secrets are the case where people usually give up and do something ad-hoc. agenix is how I kept them inside the same discipline as everything else. Next, in &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-helios64-disko&#x2F;&quot;&gt;Part 4&lt;&#x2F;a&gt;, I put all of this to work standing up an ARM NAS from bare metal — disko for the disks, agenix for the backup credentials, and &lt;code&gt;nixos-anywhere&lt;&#x2F;code&gt; to tie it together.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;A public example secrets repo is at &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;parallaxisjones&#x2F;nix-secrets&quot;&gt;&lt;code&gt;parallaxisjones&#x2F;nix-secrets&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;; the fleet config that consumes it is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;parallaxisjones&#x2F;dotfiles&quot;&gt;&lt;code&gt;parallaxisjones&#x2F;dotfiles&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;— Parker Jones, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;parkerjones.dev&quot;&gt;parkerjones.dev&lt;&#x2F;a&gt;&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
