Why this matters
The bot's Telegram token currently sits in a .env on the VPS. In NixOS the obvious move, environment.TELEGRAM_TOKEN = "123:abc" in the service, puts the token into /nix/store, where every user and every machine you copy the closure to can read it. This lesson is about why that is unfixable after the fact and what the two standard tools do instead.
Concept
The store is public by design
On this machine:
$ stat -c '%A %U %G %n' /nix/store
drwxrwxr-t root nixbld /nix/store
$ stat -c '%A %U %G %n' "$(nix eval --raw nixpkgs#path)/flake.nix"
-r--r--r-- root root /nix/store/1lv9wbcx...-source/flake.nixEvery path is world-readable. That is what lets any user run any package. It also means a string inside any derivation, including the rendered claimbot.service from Services and systemd, is readable by any local account. Worse:
- The path is part of the system closure, so
nix copyandnixos-rebuild --target-hostship it to other hosts and caches. - It lives in every old generation until garbage collection.
- Store paths are content-addressed by hash. Rotating the token creates a new path; the old one is still there.
Rule: a secret may be referenced by path from the store, never contained in it.
The shape both tools share
- You generate an
agekey pair per host (or derive one from the host's SSH host key). - Secrets are encrypted to those public keys and committed to the repo. Ciphertext in git is fine.
- A NixOS module decrypts at activation, using the host's private key which never leaves the host, into a
tmpfsunder/run. - Services read the decrypted file by path.
The encrypted file goes through the store; the decrypted file does not.
agenix
Simplest. One secret per .age file. A secrets.nix at the repo root lists which public keys may decrypt which file:
let
laptop = "ssh-ed25519 AAAA...laptop";
vps = "ssh-ed25519 AAAA...vps";
in
{
"claimbot.env.age".publicKeys = [ laptop vps ];
}agenix -e claimbot.env.age opens an editor, encrypts on save. In the host config:
{ config, ... }:
{
age.secrets."claimbot.env".file = ../secrets/claimbot.env.age;
systemd.services.claimbot.serviceConfig.EnvironmentFile =
config.age.secrets."claimbot.env".path;
}Decrypted to /run/agenix/claimbot.env, root-owned, mode 0400 by default. age.secrets.<name>.owner, .group and .mode adjust that. The host's SSH host key at /etc/ssh/ssh_host_ed25519_key is the default identity, so a fresh host can decrypt as soon as its host key is in secrets.nix.
Verify: option names age.secrets.<name>.file, .path, .owner, .mode and age.identityPaths at https://github.com/ryantm/agenix#reference. The module is inputs.agenix.nixosModules.default, imported via specialArgs as in Flake-based NixOS. The CLI exists in nixpkgs as agenix-cli (version 0.1.2 in your pinned revision).
sops-nix
More machinery, more features. Secrets are keys inside one YAML or JSON file encrypted with sops; only values are encrypted, so git diff shows which key changed. Supports age, GPG and cloud KMS, and can set file ownership and render templates.
{ config, ... }:
{
sops.defaultSopsFile = ../secrets/vps.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.secrets.telegram_token = { };
sops.secrets."claimbot.env" = { };
systemd.services.claimbot.serviceConfig.EnvironmentFile =
config.sops.secrets."claimbot.env".path;
}Decrypted to /run/secrets/<name>, root-owned by default; EnvironmentFile is read by systemd as root before the service drops to its DynamicUser, so no owner is needed here. The .sops.yaml at the repo root maps file globs to recipient keys, like agenix's secrets.nix. ssh-to-age converts an SSH host key to an age recipient so you can reuse host keys here too.
Verify: sops.defaultSopsFile, sops.age.keyFile, sops.secrets.<name>.owner, .path, and sops.templates at https://github.com/Mic92/sops-nix. sops 3.13.3 and age 1.3.2 are in your pinned nixpkgs.
Choosing
- One or two hosts, a handful of secrets, you want to understand every piece: agenix.
- Many secrets, want them grouped per host in one file, need per-key diffs or templates that interpolate several secrets into one config: sops-nix.
Both are fine. Switching later is an afternoon.
Handing a secret to a service
EnvironmentFile reads KEY=value lines into the service environment. Simple, but the environment is visible to the process and anything it spawns.
LoadCredential is systemd's stricter path. It copies the file into a per-service directory and exposes it as $CREDENTIALS_DIRECTORY/<id>:
{ config, ... }:
{
systemd.services.claimbot.serviceConfig.LoadCredential = [
"token:${config.age.secrets.telegram_token.path}"
];
}The bot then reads os.environ["CREDENTIALS_DIRECTORY"] + "/token". Works with DynamicUser because systemd, not the service user, does the copy. Prefer it for new code; use EnvironmentFile when the program only knows how to read env vars.
Try it
- Check the store's permissions yourself:
stat -c '%A %U %G %n' /nix/storedrwxrwxr-t root nixbld /nix/store- Confirm the tools are in your pinned nixpkgs, no download beyond evaluation:
nix eval --raw nixpkgs#sops.version; echo; nix eval --raw nixpkgs#age.version; echo; nix eval --raw nixpkgs#agenix-cli.version3.13.3
1.3.2
0.1.2- Prove the leak. Write a module with
environment.variables.TOKEN = "leaked";, evaluate.config.environment.variables.TOKENthrough your Try-it flake, and reason about where that string would end up after a build: in/etc/profileinside the system closure, world-readable.
Exercise
Decide, for the bot: which tool, which secret files, which delivery mechanism. Write the host-side module for your choice with a Verify comment for every option name you have not checked.
Solution
One defensible answer: agenix, one file claimbot.env.age containing TELEGRAM_TOKEN=..., delivered by EnvironmentFile because python-telegram-bot reads the env.
{ config, ... }:
{
# Verify: age.secrets.<name>.file and .path against the agenix README
age.secrets."claimbot.env".file = ../secrets/claimbot.env.age;
systemd.services.claimbot.serviceConfig.EnvironmentFile =
config.age.secrets."claimbot.env".path;
}Plus secrets.nix listing the VPS host key and your laptop key, and imports = [ inputs.agenix.nixosModules.default ]; in the host.
Trap
builtins.readFile ./token.txt or environment.etc."bot.env".text = "TOKEN=..." to "keep it out of git". The gitignore protects the repo; the store is still public, and nix copy still ships it. If a secret ever enters the store, rotate it. Deleting the generation is not enough until nix-collect-garbage has run on every machine that received the closure.