This repository provides a reusable Flake Parts/NixOS module exposing a small helper API (my.secrets.*) to define Clan vars generators for shared, machine, and user secrets. It also ships:
- A tiny helper to expose a deployed user secret into a user-owned location at boot
- A JSON manifest emitted for each generator for external tooling
- Optional ACL automation to grant users read access to root-owned secret files without duplication
- Module export: drop-in
nixosModules.defaultfor easy reuse - Constructors:
my.secrets.mkSharedSecret,mkMachineSecret,mkUserSecret - Auto manifest:
/run/secrets[-for-users]/<name>/manifest.json - Optional discovery: import raw declarations from
vars/generatorsby tags - Expose to users:
my.secrets.exposeUserSecrets(preferred) orexposeUserSecret - Path helpers: reference deployed secret file paths directly from Nix
- Prompt types: per-file
promptType = "hidden" | "multiline-hidden" - Non-secret values: convenient accessors for files with
secret = false - ACL helpers: per-file
additionalReadersor manualallowReadAccess
Add this flake as an input and import the module on your host. Then define a simple secret using a constructor.
{
inputs.secrets-helper.url = "github:perstarkse/clan-vars-secrets-helper";
inputs.secrets-helper.inputs.nixpkgs.follows = "nixpkgs";
inputs.secrets-helper.inputs.flake-parts.follows = "flake-parts";
outputs = inputs@{ self, nixpkgs, secrets-helper, ... }:
{
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
secrets-helper.nixosModules.default
({ config, pkgs, ... }: {
my.secrets.declarations = [
(config.my.secrets.mkUserSecret {
name = "openai-api-key";
files.key = { mode = "0400"; neededFor = "users"; };
prompts.key.input = {
description = "OpenAI API key";
type = "hidden";
persist = true;
};
script = ''
cp "$prompts/key" "$out/key"
'';
})
];
# Example: wire the runtime file path into another module
services.my-service.settings.pass_file =
config.my.secrets.getPath "openai-api-key" "key";
})
];
};
};
}Choose the style that fits your workflow. You can mix them.
-
Inline with constructors (consuming module)
Define secrets where they are used with
mkUserSecret/mkMachineSecret/mkSharedSecret.my.secrets.declarations = [ (config.my.secrets.mkUserSecret { name = "surrealdb-credentials"; files.credentials = { mode = "0400"; neededFor = "users"; }; prompts.credentials.input = { description = "Content of the SurrealDB credentials environment file"; type = "hidden"; persist = true; }; script = '' cp "$prompts/credentials" "$out/credentials" ''; meta = { tags = [ "service" "surrealdb" ]; }; }) ];
- Pros: simplest wiring; sane defaults; auto manifest and prompt shaping
- Tags in
meta.tags: metadata only (do not affect inclusion)
-
Aggregated module (e.g.,
nixosModules.apiKeys)Group related secrets via constructors and import that module where needed.
- Pros: clear ownership, reuse across machines, per-secret
runtimeInputs - Tags: metadata only unless you add selection logic yourself
- Pros: clear ownership, reuse across machines, per-secret
-
Discovery by tags (raw declarations)
Place raw generator attrsets in a folder (default
vars/generators) and enable discovery:my.secrets.discover = { enable = true; dir = ./vars/generators; includeTags = [ "service" "surrealdb" ]; };
Example
vars/generators/surrealdb.nix:{ meta = { tags = [ "oumuamua" "service" "surrealdb" ]; }; "surrealdb-credentials" = { files.credentials = { mode = "0400"; neededFor = "users"; }; prompts.credentials.input = { description = "Content of the SurrealDB credentials environment file"; type = "hidden"; persist = true; }; script = '' cp "$prompts/credentials" "$out/credentials" ''; }; }
Notes:
- Tags in
meta.tagsmay be at the top level or inside a generator; both are recognized for filtering - Discovery strips
metabefore merging intoclan.core.vars.generatorsto match Clan’s schema - Raw declarations use your exact
scriptand do not get constructor wrapping (no automatic manifest/prompt defaults) unless you implement it
- Tags in
- Constructors wrap raw declarations: inject defaults, generate prompts, add
jqtoPATH, and append a read-only machine-readable manifest file to each generator’s files. - Runtime layout: deployed files live under
/run/secrets/vars/<name>/<file>forservicesand/run/secrets-for-users/vars/<name>/<file>forusers. - Manifests: a JSON file
/run/secrets[-for-users]/<name>/manifest.jsonis written by the wrapped script after yourscriptruns. Paths in the manifest include thevarssegment to reflect runtime layout. - Values: files declared with
secret = falseexpose their contents as strings under Clan’s canonical location, mirrored by helper accessors. - ACLs: per-file user read access can be granted without duplication via
additionalReadersor manualallowReadAccessitems; implemented with systemd triggers andsetfacl. - Expose-to-user: optional systemd units copy a user-scoped secret file into a user-owned destination.
my.secrets.mkSharedSecret { ... }- Scope: shared (
share = true) - Default
defaultNeededFor = "services"
- Scope: shared (
my.secrets.mkMachineSecret { ... }- Scope: machine (
share = false) - Injects
validation.hostname = config.networking.hostName - Default
defaultNeededFor = "services"
- Scope: machine (
my.secrets.mkUserSecret { ... }- Scope: user
- Default
defaultNeededFor = "users"
Common arguments for all three constructors:
- name (string, required): generator name
- files (attrs: fileName → fileSpec, required)
deploy(bool, default true): include in deployment storesecret(bool, default true): mark as secret in manifestowner(string, default "root")group(string, default "root")mode(string, default "0400")neededFor("services" | "users", default from constructor)description(string | null, default null)promptType("hidden" | "multiline-hidden", default "hidden")additionalReaders(list of strings, default[]): grant user read ACLs to the deployed file
- prompts (attrs, default auto-generated)
- Auto:
prompts.<file>.input = { description = "${name} (${file})"; type = promptType; persist = false; } - Provide a subset to override
- Auto:
- script (bash string, required): writes outputs into
$out/<file> - runtimeInputs (list of pkgs, default
[ ]plusjq) - dependencies (list of derivations, default
[ ]) - validation (attrs, default
{ }) - meta (attrs, default
{ }) - defaultNeededFor ("services" | "users"): overrides constructor default across files that omit
neededFor
Returns: an attrset keyed by name, suitable for inclusion in my.secrets.declarations.
Behavior injected by constructors:
- Adds a read-only file
manifest.jsonto the generator outputs withsecret = false,mode = "0400" - Wraps your
scriptto emitmanifest.jsoncontaining derivation metadata and resolved runtime paths - Adds
jqtoPATHfor manifest processing - Captures per-file
additionalReadersinto internal metadata for the ACL subsystem
my.secrets.declarations(list of attrs, default[])- Merge these into
clan.core.vars.generators
- Merge these into
my.secrets.discover(submodule)enable(bool, default false)dir(path, default./../../vars/generatorsrelative to this module)includeTags(list of strings, default[])excludeTags(list of strings, default[])
my.secrets.exposeUserSecret(single entry; legacy)- Deprecated in favor of
exposeUserSecrets
- Deprecated in favor of
my.secrets.exposeUserSecrets(list of submodules)enable(bool, default false)secretName(string)file(string)user(string)dest(string, default:/var/lib/user-secrets/<user>/<secret>/<file>)mode(string, default0400)group(string, default primary group of the user)
- Paths helpers (read-only)
my.secrets.paths.<gen>.<file>.pathmy.secrets.pathsFlat."<gen>.<file>".pathmy.secrets.getPath "<gen>" "<file>" -> path | null
- Value helpers (read-only; only for
secret = false)my.secrets.values.<gen>.<file>.valuemy.secrets.valuesFlat."<gen>.<file>".valuemy.secrets.getValue "<gen>" "<file>" -> string | null
- ACLs
my.secrets.allowReadAccess = [ { path = "/abs/path"; readers = [ "alice" "svc" ]; } ... ]
Grant per-user read access to root-owned deployed files without duplicating secrets or managing groups.
-
Per-file (preferred with constructors)
my.secrets.declarations = [ (config.my.secrets.mkSharedSecret { name = "api-key-aws-access"; files.aws_access_key_id = { mode = "0400"; neededFor = "users"; # deploys under /run/secrets-for-users/vars/... additionalReaders = [ "alice" ]; # grant read ACL to these users }; prompts.aws_access_key_id.input = { description = "AWS access key ID"; persist = true; type = "hidden"; }; script = '' cp "$prompts/aws_access_key_id" "$out/aws_access_key_id" ''; meta.tags = [ "aws" "api-key" "dev" "shell" ]; }) ];
This generates systemd path/service units that apply
setfacl u:<user>:rto the deployed file whenever it appears or changes. -
Manual (arbitrary files)
my.secrets.allowReadAccess = [ { path = config.my.secrets.getPath "api-key-openrouter" "api_key"; readers = [ "alice" ]; } ];
-
Triggers and behavior
- Trigger on content modifications and on parent directory changes, not on "exists" at boot (avoids start-limit loops)
- Reapply ACL unconditionally; idempotent
-
Important note about sops-nix and tmpfs
- When any ACL target is under
/run/secrets-for-users, this module enablessops.useTmpfs = trueby default (ifsops-nixis present), switching its storage to tmpfs so ACLs work - tmpfs can swap to disk; review swap configuration and consider enabling swap encryption
- When any ACL target is under
-
Requirements
- Filesystem ACL support (typically enabled on ext4/xfs)
pkgs.aclis pulled into the system when ACLs are requested
Copy a user-scoped deployed secret file from /run/secrets-for-users/vars/<name>/<file> into a user-owned destination on change.
-
Multiple entries (preferred)
my.secrets.exposeUserSecrets = [ { enable = true; secretName = "surrealdb-credentials"; file = "credentials"; user = "surrealdb"; dest = "/var/lib/surrealdb/credentials.env"; mode = "0400"; } { enable = true; secretName = "user-ssh-key"; file = "key"; user = "alice"; dest = "/home/${config.my.mainUser.name}/.ssh/id_ed25519"; mode = "0400"; } ];
-
Notes
- Triggers on file content modifications and on the source directory change
- Only updates destination if content changed
- Ensures destination directory exists with secure ownership and permissions
- Use in other module options without hardcoding paths:
config.my.secrets.paths."<gen>"."<file>".pathconfig.my.secrets.getPath "<gen>" "<file>"
- To read non-secret values (
secret = false) as strings:config.clan.core.vars.generators.<gen>.files.<file>.value(canonical per Clan)config.my.secrets.values.<gen>.<file>.value(convenience)
Example:
# Define a non-secret value and read it
my.secrets.declarations = [
(config.my.secrets.mkSharedSecret {
name = "example";
files.public = { secret = false; mode = "0444"; };
script = ''
echo -n "hello" > "$out/public"
'';
})
];
# Convenience helpers (equivalent)
config.my.secrets.getValue "example" "public"
config.my.secrets.values.example.public.value
config.my.secrets.valuesFlat."example.public".value- Path:
/run/secrets/<name>/manifest.jsonor/run/secrets-for-users/<name>/manifest.jsonwhen any file hasneededFor = "users" - Contents: name, scope, share, store settings,
derivationinfo (hostname,generatedAt,dependencies), andfileswith final deploy paths
Minimal shape (illustrative):
{
"name": "openai-api-key",
"scope": "user",
"share": false,
"neededFor": "users",
"store": { "secretStore": "…", "publicStore": "…" },
"meta": {},
"validation": {
"hostname": "host"
},
"derivation": {
"dependencies": [],
"hostname": "host",
"generatedAt": "2024-01-01T00:00:00Z"
},
"files": [
{
"name": "key",
"secret": true,
"owner": "root",
"group": "root",
"mode": "0400",
"neededFor": "users",
"description": null,
"path": "/run/secrets-for-users/vars/openai-api-key/key"
},
{
"name": "manifest.json",
"secret": false,
"owner": "root",
"group": "root",
"mode": "0400",
"neededFor": "users",
"description": "Machine-readable secret manifest",
"path": "/run/secrets-for-users/vars/openai-api-key/manifest.json"
}
]
}{
description = "My infra with vars-native secrets via flake-parts module";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
# This repo
secrets-helper.url = "github:perstarkse/clan-vars-secrets-helper";
secrets-helper.inputs.nixpkgs.follows = "nixpkgs";
secrets-helper.inputs.flake-parts.follows = "flake-parts";
};
outputs = inputs@{ self, nixpkgs, flake-parts, secrets-helper, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" ];
perSystem = { pkgs, system, ... }: { };
flake.nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
# Import the module (exported as `default`)
secrets-helper.nixosModules.default
({ config, pkgs, ... }: {
# Enable discovery from vars/generators by tags
my.secrets.discover = {
enable = true;
dir = ./vars/generators;
includeTags = [ "shared" ];
};
# Add machine-only secrets
my.secrets.declarations = [
(config.my.secrets.mkMachineSecret {
name = "jwt-signing-key";
files = {
private = { description = "JWT private key"; mode = "0400"; };
public = { description = "JWT public key"; secret = false; mode = "0444"; };
};
runtimeInputs = [ pkgs.openssl ];
script = ''
openssl genrsa -out "$out/private" 4096
openssl rsa -in "$out/private" -pubout -out "$out/public"
'';
meta = {
description = "Per-machine JWT keypair";
tags = [ "jwt" "crypto" ];
owners = [ "[email protected]" ];
rotateAfterDays = 180;
};
validation = { version = 2; };
})
];
# Multiple exposures (preferred)
my.secrets.exposeUserSecrets = [
{
enable = true;
secretName = "surrealdb-credentials";
file = "credentials";
user = "surrealdb";
dest = "/var/lib/surrealdb/credentials.env";
mode = "0400";
}
{
enable = true;
secretName = "user-ssh-key";
file = "key";
user = config.my.mainUser.name;
dest = "/home/${config.my.mainUser.name}/.ssh/id_ed25519";
mode = "0400";
}
{
enable = true;
secretName = "user-age-key";
file = "key";
user = config.my.mainUser.name;
dest = "/home/${config.my.mainUser.name}/.config/sops/age/keys.txt";
mode = "0400";
}
];
# Example: use deployed file path in another module option
services.my-service.settings.pass_file =
config.my.secrets.paths."openai-api-key".key.path;
})
];
};
};
}config.my.secrets.declarations = [
(config.my.secrets.mkMachineSecret {
name = "surrealdb-credentials";
files = {
user = { };
password = { promptType = "multiline-hidden"; };
};
script = ''
echo -n "p" > "$out/user"
cat > "$out/password" <<'EOF'
very
secret
multi
line
EOF
'';
})
];-
This module only populates
clan.core.vars.generators; it does not ship a CLI -
For metadata-driven rotation, add a stable hash to
validationas needed, e.g.:validation = { version = 1; # metaHash = builtins.hashString "sha256" (builtins.toJSON meta); };
$ nix develop
$ nix fmt
MIT