patternssecrets-managementdevopsteams

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.

secr team·

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 username and password
  • A Grafana instance that needs a token and a url
  • A Jira integration that needs an email and an API token
  • 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:

  1. Documentation — anyone can read the config and understand what services the app connects to, how many regions, what the topology looks like.
  2. 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 .env files where you've lost track of which keys belong to which service
  • Onboarding friction — new developers need to assemble a .env from Slack messages and wiki pages

Summary

ConcernFlat .env filessecr + structured secrets
Credential shapeOne key per fieldOne key per service
Multi-regionN keys per region1 JSON key per region
DocumentationComments in .env--description on each secret
Missing key detectionRuntime crashsecr template validate
Add a new serviceUpdate .env everywheresecr set + config entry
Compare environmentsManual diffingsecr diff staging production
OnboardingCopy .env from somewheresecr 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