Part 43: The Secrets Store — ISecretStore and Its Providers
"Every secret in DevLab passes through one interface. Five providers, one contract, zero plaintext on disk."
Why
DevLab has a lot of secrets. The GitLab root password. The Postgres password. The MinIO access key. The MinIO secret key. The Meilisearch master key. The PiHole admin password. The Vagrant registry token. The baget API key. The GitLab runner registration token. The TLS CA private key. The wildcard cert private key. The PiHole API token. The Bitwarden vault password (if Bitwarden is the secret backend). The age private key (if age is the backend). And so on.
There are three things that must not happen with these secrets:
- Plaintext on disk in the repo. No
secrets/postgres_passwordcommitted to git. Ever. - Plaintext in process environment. Or at least, very narrowly: only the process that needs the secret should see it, and only for as long as needed.
- Hand-rolled per-secret retrieval. Every secret going through the same interface means one place to audit, one place to test, one place to add a new backend.
The thesis of this part is: ISecretStore is a plugin contract with five built-in providers (file, age, sops, Vault, Bitwarden). Every secret in HomeLab passes through it. Distribution to consumers happens at three lifecycles: build time (Packer), boot time (Vagrant provisioning), runtime (compose). The provider is selected by config; switching providers does not require any consumer change.
The shape
public interface ISecretStore
{
string Name { get; }
Task<Result<string>> ReadAsync(string key, CancellationToken ct);
Task<Result> WriteAsync(string key, string value, CancellationToken ct);
Task<Result> DeleteAsync(string key, CancellationToken ct);
Task<Result<IReadOnlyList<string>>> ListAsync(CancellationToken ct);
}public interface ISecretStore
{
string Name { get; }
Task<Result<string>> ReadAsync(string key, CancellationToken ct);
Task<Result> WriteAsync(string key, string value, CancellationToken ct);
Task<Result> DeleteAsync(string key, CancellationToken ct);
Task<Result<IReadOnlyList<string>>> ListAsync(CancellationToken ct);
}Five methods, every one returning Result. No exceptions for missing keys; no nullable strings; no silent failures.
Provider 1: FileSecretStore (default, dev only)
[Injectable(ServiceLifetime.Singleton)]
public sealed class FileSecretStore : ISecretStore
{
public string Name => "file";
private readonly DirectoryInfo _root;
public FileSecretStore(IOptions<HomeLabConfig> config, IFileSystem fs)
{
_root = new DirectoryInfo(config.Value.Secrets?.FilePath ?? "./local/secrets");
if (!fs.Directory.Exists(_root.FullName))
fs.Directory.CreateDirectory(_root.FullName);
}
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var path = Path.Combine(_root.FullName, key);
if (!File.Exists(path))
return Result.Failure<string>($"secret not found: {key}");
var content = await File.ReadAllTextAsync(path, ct);
return Result.Success(content.Trim());
}
public async Task<Result> WriteAsync(string key, string value, CancellationToken ct)
{
var path = Path.Combine(_root.FullName, key);
await File.WriteAllTextAsync(path, value, ct);
// chmod 0600
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return Result.Success();
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class FileSecretStore : ISecretStore
{
public string Name => "file";
private readonly DirectoryInfo _root;
public FileSecretStore(IOptions<HomeLabConfig> config, IFileSystem fs)
{
_root = new DirectoryInfo(config.Value.Secrets?.FilePath ?? "./local/secrets");
if (!fs.Directory.Exists(_root.FullName))
fs.Directory.CreateDirectory(_root.FullName);
}
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var path = Path.Combine(_root.FullName, key);
if (!File.Exists(path))
return Result.Failure<string>($"secret not found: {key}");
var content = await File.ReadAllTextAsync(path, ct);
return Result.Success(content.Trim());
}
public async Task<Result> WriteAsync(string key, string value, CancellationToken ct)
{
var path = Path.Combine(_root.FullName, key);
await File.WriteAllTextAsync(path, value, ct);
// chmod 0600
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return Result.Success();
}
// ...
}FileSecretStore writes plaintext files into local/secrets/, which is .gitignored. It is the dev default. It is not secure for shared environments. The compose contributors mount these files into containers via secrets: blocks, so the plaintext never leaves the host's disk and never enters a container env var.
Provider 2: AgeSecretStore
[Injectable(ServiceLifetime.Singleton)]
public sealed class AgeSecretStore : ISecretStore
{
public string Name => "age";
private readonly AgeClient _age;
private readonly DirectoryInfo _vault;
private readonly string _identityFile; // ~/.age/identity.txt
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var encryptedPath = Path.Combine(_vault.FullName, $"{key}.age");
if (!File.Exists(encryptedPath))
return Result.Failure<string>($"secret not found: {key}");
var result = await _age.DecryptAsync(encryptedPath, _identityFile, ct);
return result.Map(stdout => stdout.Trim());
}
public async Task<Result> WriteAsync(string key, string value, CancellationToken ct)
{
var encryptedPath = Path.Combine(_vault.FullName, $"{key}.age");
var recipientFile = Path.Combine(Path.GetDirectoryName(_identityFile)!, "recipients.txt");
var result = await _age.EncryptAsync(value, recipientFile, encryptedPath, ct);
return result.Map();
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class AgeSecretStore : ISecretStore
{
public string Name => "age";
private readonly AgeClient _age;
private readonly DirectoryInfo _vault;
private readonly string _identityFile; // ~/.age/identity.txt
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var encryptedPath = Path.Combine(_vault.FullName, $"{key}.age");
if (!File.Exists(encryptedPath))
return Result.Failure<string>($"secret not found: {key}");
var result = await _age.DecryptAsync(encryptedPath, _identityFile, ct);
return result.Map(stdout => stdout.Trim());
}
public async Task<Result> WriteAsync(string key, string value, CancellationToken ct)
{
var encryptedPath = Path.Combine(_vault.FullName, $"{key}.age");
var recipientFile = Path.Combine(Path.GetDirectoryName(_identityFile)!, "recipients.txt");
var result = await _age.EncryptAsync(value, recipientFile, encryptedPath, ct);
return result.Map();
}
// ...
}age is a small, modern encryption tool with a clean CLI and a well-defined file format. The vault is a directory of .age files, decryptable only with the recipient's private key. The recipient public keys are committed to the repo; the private key is not.
Provider 3: SopsSecretStore
[Injectable(ServiceLifetime.Singleton)]
public sealed class SopsSecretStore : ISecretStore
{
public string Name => "sops";
private readonly SopsClient _sops;
private readonly string _vaultFile; // sops-encrypted YAML
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var result = await _sops.DecryptAsync(_vaultFile, format: "yaml", ct);
if (result.IsFailure) return result.Map<string>();
var doc = YamlDeserializer.Deserialize<Dictionary<string, string>>(result.Value);
return doc.TryGetValue(key, out var value)
? Result.Success(value)
: Result.Failure<string>($"secret not found: {key}");
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class SopsSecretStore : ISecretStore
{
public string Name => "sops";
private readonly SopsClient _sops;
private readonly string _vaultFile; // sops-encrypted YAML
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var result = await _sops.DecryptAsync(_vaultFile, format: "yaml", ct);
if (result.IsFailure) return result.Map<string>();
var doc = YamlDeserializer.Deserialize<Dictionary<string, string>>(result.Value);
return doc.TryGetValue(key, out var value)
? Result.Success(value)
: Result.Failure<string>($"secret not found: {key}");
}
// ...
}sops (Mozilla's Secret OPerationS) supports age, GPG, AWS KMS, GCP KMS, Azure Key Vault, and HashiCorp Vault as encryption backends. It works on whole YAML/JSON files, encrypting only the values, leaving keys readable. The team can review which keys exist in git diff without leaking the values.
Provider 4: VaultSecretStore
[Injectable(ServiceLifetime.Singleton)]
public sealed class VaultSecretStore : ISecretStore
{
public string Name => "vault";
private readonly IVaultApi _vault;
private readonly string _mount;
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var token = await _tokens.GetCurrentAsync(ct);
if (token.IsFailure) return token.Map<string>();
var result = await _vault.ReadSecretAsync($"{_mount}/data/{key}", token.Value, ct);
if (result.IsFailure) return result.Map<string>();
return Result.Success(result.Value.Data.Data["value"].ToString()!);
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class VaultSecretStore : ISecretStore
{
public string Name => "vault";
private readonly IVaultApi _vault;
private readonly string _mount;
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
var token = await _tokens.GetCurrentAsync(ct);
if (token.IsFailure) return token.Map<string>();
var result = await _vault.ReadSecretAsync($"{_mount}/data/{key}", token.Value, ct);
if (result.IsFailure) return result.Map<string>();
return Result.Success(result.Value.Data.Data["value"].ToString()!);
}
// ...
}Vault is the heavy option. It has its own ACLs, audit log, secret rotation, and database integration. HomeLab supports it but does not run it inside DevLab by default (HashiCorp Vault is a service in its own right, with its own state). For users who already have Vault, the provider lets HomeLab plug into it.
Provider 5: BitwardenSecretStore
[Injectable(ServiceLifetime.Singleton)]
public sealed class BitwardenSecretStore : ISecretStore
{
public string Name => "bitwarden";
private readonly BwClient _bw; // wraps the `bw` CLI
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
// The bw CLI reads from a logged-in vault; the user must have run `bw login` first
var result = await _bw.GetItemAsync(key, ct);
if (result.IsFailure) return result.Map<string>();
return Result.Success(result.Value.Login.Password);
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class BitwardenSecretStore : ISecretStore
{
public string Name => "bitwarden";
private readonly BwClient _bw; // wraps the `bw` CLI
public async Task<Result<string>> ReadAsync(string key, CancellationToken ct)
{
// The bw CLI reads from a logged-in vault; the user must have run `bw login` first
var result = await _bw.GetItemAsync(key, ct);
if (result.IsFailure) return result.Map<string>();
return Result.Success(result.Value.Login.Password);
}
// ...
}Bitwarden is the option for individual developers who already use it as their personal password manager. The bw CLI handles login, vault unlock, and item retrieval. HomeLab just calls it.
Build time (Packer)
Packer builds an Alpine box. Some secrets need to be baked into the box at build time — for example, the host's SSH public key for the vagrant user, or the timezone the user lives in. These are read from the secret store at Packer build time (which is generation time in HomeLab's pipeline) and written into the Packer scripts before packer build runs.
public async Task<Result> InjectBuildTimeSecretsAsync(PackerBundle bundle, CancellationToken ct)
{
var sshKey = await _secrets.ReadAsync("HOST_SSH_PUBLIC_KEY", ct);
if (sshKey.IsSuccess)
{
bundle.Variables["host_ssh_public_key"] = sshKey.Value;
}
return Result.Success();
}public async Task<Result> InjectBuildTimeSecretsAsync(PackerBundle bundle, CancellationToken ct)
{
var sshKey = await _secrets.ReadAsync("HOST_SSH_PUBLIC_KEY", ct);
if (sshKey.IsSuccess)
{
bundle.Variables["host_ssh_public_key"] = sshKey.Value;
}
return Result.Success();
}The secret is in the variable file only on the building machine. After the box is built, the Packer working directory is cleaned up. The secret is not in the final .box.
Boot time (Vagrant provisioning)
Some secrets need to be on the VM but only after it boots — for example, the GitLab runner token. These are written to the Vagrant synced folder by HomeLab before vagrant up, and a provisioning script reads them from /vagrant/secrets/... and applies them.
public async Task<Result> StageBootTimeSecretsAsync(DirectoryInfo workdir, CancellationToken ct)
{
var stagingDir = new DirectoryInfo(Path.Combine(workdir.FullName, "secrets-staging"));
if (!stagingDir.Exists) stagingDir.Create();
var keys = new[] { "GITLAB_RUNNER_TOKEN", "PIHOLE_API_TOKEN", "VAGRANT_REGISTRY_TOKEN" };
foreach (var key in keys)
{
var value = await _secrets.ReadAsync(key, ct);
if (value.IsFailure) continue; // optional secret
var path = Path.Combine(stagingDir.FullName, key);
await File.WriteAllTextAsync(path, value.Value, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
return Result.Success();
}public async Task<Result> StageBootTimeSecretsAsync(DirectoryInfo workdir, CancellationToken ct)
{
var stagingDir = new DirectoryInfo(Path.Combine(workdir.FullName, "secrets-staging"));
if (!stagingDir.Exists) stagingDir.Create();
var keys = new[] { "GITLAB_RUNNER_TOKEN", "PIHOLE_API_TOKEN", "VAGRANT_REGISTRY_TOKEN" };
foreach (var key in keys)
{
var value = await _secrets.ReadAsync(key, ct);
if (value.IsFailure) continue; // optional secret
var path = Path.Combine(stagingDir.FullName, key);
await File.WriteAllTextAsync(path, value.Value, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
return Result.Success();
}The staging dir is mounted by Vagrant at /vagrant/secrets-staging. A provisioning script copies the files into the right runtime locations and then deletes the staging dir.
Runtime (compose)
Most secrets end up in compose secrets: blocks, which Docker Compose mounts as files inside containers at /run/secrets/<name>. HomeLab populates the source files from the secret store just before compose up:
public async Task<Result> StageComposeSecretsAsync(ComposeFile compose, DirectoryInfo workdir, CancellationToken ct)
{
var secretsDir = new DirectoryInfo(Path.Combine(workdir.FullName, "secrets"));
if (!secretsDir.Exists) secretsDir.Create();
foreach (var (name, def) in compose.Secrets)
{
if (def.File is null) continue;
var key = name.ToUpperInvariant();
var value = await _secrets.ReadAsync(key, ct);
if (value.IsFailure) return value.Map();
var targetPath = Path.Combine(workdir.FullName, def.File.TrimStart('.', '/'));
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await File.WriteAllTextAsync(targetPath, value.Value, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(targetPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
return Result.Success();
}public async Task<Result> StageComposeSecretsAsync(ComposeFile compose, DirectoryInfo workdir, CancellationToken ct)
{
var secretsDir = new DirectoryInfo(Path.Combine(workdir.FullName, "secrets"));
if (!secretsDir.Exists) secretsDir.Create();
foreach (var (name, def) in compose.Secrets)
{
if (def.File is null) continue;
var key = name.ToUpperInvariant();
var value = await _secrets.ReadAsync(key, ct);
if (value.IsFailure) return value.Map();
var targetPath = Path.Combine(workdir.FullName, def.File.TrimStart('.', '/'));
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await File.WriteAllTextAsync(targetPath, value.Value, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(targetPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
return Result.Success();
}This runs in the Apply stage, just before docker compose up. The plaintext files exist on the host's disk for the brief window between staging and Docker reading them; immediately after, the Docker secrets primitive takes over (the files are mounted read-only into containers as in-memory tmpfs).
A more paranoid mode (for the hardened tier from Part 28) wipes the staging files immediately after compose up returns, leaving no plaintext on disk at all.
The wiring
The provider is selected by config:
services.AddSingleton<ISecretStore>(sp =>
{
var config = sp.GetRequiredService<IOptions<HomeLabConfig>>().Value;
return config.Secrets?.Provider switch
{
"file" => sp.GetRequiredService<FileSecretStore>(),
"age" => sp.GetRequiredService<AgeSecretStore>(),
"sops" => sp.GetRequiredService<SopsSecretStore>(),
"vault" => sp.GetRequiredService<VaultSecretStore>(),
"bitwarden" => sp.GetRequiredService<BitwardenSecretStore>(),
_ => sp.GetRequiredService<FileSecretStore>() // default
};
});services.AddSingleton<ISecretStore>(sp =>
{
var config = sp.GetRequiredService<IOptions<HomeLabConfig>>().Value;
return config.Secrets?.Provider switch
{
"file" => sp.GetRequiredService<FileSecretStore>(),
"age" => sp.GetRequiredService<AgeSecretStore>(),
"sops" => sp.GetRequiredService<SopsSecretStore>(),
"vault" => sp.GetRequiredService<VaultSecretStore>(),
"bitwarden" => sp.GetRequiredService<BitwardenSecretStore>(),
_ => sp.GetRequiredService<FileSecretStore>() // default
};
});Switching from file to age:
secrets:
provider: age
age:
identity_file: ~/.age/identity.txt
vault_dir: ./secrets-encryptedsecrets:
provider: age
age:
identity_file: ~/.age/identity.txt
vault_dir: ./secrets-encryptedThe vault directory is committed to the repo (it contains only encrypted files). The identity file is not. The team shares the encrypted vault; each member has their own identity that decrypts it.
The test
public sealed class SecretStoreTests
{
[Fact]
public async Task file_store_round_trips_a_secret()
{
var fs = new MockFileSystem();
var store = new FileSecretStore(Options.Create(new HomeLabConfig
{
Secrets = new() { Provider = "file", FilePath = "/secrets" }
}), fs);
await store.WriteAsync("test", "hello", default);
var result = await store.ReadAsync("test", default);
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be("hello");
}
[Fact]
public async Task age_store_decrypts_via_age_binary()
{
var age = new ScriptedAgeClient();
age.OnDecrypt("/vault/test.age", "/identity.txt", exitCode: 0, stdout: "hello\n");
var store = new AgeSecretStore(age, vaultDir: "/vault", identityFile: "/identity.txt");
var result = await store.ReadAsync("test", default);
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be("hello");
}
[Fact]
public async Task missing_secret_returns_failure_with_clear_message()
{
var fs = new MockFileSystem();
var store = new FileSecretStore(Options.Create(new HomeLabConfig
{
Secrets = new() { Provider = "file", FilePath = "/secrets" }
}), fs);
var result = await store.ReadAsync("nonexistent", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("secret not found"));
result.Errors.Should().Contain(e => e.Contains("nonexistent"));
}
}public sealed class SecretStoreTests
{
[Fact]
public async Task file_store_round_trips_a_secret()
{
var fs = new MockFileSystem();
var store = new FileSecretStore(Options.Create(new HomeLabConfig
{
Secrets = new() { Provider = "file", FilePath = "/secrets" }
}), fs);
await store.WriteAsync("test", "hello", default);
var result = await store.ReadAsync("test", default);
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be("hello");
}
[Fact]
public async Task age_store_decrypts_via_age_binary()
{
var age = new ScriptedAgeClient();
age.OnDecrypt("/vault/test.age", "/identity.txt", exitCode: 0, stdout: "hello\n");
var store = new AgeSecretStore(age, vaultDir: "/vault", identityFile: "/identity.txt");
var result = await store.ReadAsync("test", default);
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be("hello");
}
[Fact]
public async Task missing_secret_returns_failure_with_clear_message()
{
var fs = new MockFileSystem();
var store = new FileSecretStore(Options.Create(new HomeLabConfig
{
Secrets = new() { Provider = "file", FilePath = "/secrets" }
}), fs);
var result = await store.ReadAsync("nonexistent", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("secret not found"));
result.Errors.Should().Contain(e => e.Contains("nonexistent"));
}
}What this gives you that bash doesn't
A bash script that handles secrets is cat ~/.netrc plus export PG_PASSWORD=$(cat ~/.pg_pass) plus a .env file in the repo root that the developer "promised" not to commit. The first time someone fat-fingers git add -A, the password is on GitHub.
A typed ISecretStore with five providers gives you, for the same surface area:
- One contract for every secret in HomeLab
- Five backends selected by config: file (dev), age (team), sops (team with cloud), Vault (enterprise), Bitwarden (individual)
- Three lifecycle distribution paths (build, boot, runtime)
- Architecture tests that prevent plaintext secrets from being committed
- Tests that exercise each provider against fakes
The bargain pays back the first time you switch from file to age and watch the encrypted vault flow through CI without anyone touching plaintext.