Motivation

I run a small Kubernetes cluster managed declaratively with Talos Linux and kubenix. The repository that defines the cluster – every Helm deployment, Talos node configuration and ArgoCD application – is something I’d like to publish: it’s how I document my own setup to help myself remember what’s true. Other people might find the patterns useful.

The cluster also has secrets: a kubeconfig, a Hetzner Cloud API token, a talosconfig with a CA bundle inside it. Those obviously can’t go in the public repo. But without them, the infrastructure as code is incomplete.

A recent blog post by Isabel: NixOS and Secrets (May 8, 2026) encourages the use of sops-nix and also compares agenix. It discourages git-crypt and simply not committing your secrets to git, hoping you’ll remember how they get transferred around between machines.

I similarly did a Nix + secrets management deep dive when I started using Nix 3 years ago and found every solution difficult to use. If only Vimjoyer’s NixOS Secrets Management | SOPS-NIX video had been out then.

I want to show the world my kubenix config. I want to use it as documentation.

But I don’t want to publish my encrypted secrets!

I know, I know. They’re encrypted.

But I’ve worked with post-quantum cryptography, and I just don’t think writing your secrets in code on your outer wall is the same as not telling people your secrets. Call me paranoid. But a secret not shared is ultimately safer than one that is encrypted and shared.

So here are my design constraints:

  1. I want to commit my secrets, encrypted, to git.
  2. I want to publish everything but the secrets.
  3. I want the repository to be operational with minimal effort.

Two Nix flakes: a public one, and a private one

