Beyond Key-Value: Managing Structured Secrets with secr
When your secrets grow beyond flat API keys into multi-field credentials across regions and services, here's how to keep them organised without drowning in env vars.
Most .env files start simple:
DATABASE_URL=postgres://localhost:5432/myapp
API_KEY=sk-abc123
REDIS_HOST=localhost
One key, one string. That works until your project grows and your secrets start looking like this:
- An OpenSearch cluster that needs a
usernameandpassword - A Grafana instance that needs a
tokenand aurl - A Jira integration that needs an
emailand an APItoken - The same service repeated across multiple regions, each with different credentials
Suddenly you're either flattening everything into dozens of individual env vars (OPENSEARCH_US_WEST_2_USERNAME, OPENSEARCH_US_WEST_2_PASSWORD, OPENSEARCH_EU_WEST_1_USERNAME...) or jamming JSON into env vars and hoping nobody breaks the quoting.
There's a cleaner pattern. Here's how it works with secr.
Store structured credentials as JSON values
secr values are strings, but strings can hold JSON. Instead of splitting a credential across multiple keys, store it as one:
secr set 'OPENSEARCH_US_WEST_2={"username": "admin", "password": "s3cret"}'
secr set 'OPENSEARCH_EU_WEST_1={"username": "admin", "password": "different-s3cret"}'
secr set 'GRAFANA={"token": "glsa_abc123", "url": "https://grafana.internal"}'
secr set 'JIRA={"email": "bot@company.com", "token": "ATATT3x..."}'
One secret per logical service. The shape of each credential is self-contained — you don't need to remember which keys belong together.
Use --description to document what each secret contains, so your teammates aren't guessing at the structure:
secr set 'OPENSEARCH_US_WEST_2={"username": "admin", "password": "s3cret"}' \
--description 'JSON: { username, password } — OpenSearch cluster credentials for us-west-2'
$ secr ls
my-project/production — 4 secrets
GRAFANA v1 — JSON: { token, url }
JIRA v1 — JSON: { email, token }
OPENSEARCH_EU_WEST_1 v1 — JSON: { username, password }
OPENSEARCH_US_WEST_2 v1 — JSON: { username, password }
Enforce structure with templates
When secrets have internal structure, it's easy for someone to add a new region and forget a key — or misspell one. secr templates let you define which secrets a project must have:
secr template add OPENSEARCH_US_WEST_2
secr template add OPENSEARCH_EU_WEST_1
secr template add GRAFANA
secr template add JIRA
Then validate before deploying:
$ secr template validate --env production
✓ All 4 required keys are present in production
If someone forgets to set OPENSEARCH_AP_SOUTHEAST_2 after adding a new region to the config, secr template validate catches it before the deploy does.
Reference secret names in a committed config file
Create a config file that describes your infrastructure. Put the secret key names in the config, not the secrets themselves:
# config.yaml — safe to commit, contains zero credentials
opensearch:
regions:
- name: "us-west-2"
url: "https://search.us-west-2.internal:9200"
secret_key: "OPENSEARCH_US_WEST_2"
- name: "eu-west-1"
url: "https://search.eu-west-1.internal:9200"
secret_key: "OPENSEARCH_EU_WEST_1"
grafana:
secret_key: "GRAFANA"
jira:
url: "https://yourorg.atlassian.net"
secret_key: "JIRA"
This gives you two things at once:
- Documentation — anyone can read the config and understand what services the app connects to, how many regions, what the topology looks like.
- Safety — the file is committable, reviewable, and diffable. No risk of leaking credentials in a PR.
Inject and parse at runtime
The simplest approach: use secr run to inject secrets as environment variables, then parse the JSON values in your application code.
secr run -- node server.js
// server.js — secrets arrive as env vars, parse the structured ones
const yaml = require("yaml");
const fs = require("fs");
const config = yaml.parse(fs.readFileSync("config.yaml", "utf8"));
// OpenSearch — iterate regions from config, credentials from env
for (const region of config.opensearch.regions) {
const creds = JSON.parse(process.env[region.secret_key]);
const client = new OpenSearchClient({
url: region.url,
username: creds.username,
password: creds.password,
});
}
// Grafana — URL and token bundled in one secret
const grafana = JSON.parse(process.env[config.grafana.secret_key]);
const grafanaClient = new GrafanaClient({
url: grafana.url,
token: grafana.token,
});
No subprocess calls. No file I/O. secr run injects the values and nothing is written to disk.
Python
The same pattern works with any language. With Python:
secr run -- python app.py
import os, json, yaml
config = yaml.safe_load(open("config.yaml"))
for region in config["opensearch"]["regions"]:
creds = json.loads(os.environ[region["secret_key"]])
client = OpenSearchClient(
url=region["url"],
username=creds["username"],
password=creds["password"],
)
Using the SDK
If you prefer programmatic access over secr run, the TypeScript SDK works too:
import { SecrClient } from "@secr/sdk";
const secr = new SecrClient({ token: process.env.SECR_TOKEN });
const secrets = await secr.getSecrets("my-org", "my-project", "production");
// secrets is a Record<string, string> — parse the JSON ones
const opensearch = JSON.parse(secrets["OPENSEARCH_US_WEST_2"]);
Compare structured secrets across environments
When credentials differ between staging and production — different hosts, different passwords — secr diff shows you what's diverged:
$ secr diff staging production
OPENSEARCH_US_WEST_2 differs
OPENSEARCH_EU_WEST_1 differs
GRAFANA differs
JIRA same
And when you're ready to promote a tested credential from staging:
secr promote GRAFANA --from staging --to production
Adding a new region or service
This is where the pattern pays off. To add a new OpenSearch region:
secr set 'OPENSEARCH_AP_SOUTHEAST_2={"username": "admin", "password": "another-s3cret"}' \
--description 'JSON: { username, password } — OpenSearch ap-southeast-2'
secr template add OPENSEARCH_AP_SOUTHEAST_2
# Add to config.yaml
- name: "ap-southeast-2"
url: "https://search.ap-southeast-2.internal:9200"
secret_key: "OPENSEARCH_AP_SOUTHEAST_2"
Two steps. No .env updates across machines, no Docker Compose changes, no CI pipeline edits, no Slack messages asking teammates to update their local files. Every team member gets the new credential on their next secr run.
When this pattern makes sense
Not every project needs structured secrets. If your app has three flat API keys, a regular secr set KEY=value is fine.
But if you're seeing any of these signs, JSON-structured secrets can simplify things:
- Credentials that travel in pairs — username + password, email + token, URL + key
- The same service across multiple regions, each with unique credentials
- Config that mixes secrets with non-secrets — you want to commit the topology but not the credentials
- Growing
.envfiles where you've lost track of which keys belong to which service - Onboarding friction — new developers need to assemble a
.envfrom Slack messages and wiki pages
Summary
| Concern | Flat .env files | secr + structured secrets |
|---|---|---|
| Credential shape | One key per field | One key per service |
| Multi-region | N keys per region | 1 JSON key per region |
| Documentation | Comments in .env | --description on each secret |
| Missing key detection | Runtime crash | secr template validate |
| Add a new service | Update .env everywhere | secr set + config entry |
| Compare environments | Manual diffing | secr diff staging production |
| Onboarding | Copy .env from somewhere | secr login and run |
The core idea: your config file describes what you connect to, secr stores how you authenticate. Keep them separate, and both become easier to manage.
Have a growing .env file? Import it into secr with secr migrate .env and start structuring your credentials today.
Ready to get started?
Stop sharing secrets over Slack. Get set up in under two minutes.
Create your account