Support optional encrypted-at-rest secrets via SOPS (re #233) #236

Closed
nickorlabs wants to merge 1 commit from nickorlabs/feat/sops-encrypted-secrets into main
nickorlabs commented 2026-06-01 05:30:25 +02:00 (Migrated from github.com)

Summary

Concrete code for #233. Adds opt-in SOPS support so true secrets (OPENAI_API_KEY, ODYSSEUS_ADMIN_PASSWORD, SEARXNG_SECRET, MCP OAuth secrets, IMAP passwords) can be encrypted at rest instead of sitting plaintext in .env. Non-secret config (APP_PORT, LLM_HOST, etc.) stays in .env so structural diffs remain reviewable.

The feature is only active when /app/secrets.env.enc is present at container start. Existing deployments are bit-for-bit identical.

Diff overview (6 files, +123 / -0)

File Purpose
Dockerfile Pin sops v3.13.1 (~10MB static Go binary), arch-aware via dpkg --print-architecture
docker/entrypoint.sh When secrets.env.enc exists, wrap gosu with sops exec-env — secrets become env vars JIT, plaintext never touches the FS. Hard-fails if sops is missing or no SOPS_AGE_KEY[_FILE] set.
.sops.yaml Creation rule, placeholder age public key
secrets.env.example Documents which env vars are expected as secrets
.gitignore Mirror the .env pattern: ignore secrets.env, allow secrets.env.example and secrets.env.enc
SECURITY.md New "Encrypting Secrets At Rest" section with the full age-keygen → sops -e → compose mount workflow

Naming

The codebase already has routes/vault_routes.py (Bitwarden/Vaultwarden integration — runtime feature). To avoid collision I'm using "encrypted secrets at rest" / "SOPS" / secrets.env.enc throughout — never "vault."

Backwards compatibility

  • No secrets.env.enc → entrypoint takes the existing exec gosu "$PUID:$PGID" "$@" path, byte-for-byte unchanged.
  • No new Python runtime deps.
  • No changes to python-dotenv flow.
  • Image grows by ~10MB (one static Go binary).

Test plan

  • docker build . succeeds on the modified Dockerfile
  • docker run odysseus-sops-test:local sh -c 'sops --version'sops 3.13.1
  • No secrets.env.enc present → entrypoint code path is unchanged (the new block is skipped by [ -f ... ])
  • Maintainer: end-to-end (generate age key → sops -e secrets.env > secrets.env.enc → mount key into container → docker compose up → verify env vars are populated and plaintext never appears on disk)

Open for direction

I went with the design described in #233 (sops bundled in the image, age keys, opt-in via file presence). If you'd prefer any of:

  • sops as a build-time ARG so non-users don't pull the binary
  • A different file location / name
  • GPG / cloud KMS abstraction instead of age
  • "We don't want this in core — closing"

…just say the word and I'll adjust or close. Self-contained PR; easy to drop.

## Summary Concrete code for #233. Adds **opt-in** SOPS support so true secrets (`OPENAI_API_KEY`, `ODYSSEUS_ADMIN_PASSWORD`, `SEARXNG_SECRET`, MCP OAuth secrets, IMAP passwords) can be encrypted at rest instead of sitting plaintext in `.env`. Non-secret config (`APP_PORT`, `LLM_HOST`, etc.) stays in `.env` so structural diffs remain reviewable. **The feature is only active when `/app/secrets.env.enc` is present at container start. Existing deployments are bit-for-bit identical.** ## Diff overview (6 files, +123 / -0) | File | Purpose | |---|---| | `Dockerfile` | Pin sops v3.13.1 (~10MB static Go binary), arch-aware via `dpkg --print-architecture` | | `docker/entrypoint.sh` | When `secrets.env.enc` exists, wrap `gosu` with `sops exec-env` — secrets become env vars JIT, plaintext never touches the FS. Hard-fails if sops is missing or no `SOPS_AGE_KEY[_FILE]` set. | | `.sops.yaml` | Creation rule, placeholder age public key | | `secrets.env.example` | Documents which env vars are expected as secrets | | `.gitignore` | Mirror the `.env` pattern: ignore `secrets.env`, allow `secrets.env.example` and `secrets.env.enc` | | `SECURITY.md` | New "Encrypting Secrets At Rest" section with the full age-keygen → `sops -e` → compose mount workflow | ## Naming The codebase already has `routes/vault_routes.py` (Bitwarden/Vaultwarden integration — runtime feature). To avoid collision I'm using "encrypted secrets at rest" / "SOPS" / `secrets.env.enc` throughout — never "vault." ## Backwards compatibility - No `secrets.env.enc` → entrypoint takes the existing `exec gosu "$PUID:$PGID" "$@"` path, byte-for-byte unchanged. - No new Python runtime deps. - No changes to `python-dotenv` flow. - Image grows by ~10MB (one static Go binary). ## Test plan - [x] `docker build .` succeeds on the modified Dockerfile - [x] `docker run odysseus-sops-test:local sh -c 'sops --version'` → `sops 3.13.1` - [x] No `secrets.env.enc` present → entrypoint code path is unchanged (the new block is skipped by `[ -f ... ]`) - [ ] Maintainer: end-to-end (generate age key → `sops -e secrets.env > secrets.env.enc` → mount key into container → `docker compose up` → verify env vars are populated and plaintext never appears on disk) ## Open for direction I went with the design described in #233 (sops bundled in the image, age keys, opt-in via file presence). If you'd prefer any of: - sops as a build-time `ARG` so non-users don't pull the binary - A different file location / name - GPG / cloud KMS abstraction instead of age - "We don't want this in core — closing" …just say the word and I'll adjust or close. Self-contained PR; easy to drop.
sleepy closed this pull request 2026-06-01 19:46:10 +02:00

Pull request closed

Sign in to join this conversation.
No description provided.