@charcoalhq/lockbox
v0.2.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
- 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 --envs test,productionThis 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
# 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.tsMerge order
For each environment, configs are deep-merged in this order:
default.json < {env}/clear.json < {env}/secret.jsonLater values override earlier ones. Objects are merged recursively; all other values (arrays, primitives, null) are replaced.
Encryption
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.
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 --envs test,staging,productionlockbox generate
Encrypt plaintext secrets and generate per-environment config files.
lockbox generatelockbox validate
Check that secrets are encrypted, generated files are up-to-date, and required fields are present.
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.jsonlockbox set-secret
Set a secret value. Requires --env. Auto-runs generate (which encrypts and regenerates).
lockbox set-secret db.password s3cret --env productionlockbox 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 productionConfiguration
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 |
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
