Secrets with SOPS
How Tale encrypts provider keys on disk with SOPS and age, the three storage modes, and the full key rotation walk.
3 min read
Tale stores provider API keys in providers/*.secrets.json files on disk. The default mode after tale init encrypts these files with SOPS using an age key; an alternative mode reads multiple keys from a file (the rotation path); a third mode keeps the files in plaintext at file mode 0600 for environments where the disk is encrypted at rest and rotation is handled externally. This page is the operator's walkthrough of the three modes and the safe rotation path.
The env vars that drive the modes are SOPS_AGE_KEY and SOPS_AGE_KEY_FILE — their reference rows live in Environment reference. This page is the longer story.
The three modes
| Mode | Env vars | When to use |
|---|---|---|
| Inline age key | SOPS_AGE_KEY=AGE-SECRET-KEY-1... | Default after tale init. Single host, single key. |
| Key file | SOPS_AGE_KEY_FILE=/path/to/keys | Required for rotation. One age key per line, # comments. |
| Plaintext at 0600 | Both unset | Disk encrypted at rest, or external tooling writes the files. |
The platform container picks the mode at boot. The inline form is the simplest; the file form is the only one that supports multiple readers (which is what makes rotation possible without downtime); the plaintext form skips SOPS entirely and trusts the filesystem.
First-boot encrypted mode
tale init generates an age keypair and writes the private half into SOPS_AGE_KEY in your .env. Provider secret files written through Settings > Providers are encrypted on save:
# Inspect — the file is SOPS-encrypted JSON, not the cleartext API key
cat providers/openai.secrets.json
# {
# "apiKey": "ENC[AES256_GCM,data:...,iv:...,tag:...]",
# "sops": { ... }
# }Decryption happens in-process when the platform container reads the file. The age key never leaves the platform container's memory.
Rotating the age key
Rotation is the one path the inline form does not cover — only SOPS_AGE_KEY_FILE lets you accept ciphertext readable by both the old and the new key during the cutover. The walk:
# 1. Generate a new age key
age-keygen -o /etc/tale/age-keys.txt
# 2. Append the new key as a second line in the file
echo "AGE-SECRET-KEY-1NEW..." >> /etc/tale/age-keys.txt
# 3. Point .env at the file and restart the platform container
sed -i 's|^SOPS_AGE_KEY=.*|# SOPS_AGE_KEY=|' .env
sed -i 's|^# SOPS_AGE_KEY_FILE=.*|SOPS_AGE_KEY_FILE=/etc/tale/age-keys.txt|' .env
docker compose restart tale-platform tale-convexNow both old and new keys can decrypt existing files. Re-save each provider's API key under Settings > Providers — each save produces ciphertext readable by both keys. Once every provider has been re-saved (the Last rotated column in the providers table tells you which still hold old ciphertext), remove the old key from the file:
# 4. Drop the old key line and restart again
sed -i '/^AGE-SECRET-KEY-1OLD/d' /etc/tale/age-keys.txt
docker compose restart tale-platform tale-convexThe order is load-bearing: never remove the old key before every file is re-encrypted, or the platform container will fail to read the still-old files at the next decryption.
Switching to plaintext
When the host disk is encrypted at rest (LUKS, AWS EBS encryption, GCP CSEK) and you do not want a second layer of key management, the plaintext mode is the supported option. Comment out both SOPS_AGE_KEY and SOPS_AGE_KEY_FILE, restart, and re-save each provider — the files are now JSON at mode 0600.
The risk model shifts: a leaked filesystem dump is now a leaked credential dump. Pick this mode only when the disk encryption is real (not a tickbox), and audit the host's backup story to confirm no plaintext snapshot escapes.
External secret stores
When your keys already live in Vault, a cloud secret manager, or Kubernetes Secrets, the first-class pattern is the env-var key source: point each provider at an environment variable with secretsEnv and let your secret store populate that variable. No cleartext file touches the disk, and the reserved-prefix gate keeps a config-write actor from reading an unrelated deployment secret. The full mechanism — the TALE_PROVIDER_KEY_ prefix gate, resolution order, and the restart-on-change behaviour — lives in Providers.
The file-mount approach is the legacy alternative: write the cleartext *.secrets.json files from the external store and run Tale in plaintext mode. It still works, but it puts the cleartext key on disk and breaks if you save a provider through the UI — the UI overwrites the mount. Prefer the env-var source unless a constraint forces the file form.
Where this fits
This page is the operator's full guide to the SOPS layer; the env-var reference rows are in Environment reference, and the provider file format itself in Providers. If a key is leaked, rotation is the same walk above run urgently.