·7 min read·infrastructure
Share

Secrets Management for a Sovereign Stack: Stop Pasting API Keys in Plaintext

How I manage API keys across dozens of self-hosted daemons on one Mac — git-ignored dotenv with chmod 600, launchd wrappers, Keychain, gpg backups, no cloud vault.

One Mac, one wallet of keys, no cloud vault

My whole operation runs on a Mac Studio behind a Cloudflare tunnel: three Next.js sites under launchd, a dozen background daemons, local model servers, and scripts that call xAI, Anthropic, Cloudflare, and a handful of DSP APIs all day. Every one of those calls needs a credential. The lazy move is to paste keys inline — hardcode the xAI token in the script, drop the Cloudflare token in the plist, commit a .env "just to test." Every one of those is a landmine.

I don't rent a cloud secrets manager. I don't want a monthly bill and a network dependency between my daemons and the tokens they need to run. Everything below is self-hosted, offline-capable, and costs nothing. Here's the actual discipline.

One git-ignored secrets directory, chmod 600

Every key lives in ~/.daj-secrets/, one file per credential, and nothing else. No sprawl, no copies pasted into ten scripts.

mkdir -p ~/.daj-secrets
chmod 700 ~/.daj-secrets
printf '%s' 'xai-abc123...' > ~/.daj-secrets/xai_api_key
chmod 600 ~/.daj-secrets/xai_api_key

chmod 600 means only my user can read it — not other accounts, not group, not world. 700 on the directory itself stops anyone listing the filenames. Scripts read the value at runtime instead of embedding it:

XAI_KEY="$(cat ~/.daj-secrets/xai_api_key)"

The rule that keeps this clean: the secrets directory is never inside a git repo. It sits in $HOME, above every project. If I want a project-local .env, the very first thing I do is add it to .gitignore before I write a single key into it — and I add the whole family, .env, .env.*, *.key, .daj-secrets, so a future me doesn't slip.

If you already committed a key: rotate, don't just delete

This is the part people get wrong. You push a key, notice it in the diff, and delete it in the next commit thinking you're safe. You are not. The key is still in the git history, still in every clone, still in whatever cache GitHub or a CI runner kept. A git rm changes nothing about the fact that the secret was published.

The only real fix is to rotate the credential — go to the provider, revoke the leaked key, mint a new one, drop it in ~/.daj-secrets/. Treat the leaked value as burned forever. Scrubbing history with git filter-repo is worth doing afterward for hygiene, but rotation is the step that actually protects you. I keep a mental line for this: a key that touched a git commit is dead the moment I see it, no exceptions.

# after minting the replacement at the provider:
printf '%s' 'NEW-token' > ~/.daj-secrets/cloudflare_token
chmod 600 ~/.daj-secrets/cloudflare_token
# then restart whatever daemon consumes it

Loading secrets into launchd without baking them into the plist

Most of my long-running jobs are launchd agents. The trap is putting keys in EnvironmentVariables inside the .plist — because plists are plaintext XML, they get backed up, they get synced, they end up readable in a dozen places. I never put a secret in a plist.

Instead the plist points at a thin wrapper script, and the wrapper sources the env file:

#!/bin/bash
# ~/bin/run-daemon.sh
set -a
source ~/.daj-secrets/daemon.env   # KEY=value lines, chmod 600
set -e
exec node ~/daj-ai-hub/of-automation/some-daemon.js

set -a marks every variable that gets sourced for export, so the Node process inherits them; set +a (or just letting the script end) turns it back off. The plist only ever names the wrapper:

<key>ProgramArguments</key>
<array>
  <string>/Users/you/bin/run-daemon.sh</string>
</array>

Now the secret exists in exactly one file, the daemon still gets its environment, and the launchd definition is safe to back up or read. Rotating a key means editing one env file and running launchctl kickstart -k on the job — no plist surgery.

macOS Keychain for the interactive stuff

For credentials I type by hand rather than feed to a daemon, the login Keychain is already an encrypted, OS-managed store sitting right there. No reason to reinvent it.

# store once
security add-generic-password -a "$USER" -s dajai-anthropic -w 'sk-ant-...'

# read it back in a script
KEY="$(security find-generic-password -s dajai-anthropic -w)"

The Keychain is encrypted at rest and unlocks with my login. I use it for the keys tied to my own sessions and reserve the flat ~/.daj-secrets/ files for the headless daemons, where an always-available file is simpler than prompting for a Keychain unlock at boot.

gpg-encrypt the bundle for backup

A single directory of plaintext keys is a great single point of failure. If that disk dies, my whole stack is locked out until I re-mint every token by hand. So I keep an encrypted backup — and because it's gpg-encrypted, I can drop it anywhere, including cloud storage, without exposing anything.

# backup
tar czf - -C ~ .daj-secrets | gpg --symmetric --cipher-algo AES256 \
  -o ~/backups/secrets-$(date +%F).tar.gz.gpg

# restore
gpg -d ~/backups/secrets-2026-07-11.tar.gz.gpg | tar xzf - -C ~

The .gpg file is useless without the passphrase, which lives in my head, not on the disk. That's the whole point: the backup can leak and it's still just noise.

Rotate on a schedule, not on panic

Rotation shouldn't only happen after a leak. Keys that never change are keys that quietly accumulate exposure across every log, every backup, every process that ever read them. I rotate the high-value tokens — the ones that can spend money or post publicly — on a cadence, and treat any provider that emails a breach notice as an instant rotate. Because every consumer reads from one file or one Keychain entry, rotating is a two-minute job: replace the value, restart the job. That low friction is the reason the discipline actually survives contact with a busy week.

The through-line across all of it: one canonical location per secret, never in a repo, never in a plist, and always rotatable in one move. Get that right and "where's that key" stops being a question you answer by grepping your source code.

FAQ

Isn't a plaintext file with chmod 600 still risky?

It's a real tradeoff, and for unattended daemons it's the pragmatic one. chmod 600 restricts the file to your user, and the directory at 700 hides the filenames — anyone reading it already has your user account, at which point Keychain-at-login wouldn't help either. The defense that matters most is keeping the file out of git, out of backups-in-cleartext, and rotatable, so exposure has a short half-life. For interactive keys where you're present to unlock, prefer the Keychain.

Why not just use a cloud secrets manager?

For a self-hosted stack it adds a network dependency, a bill, and another vendor holding your keys, to solve a problem one directory and chmod already solves. Cloud vaults earn their keep with big teams and rotating fleets of servers. On one Mac driving my own daemons, the sovereign version — files, Keychain, gpg — is simpler, offline-capable, and gives me the same "keys live in one place" guarantee.

I committed an API key an hour ago and deleted it. Am I fine?

No. Deleting it in a later commit leaves the key in your git history and in every clone or cache that pulled it. Go rotate it at the provider right now — revoke the old value, mint a new one, update your secrets file. Scrubbing history afterward is good hygiene, but rotation is the only step that actually stops someone using the leaked key.

How do I get keys into a launchd job without hardcoding them?

Point the plist at a small wrapper script instead of your program directly, and have the wrapper set -a; source ~/.daj-secrets/job.env before exec-ing the real process. The secret stays in one chmod-600 env file, the daemon inherits it as environment variables, and the plist itself — which gets backed up as plaintext XML — never contains a credential.

Follow Hellcat Blondie everywhere

OnlyFans, Instagram, TikTok, and more. One page, all links.

Related