@charcoalhq/lockbox
v0.3.0
Published
Typed configuration with encrypted secrets. JSON-based, per-environment overrides, libsodium sealed-box encryption, TypeScript codegen, and validation hooks.
Maintainers
Readme
lockbox
The last config and secrets manager your TypeScript app needs.
Define your config in JSON. Secrets get encrypted automatically. Everything is merged per-environment, typed end-to-end, and validated on every commit. No more .env juggling, no more runtime surprises.
- Per-environment overrides — base defaults deep-merged with environment-specific config and secrets
- Config inheritance — environments can extend other environments (
_extends), sharing a common base without duplication - Sub-environments — nest additional config layers within an environment for regions, clusters, tenants, or any other dimension
- Partial overrides — layer ad-hoc config on top at runtime, without baking it into generated files
- Encrypted secrets — libsodium sealed boxes. Public key lives in your repo, private key stays in your secrets manager
- Full type safety — validate and type your config with any Standard Schema-compatible library (Zod, Valibot, ArkType, etc.)
- Git hooks — validates that secrets are encrypted and generated files are fresh before you push
Quick start
1. Install
pnpm add @charcoalhq/lockbox2. Initialize
npx lockbox init --dir ./src/config --env test --env production
# With sub-environments (e.g. regions):
npx lockbox init --dir ./src/config --env test --env production --sub-env us-west-2 --sub-env eu-central-1This creates the directory structure, generates a keypair, and prints your private key. Save the private key securely — it won't be shown again.
3. Add config values
Use the CLI to set values — it writes the JSON and regenerates TypeScript files automatically:
# Set defaults (shared across all environments)
npx lockbox set server.host 0.0.0.0
npx lockbox set server.port 3000
npx lockbox set db.host localhost
npx lockbox set db.port 5432
npx lockbox set db.password '**REQUIRED**'
# Override per environment
npx lockbox set db.host prod.db.example.com --env production
# Override per sub-environment
npx lockbox set db.host us-west-2.db.example.com --env production --sub-env us-west-2
# Set secrets (encrypted automatically)
npx lockbox set-secret db.password hunter2 --env productionOr edit the JSON files directly if you prefer:
src/config/default.json — base values merged into every environment:
{
"server": { "host": "0.0.0.0", "port": 3000 },
"db": { "host": "localhost", "port": 5432, "password": "**REQUIRED**" }
}src/config/production/clear.json — non-secret production overrides:
{
"db": { "host": "prod.db.example.com" }
}src/config/production/secret.json — secret values (add as plaintext, they'll be encrypted):
{
"db": { "password": "hunter2" }
}Then run npx lockbox generate to encrypt secrets and generate TypeScript files.
5. Use in your app
Define your schema using any Standard Schema-compatible library (Zod, Valibot, ArkType, etc.):
import { z } from 'zod';
import { createConfig } from '@charcoalhq/lockbox';
import testConfig from './config/test/generated.js';
import prodConfig from './config/production/generated.js';
const configSchema = z.object({
server: z.object({
host: z.string(),
port: z.coerce.number(),
}),
db: z.object({
host: z.string(),
port: z.number().default(5432),
password: z.string(),
}),
});
export const { config, environment } = await createConfig({
configs: { test: testConfig, production: prodConfig },
environment: process.env.NODE_ENV ?? 'test',
privateKey: process.env.MY_PRIVATE_KEY,
schema: configSchema,
});
// config is fully typed as z.infer<typeof configSchema>Validation runs after decryption, so your schema sees the final plaintext values. On failure, you get a clear error:
lockbox: Config validation failed:
✖ server.port: Expected number, received string
✖ db.password: RequiredAccess your config with full type safety:
import { config } from './config.js';
app.listen(config.server.port, config.server.host);How it works
Directory structure
src/config/
├── lockbox.pub # Public key (committed to repo)
├── default.json # Base config merged into all environments
├── test/
│ ├── clear.json # Non-secret overrides
│ ├── secret.json # Encrypted secret values
│ └── generated.ts # Auto-generated (do not edit)
└── production/
├── clear.json
├── secret.json
├── generated.ts
└── us-west-2/ # Sub-environment (optional)
├── clear.json
├── secret.json
└── generated.tsMerge order
For each environment, configs are deep-merged in this order:
default.json
< ...ancestor environments (via _extends, most distant first)...
< {env}/clear.json
< {env}/secret.json
< {env}/{sub-env}/clear.json (if sub-environment exists)
< {env}/{sub-env}/secret.json (if sub-environment exists)
< overrides (runtime only)Later values override earlier ones. Objects are merged recursively; all other values (arrays, primitives, null) are replaced.
Config inheritance
Environments can extend other environments using _extends in clear.json:
// staging/clear.json
{
"_extends": "production",
"logging": { "level": "debug" }
}Staging will inherit production's full merged config, then apply its own overrides on top. Multi-level chains work too (dev extends staging extends production). The _extends key is stripped from the final config.
Sub-environments
Add additional config layers as subdirectories within an environment. Any subdirectory containing clear.json or secret.json is treated as a sub-environment. Use cases include regions, clusters, tenants, or any other dimension you need:
production/
├── clear.json # Base production config
├── secret.json
├── generated.ts
├── us-west-2/ # Region-specific overrides
│ ├── clear.json
│ ├── secret.json
│ └── generated.ts
└── eu-central-1/
├── clear.json
├── secret.json
└── generated.tsSub-environments inherit the full parent environment config (including any _extends chain) and apply their own overrides on top. Each sub-environment gets its own generated.ts.
Import the sub-environment's generated file directly:
import prodUsWest from './config/production/us-west-2/generated.js';
const { config } = await createConfig({
configs: { 'production-us-west-2': prodUsWest },
environment: 'production-us-west-2',
schema: configSchema,
});Runtime overrides
Apply ad-hoc overrides at runtime without baking them into generated files:
const { config } = await createConfig({
configs: { production: prodConfig },
environment: 'production',
schema: configSchema,
overrides: {
db: { host: 'local-override.db.com' },
},
});Overrides are deep-merged after decryption and before schema validation. Use them for local development or testing — they are never written to generated.ts.
You can also view the effective config with overrides applied via the CLI:
lockbox view --env production --override local.jsonEncryption
Secrets use libsodium sealed boxes:
- Anyone with the public key (committed to repo) can encrypt new secrets
- Only the private key holder can decrypt
- Format:
ENC[base64_ciphertext]
Add secrets as plaintext to secret.json and run lockbox generate — they'll be encrypted automatically.
[!NOTE] Committing encrypted secrets is safe. The ciphertext in your repo is useless without the private key, which never touches git. This is the same approach used by Mozilla SOPS, Sealed Secrets, and Transcrypt. Think of it like SSH keys: the public key encrypts (safe to share), the private key decrypts (never shared). Git hooks prevent plaintext secrets from ever being committed.
Required fields
Use the **REQUIRED** sentinel in default.json to mark fields that must be set in each environment:
{ "db": { "password": "**REQUIRED**" } }lockbox validate will fail if any non-skipped environment still has **REQUIRED** values. Configure which environments skip this check in lockbox.json:
{ "skipRequiredFieldValidation": ["test"] }CLI reference
All commands respect lockbox.json for defaults. Use --dir to override.
lockbox init
Scaffold a new config directory and generate a keypair.
lockbox init --dir ./src/config --env test --env staging --env production
lockbox init --dir ./src/config --env test --env production --sub-env us-west-2 --sub-env eu-central-1lockbox generate
Encrypt plaintext secrets and generate per-environment config files (including sub-environments).
lockbox generatelockbox validate
Check that secrets are encrypted, generated files are up-to-date, and required fields are present (including sub-environments).
lockbox validateAdd to your git hooks:
{
"simple-git-hooks": {
"pre-commit": "npx lockbox validate",
"pre-push": "npx lockbox validate"
}
}lockbox set
Set a plaintext config value. Supports dot-notation for nested keys. Auto-runs generate.
lockbox set server.port 8080 --env production
lockbox set db.host localhost # writes to default.json
lockbox set db.host us-west-2.db.com --env production --sub-env us-west-2lockbox set-secret
Set a secret value. Requires --env. Auto-runs generate (which encrypts and regenerates).
lockbox set-secret db.password s3cret --env production
lockbox set-secret db.password regional-s3cret --env production --sub-env us-west-2lockbox keygen
Generate a new encryption keypair.
lockbox keygenlockbox set-private-key
Store a private key locally for CLI operations (saved to .lockbox/private-key with 600 permissions, auto-added to .gitignore).
lockbox set-private-key <base64-key>lockbox view
View the full decrypted config for an environment. Requires --env. Reads the private key from .lockbox/private-key.
lockbox view --env production
lockbox view --env production --sub-env us-west-2
lockbox view --env production --override local.jsonConfiguration
lockbox.json
Created by lockbox init in your project root:
{
"dir": "./src/config",
"importSource": "@charcoalhq/lockbox",
"skipRequiredFieldValidation": ["test"]
}| Field | Default | Description |
|---|---|---|
| dir | ./config | Path to the environments directory |
| importSource | @charcoalhq/lockbox | Package name used in generated import statements |
| skipRequiredFieldValidation | [] | Environments that skip required field checks |
createConfig options
| Option | Default | Description |
|---|---|---|
| configs | (required) | Map of environment name to imported config object |
| environment | (required) | The active environment. Must be a key in configs |
| privateKey | — | Base64 private key, or () => string \| Promise<string> resolver (e.g. from KMS). Required if config contains encrypted values |
| schema | (required) | A Standard Schema-compliant schema to validate (and optionally transform) the config after loading |
| overrides | — | Partial config object deep-merged on top after decryption, before validation |
API
import { createConfig, deepFreeze } from '@charcoalhq/lockbox';
import type { CreateConfigOptions, CreateConfigResult, LockboxConfig } from '@charcoalhq/lockbox';createConfig<T>(options)— load and decrypt config for the current environmentdeepFreeze<T>(obj)— recursively freeze an object (used by generated files)
License
MIT
