Skip to content

Declarative secrets manager

Declare secrets once.
Store them anywhere.

Stop committing .env files. Define what your application needs in secretspec.toml, then plug in keyring, 1Password, Vault, AWS, or any of 11 backends — same code, every environment.

secretspec.toml
[project]
name = "my-app"
revision = "1.0"

[profiles.default]
DATABASE_URL = { description = "PostgreSQL", required = true }
REDIS_URL    = { description = "Redis cache" }
TLS_CERT     = { as_path = true }
DB_PASSWORD  = { type = "password", generate = true }

[profiles.development]
# Inherits from default — override what changes
DATABASE_URL = { default = "postgresql://localhost/dev" }
terminal
# 1. Initialize from existing .env
$ secretspec init --from dotenv
 Created secretspec.toml with 5 secrets

# 2. Pick a storage backend (one-time)
$ secretspec config init
 Saved to ~/.config/secretspec/config.toml

# 3. Run your app with secrets injected
$ secretspec run --profile production -- npm start
 Loaded DATABASE_URL from keyring
 Loaded REDIS_URL    from keyring
 Generated DB_PASSWORD (32 chars)
 Wrote TLS_CERT to /tmp/secretspec-tls-cert
 npm start

One declaration · Any provider

macOS Keychain
1Password
LastPass
Bitwarden
Vault / OpenBao
AWS Secrets Manager
Google Cloud Secret Manager
Proton Pass
Pass (GPG)
.env files
Secret Service
Credential Manager
macOS Keychain
1Password
LastPass
Bitwarden
Vault / OpenBao
AWS Secrets Manager
Google Cloud Secret Manager
Proton Pass
Pass (GPG)
.env files
Secret Service
Credential Manager

Three commands. Done.

From a leaky .env to a portable, declarative secrets setup the whole team can use.

1

Declare

Generate secretspec.toml from your existing .env, or write it by hand. Names and descriptions only — never values.

$ secretspec init --from dotenv
2

Configure

Pick a backend: system keyring, 1Password, Vault, AWS, GCP, Bitwarden, Pass, dotenv, or env vars.

$ secretspec config init
? Default provider: keyring
3

Run

Secrets are loaded at runtime and injected as environment variables. Same command, every environment.

$ secretspec run -- npm start

One file. Every secret. Every environment.

Built around the three questions every project answers: what secrets, how requirements differ per environment, and where values live.

Declaration vs storage

Declare a secret. Swap the source.

Your secretspec.toml never changes. The same code reads from Keychain, 1Password, Vault, AWS, or any of 11 backends.

secretspec.toml
[project]
name = "my-app"

[profiles.default]
DATABASE_URL      = { required = true }
STRIPE_SECRET_KEY = { required = true }
Source: System keyring
secretspec.toml declares DATABASE_URL
$ secretspec run — injected as env var at runtime
Your app reads DATABASE_URL from env
Profiles

One config. Every environment.

Profiles let the same secret be optional in development, required in production — without changing application code. How profiles work →

[profiles.default]
DATABASE_URL = { required = true }
[profiles.development]
DATABASE_URL = { default = "postgresql://localhost/dev" }
[profiles.production]
DATABASE_URL = { providers = ["vault", "keyring"] }
Same command. Every profile.
$ secretspec run --profile production -- npm start
--profile development --profile staging --profile production SECRETSPEC_PROFILE=ci
Per-secret providers

Fallback chains, per secret.

Specify an ordered list of providers for each secret. SecretSpec walks the chain until it finds the value — perfect for shared team vaults with personal overrides.

secretspec.toml
[providers]
team_vault = "onepassword://vault/Shared"
keyring    = "keyring://"
env        = "env://"

[profiles.production]
API_KEY = {
  description = "Third-party API key",
  providers = ["team_vault", "keyring", "env"]
}
Look up API_KEY in team_vault (1Password)
Found in keyring

Same code. Same secret. Different source per machine.

Type-safe SDK

Compile-time secrets in Rust.

The proc macro reads secretspec.toml at compile time and generates strongly-typed structs. Misspelling a secret name fails the build, not your deploy. SDK reference →

main.rs
// Generate typed structs from secretspec.toml
secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let secrets = Secrets::builder()
        .with_provider("keyring")
        .with_profile(Profile::Production)
        .load()?;

    // DATABASE_URL → database_url (compile-time checked)
    println!("Database: {}", secrets.secrets.database_url);

    // Optional secrets are Option<String>
    if let Some(redis) = &secrets.secrets.redis_url {
        println!("Redis: {}", redis);
    }

    secrets.secrets.set_as_env_vars();
    Ok(())
}
Migration

Switch backends with one command.

Outgrowing dotenv? Standardizing on Vault? secretspec import moves every secret without touching a single line of application code.

1 · From
dotenv://.env.production
DATABASE_URL=postgres://…
STRIPE_SECRET_KEY=sk_live_…
REDIS_URL=redis://…
2 · To
keyring://
$ secretspec import dotenv://.env.production
✓ Imported 5 secrets to keyring://
✓ Application code unchanged
devenv & Nix

Native devenv & Nix integration.

Enable SecretSpec in your devenv.yaml and every secret declared in secretspec.toml is loaded into config.secretspec.secrets — wire them into env vars, services, or processes from devenv.nix. devenv docs →

devenv.yaml
secretspec:
  enable: true
  provider: keyring   # keyring, dotenv, env, 1password, …
  profile: default
devenv.nix
{ config, ... }:
{
  # Wire any declared secret into the shell env
  env.DATABASE_URL = config.secretspec.secrets.DATABASE_URL;
}

Switch providers per machine without touching devenv.nix: devenv --secretspec-provider dotenv --secretspec-profile dev shell.

Stop leaking secrets.

Declare what your application needs. Store the values anywhere. Onboard new developers in one command.