Secrets Management
Zero-plaintext secrets workflow for E2E tests and local development.
Architecture
There are two components:
scripts/secrets.sh — a portable Bash script that handles
encryption, decryption, and in-memory injection. It knows nothing
about YAML structure. It treats secrets files as opaque blobs.
Secrets loader — a language-specific module that parses the
YAML content and provides structured access to credentials. In this
project it is SecretsHelper (TypeScript) because the E2E tests use
Playwright. Other projects would write their loader in whatever
language their test framework uses (Python, Go, Ruby, etc.).
The two components connect via the STRATOS_SECRETS environment
variable. The script decrypts and injects; the loader reads and parses.
Plaintext secrets never persist on disk in CI. For local development,
secrets.yaml is .gitignored and can be deleted after encrypting.
In this repo, secrets.yaml.enc is also .gitignored and not committed
(see .gitignore) — the encrypted file and the age keys that can decrypt
it are distributed out-of-band (e.g. a shared vault), not via git. This
doc doubles as a portable template (see Adopting in Other Projects below),
where committing .enc is a valid per-project choice — just not this
project's.
Why SOPS + age
OpenSSL (the default backend) encrypts with a shared passphrase. Everyone who needs access must know the passphrase, and rotating it means re-distributing it to the whole team. It works for solo use but breaks down with multiple contributors.
SOPS (Secrets OPerationS) is Mozilla's tool for encrypting
structured files (YAML, JSON, ENV). Unlike OpenSSL which encrypts
the entire file as a binary blob, SOPS encrypts individual values
while leaving keys and structure visible. This means git diff
shows which fields changed even though the values are opaque.
age is a modern, minimal encryption tool — think of it as the
successor to GPG without the complexity. Each person generates a
keypair: a public key (starts with age1...) and a private key.
The public keys are listed in .sops.yaml so SOPS knows who can
decrypt. The private key stays on your machine.
Together they solve the team secrets problem:
- No shared passphrase to manage or rotate
- Add a team member by adding their public key to
.sops.yaml - Remove access by removing their key and re-encrypting
- Diffs remain readable in pull requests
- Private keys never leave individual machines
Prerequisites
Tool Required for Install
───────── ────────────────── ──────────────────────────
openssl Default backend Pre-installed on macOS/Linux
sops SOPS backend brew install sops
age SOPS backend brew install age
Check your setup:
scripts/secrets.sh check # openssl (default)
scripts/secrets.sh check --backend sops # sops + age
Secrets Methods
1. Plaintext file (local development)
Simplest approach — keep secrets.yaml on disk while developing.
cp e2e/secrets.yaml.template secrets.yaml
# Edit with your credentials
bunx playwright test
The file is .gitignored. Never commit it.
2. OpenSSL encrypted file (default)
Encrypt with a passphrase, decrypt when needed.
# Encrypt
scripts/secrets.sh encrypt
# Decrypt
scripts/secrets.sh decrypt
# Run tests without plaintext on disk
scripts/secrets.sh run-e2e
3. SOPS + age (recommended for teams)
Uses public-key encryption. No shared passphrase needed — each team
member's age public key is listed in .sops.yaml.
# First-time setup: generate an age keypair
age-keygen -o ~/.config/sops/age/keys.txt
# Share your public key (age1...) with the team for .sops.yaml
# Encrypt / decrypt
scripts/secrets.sh encrypt --backend sops
scripts/secrets.sh decrypt --backend sops
# Run tests without plaintext on disk
scripts/secrets.sh run-e2e --backend sops
Set the default backend to avoid typing --backend sops every time:
export STRATOS_SECRETS_BACKEND=sops
Multi-Environment Support
Use --env to manage per-environment secrets files:
# Encrypt per-environment files
scripts/secrets.sh encrypt --env local
scripts/secrets.sh encrypt --env staging
# Decrypt a specific environment
scripts/secrets.sh decrypt --env local
# Run tests against a specific environment
scripts/secrets.sh run-e2e --env staging -- --headed
File naming convention:
Flag Plaintext file Encrypted file
──────────────── ────────────────────── ──────────────────────────
(none) secrets.yaml secrets.yaml.enc
--env local secrets.local.yaml secrets.local.yaml.enc
--env staging secrets.staging.yaml secrets.staging.yaml.enc
All secrets.*.yaml files are .gitignored. Encrypted .enc files can
be committed if the team wants shared encrypted secrets in the repo — or,
as this repo does, kept out of git entirely and distributed out-of-band
(shared vault) instead, e.g. when the repo itself is public and a team
doesn't want to reveal even the existence of environment-specific
secrets to outside forks.
Profiles vs Environments
Environments (--env) select which file to load — one file per
deployment target (local Stratos, staging, production).
Profiles (E2E_PROFILE) select which section within a file
to use — useful when a single secrets file has credentials for multiple
CF endpoints.
These compose: --env staging loads secrets.staging.yaml, and
E2E_PROFILE=us-east selects the us-east profile within that
file.
Environment Variables
Variable Purpose Default
──────────────────────── ─────────────────────────────────── ────────────────────────
STRATOS_SECRETS YAML content injected by run-e2e (none)
E2E_ENV Environment name for file selection (none)
E2E_PROFILE Profile name within secrets file (none)
E2E_BASE_URL Override Stratos URL https://localhost:5440
STRATOS_SECRETS_BACKEND Default encryption backend openssl
SOPS_AGE_KEY_FILE Path to age private key ~/.config/sops/age/keys.txt
Secrets Loader
The loader is the language-specific piece that parses YAML content and provides structured access to credentials. Each project writes its own loader in the language of its test framework.
Load order
The loader checks these sources in priority order:
STRATOS_SECRETSenv var (set byscripts/secrets.sh run-e2e)secrets.<env>.yamlfile (whenE2E_ENVis set)secrets.yamlfile (default)
First source found wins. Within the loaded content,
E2E_PROFILE selects a named profile if present.
Loader contract
Any loader implementation should:
- Check for a
STRATOS_SECRETSenv var first (YAML string) - Fall back to file on disk
- Support profile selection within the parsed content
- Return structured credentials to the test framework
This project's TypeScript loader (e2e/helpers/secrets-helpers.ts)
is a reference implementation. Below are starter templates for
common languages. Each follows the same contract — adapt the
return structure to match your project's secrets schema.
Bash
#!/usr/bin/env bash
# secrets-loader.sh — source this or call as a function
secrets_load() {
local env="${E2E_ENV:-}"
local content=""
# Source 1: env var
if [[ -n "${STRATOS_SECRETS:-}" ]]; then
content="${STRATOS_SECRETS}"
else
# Source 2: env-specific file
local file="secrets.yaml"
[[ -n "${env}" ]] && file="secrets.${env}.yaml"
if [[ ! -f "${file}" ]]; then
echo "Error: secrets not found" >&2
return 1
fi
content="$(cat "${file}")"
fi
# Parse with yq (install: brew install yq)
# Adapt these field paths to your schema
local profile="${E2E_PROFILE:-}"
local prefix=""
if [[ -n "${profile}" ]]; then
prefix=".profiles.${profile}"
fi
ADMIN_USER="$(echo "${content}" | yq "${prefix}.consoleUsers.admin.username")"
ADMIN_PASS="$(echo "${content}" | yq "${prefix}.consoleUsers.admin.password")"
CF_URL="$(echo "${content}" | yq "${prefix}.endpoints.cf[0].url")"
export ADMIN_USER ADMIN_PASS CF_URL
}
Python
#!/usr/bin/env python3
"""secrets_loader.py — load secrets from env var or file."""
import os
import yaml
def load_secrets():
env = os.environ.get("E2E_ENV", "")
content = os.environ.get("STRATOS_SECRETS", "")
if not content:
filename = f"secrets.{env}.yaml" if env else "secrets.yaml"
if not os.path.exists(filename):
raise FileNotFoundError(f"Secrets not found: {filename}")
with open(filename) as f:
content = f.read()
raw = yaml.safe_load(content)
profile = os.environ.get("E2E_PROFILE", "")
if profile:
if "profiles" not in raw or profile not in raw["profiles"]:
available = list(raw.get("profiles", {}).keys())
raise KeyError(f"Profile '{profile}' not found. Available: {available}")
secrets = {**raw, **raw["profiles"][profile]}
else:
secrets = raw
# Adapt this return structure to your schema
return {
"console": {
"admin": secrets.get("consoleUsers", {}).get("admin", {}),
"user": secrets.get("consoleUsers", {}).get("nonAdmin", {}),
},
"endpoints": secrets.get("endpoints", {}),
"raw": secrets,
}
Perl
#!/usr/bin/env perl
# secrets_loader.pl — load secrets from env var or file.
use strict;
use warnings;
use YAML::XS qw(Load LoadFile);
sub load_secrets {
my $env = $ENV{E2E_ENV} // '';
my $content = $ENV{STRATOS_SECRETS} // '';
my $raw;
if ($content) {
$raw = Load($content);
} else {
my $file = $env ? "secrets.$env.yaml" : 'secrets.yaml';
die "Secrets not found: $file\n" unless -f $file;
$raw = LoadFile($file);
}
my $profile = $ENV{E2E_PROFILE} // '';
my $secrets;
if ($profile) {
my $profiles = $raw->{profiles} // {};
die "Profile '$profile' not found\n" unless $profiles->{$profile};
$secrets = { %$raw, %{ $profiles->{$profile} } };
} else {
$secrets = $raw;
}
# Adapt this return structure to your schema
return {
console => {
admin => $secrets->{consoleUsers}{admin} // {},
user => $secrets->{consoleUsers}{nonAdmin} // {},
},
endpoints => $secrets->{endpoints} // {},
raw => $secrets,
};
}
1;
Daily Workflows
Local development (online)
# One-time: decrypt secrets
scripts/secrets.sh decrypt
# Run tests (reads secrets.yaml from disk)
bunx playwright test
CI / secure environments
# Secrets never touch disk
scripts/secrets.sh run-e2e --backend sops -- --reporter=list
Switching environments
# Test against local Stratos
scripts/secrets.sh run-e2e --env local
# Test against staging
scripts/secrets.sh run-e2e --env staging
Updating secrets
# Decrypt, edit, re-encrypt
scripts/secrets.sh decrypt --env local
$EDITOR secrets.local.yaml
scripts/secrets.sh encrypt --env local
rm secrets.local.yaml # optional: remove plaintext
Troubleshooting
Problem Cause Fix
───────────────────────── ───────────────────────────────── ─────────────────────────────────────
Secrets file not found No secrets.yaml, no env var Run secrets.sh decrypt or copy template
Profile 'X' not found E2E_PROFILE not in file Check profiles: section in secrets file
bad decrypt (openssl) Wrong passphrase Re-enter passphrase
no identity matched (sops) Age key not in .sops.yaml Add your public key to .sops.yaml
age: no identity Missing age key file age-keygen -o ~/.config/sops/age/keys.txt
Tests fail with auth Wrong credentials Verify creds match target environment
E2E_ENV ignored No matching file on disk Check secrets.<env>.yaml exists
Adopting in Other Projects
scripts/secrets.sh is self-contained with no repo-specific paths.
To adopt in another project:
- Copy
scripts/secrets.shto the new repo - Add
secrets.yamlandsecrets.*.yamlto.gitignore - Create a secrets template showing the expected YAML structure
- Write a secrets loader in your project's language following the loader contract above (check env var, fall back to file, support profiles)
- Rename the
STRATOS_*env vars as appropriate for the project
The script resolves the repo root via git rev-parse and operates
on secrets.yaml relative to that root.