The structure I settled on is two flakes in each their repo:

  • kubenix-cluster/ – the public flake. flake.nix, services/*.nix, lib/*.nix. Safe to publish.
  • kubenix-secrets/ – the private secrets flake, also cloned as kubenix-cluster/secrets/ — a separate git repository nested inside, with its own flake.nix. Holds sops/age-encrypted YAML files. Never published.

The nested repo is wired into the parent as a git+file flake input. This keeps the integration inside Nix’s input system rather than in ad-hoc bash. The setup took a couple of evenings to land cleanly because Nix’s purity rules around git-backed flakes have a couple of edges.

The two flakes

The parent kubenix-cluster flake declares one extra input:

# flake.nix
kubenix-secrets.url = "git+file:./secrets";
kubenix-secrets.inputs.nixpkgs.follows = "nixpkgs";
kubenix-secrets.inputs.flake-parts.follows = "flake-parts";
kubenix-secrets.inputs.devshell.follows = "devshell";

(Sorry about the input deduplication; I swear it’s a phase in my life I’ll get past.)

The secrets/flake.nix exposes two things to consumers:

flake = {
  lib.secrets = {
    argocd-admin = ./secrets/argocd-admin.yaml;
    hcloud-token = ./secrets/hcloud-token.yaml;
    kubeconfig   = ./secrets/kubeconfig.yaml;
    talosconfig  = ./secrets/talosconfig.yaml;
    vault        = ./secrets/vault.yaml;
    # ...
  };

  flakeModules.default = {
    imports = [ inputs.devshell.flakeModule ];
    perSystem = { pkgs, system, ... }: {
      devshells.default.packages = [
        pkgs.sops
        pkgs.age
        inputs.sops-nix.packages.${system}.default
      ];
    };
  };
};
  1. lib.secrets is a map from logical name to file path. Consumers receive paths to encrypted files, never plaintext. That means the parent flake can reference inputs.kubenix-secrets.lib.secrets.kubeconfig at Nix-eval time without any decryption happening. Decryption is a runtime concern. This is important, as Isabel points out: assume that the Nix store is world readable.

  2. flakeModules.default is the parent’s hook for getting sops/age/sops-nix into its devshell. Importing it is one line in lib/secrets.nix:

    { inputs, ... }: {
      imports = [ inputs.kubenix-secrets.flakeModules.default ];
    }
    

    The parent repository doesn’t need to know which packages the secrets flake wants in the shell — it just imports the module and the right binaries appear.

Wrapping commands with sops

Every command that needs a decrypted artifact gets a thin devshell wrapper. The pattern:

{
  name = "kubectl";
  help = "kubectl with sops-decrypted kubeconfig";
  command = ''
    tmp=$(mktemp --tmpdir=/dev/shm kubeconfig.XXXXXX)
    trap 'rm -f "$tmp"' EXIT
    ${lib.getExe pkgs.sops} decrypt "${secrets.kubeconfig}" > "$tmp"
    KUBECONFIG="$tmp" ${lib.getExe pkgs.kubectl} "$@"
  '';
}

This needs a bit of unpacking:

  • ${secrets.kubeconfig} interpolates to a /nix/store/...-kubenix-secrets-source/secrets/kubeconfig.yaml path at Nix-eval time. The encrypted file is copied into the Nix store as part of evaluating the secrets flake, so the wrapper doesn’t depend on the working tree at runtime.
  • mktemp --tmpdir=/dev/shm keeps the decrypted plaintext on tmpfs — in RAM, never written to disk.
  • trap ... EXIT unlinks the plaintext the moment the wrapper exits, even on Ctrl-C or error.
  • ${lib.getExe pkgs.kubectl} bakes the absolute store path of kubectl into the wrapper. The wrapper does not depend on kubectl being on $PATH — useful when the wrapper is invoked from a script or hook that doesn’t have the devshell environment loaded.

The same shape applies to hcloud, hcloud-upload-image, and talosctl. For hcloud the wrapper extracts a single field from a YAML secret via yq:

command = ''
  token=$(${lib.getExe pkgs.sops} decrypt "${secrets.hcloud-token}" \
            | ${lib.getExe pkgs.yq-go} -r .hcloud_token)
  HCLOUD_TOKEN="$token" ${lib.getExe pkgs.hcloud} "$@"
'';

Worth noting: pkgs.kubectl is not in the devshell’s packages = [ ... ] list — only the wrapper exists. That way which kubectl always finds the wrapper, never a raw binary that would silently try to read ~/.kube/config and surprise me with stale credentials.

Some gotchas

The parent flake is git-backed (git+file:///.../kubenix-cluster). Nix enforces a rule for git flakes: every path the flake reads must be visible to git in the index. Untracked files are invisible at evaluation, even if they exist on disk.

I wanted to reference a private self-hosted remote copy of kubenix-secrets, i.e. git+ssh, but ran into problems with SSH keys and my AI agent’s sandbox: Using my bubblebox fork of numtide’s claudebox, my AI agent gets indirect access to project-local secrets via pre-authenticated commands like kubectl, talosctl and hcloud. But it doesn’t get free access to my SSH keys used outside of this project. (And yes, this does fall short of a certain standard: That the AI agent never touches a secret. I have an idea for that using doubly nested Linux user namespaces + a proxy service. But I need a basis first, and that’s this.)

The natural-looking declaration for the sub-repo is:

kubenix-secrets.url = "path:./secrets";

Nix rejects it:

error: Path 'secrets/flake.nix' in the repository ".../kubenix-cluster" is not tracked by Git.

To make it visible to Nix, run: git -C ".../kubenix-cluster" add "secrets/flake.nix"

But git add secrets/ doesn’t actually solve the problem. secrets/ is itself a git repository (it has its own .git), so git records a gitlink — the submodule SHA — not the file contents. Nix still can’t read through the parent’s index to the inner files. I’ve added something to the index, but not the something Nix needed.

The fix is to stop pretending secrets/ is a directory of files in the parent repo and treat it as what it actually is: a separate flake on the filesystem.

kubenix-secrets.url = "git+file:./secrets"; # or: "git+file:../kubenix-secrets"

With git+file: Nix evaluates the secrets repo via its own .git/HEAD. The parent’s index is irrelevant — there’s no cross-repo tracking check anymore. The trade-off is that untracked or unstaged changes inside secrets/ are invisible to Nix until they’re at least staged in the inner repo. In practice that’s the right default for a secrets workflow: rotation should be deliberate.

secrets-example/

The split into two repos is great for keeping plaintext keys out of git history, but it leaves a documentation gap: a reader who clones kubenix-cluster can see the wrappers reference secrets.kubeconfig, secrets.hcloud-token, etc., but they can’t see what those files actually look like, or where they sit on disk.

So alongside the real secrets/ repo I’m building a secrets-example/ directory in the parent — same set of filenames, same .sops.yaml, same directory layout, but encrypted to a throwaway age key checked into the repo. Anyone reading the public version can decrypt it locally, inspect the YAML structure, and understand the wiring.