@yedoma-labs/turar-config
v0.3.0
Published
Type-safe configuration management with file loading, environment cascading, and secrets integration - extends bylyt-env-guard
Maintainers
Readme
@yedoma-labs/turar-config
Type-safe configuration management with file loading, environment cascading, and secrets integration. Built on @yedoma-labs/bylyt-env-guard for type-safe validation.
Features
- 📁 Multi-format config - JSON, YAML, TOML support with auto-detection
- 🌍 Environment cascading - Merge
default→{NODE_ENV}→ env vars - 🔒 Secrets integration - HashiCorp Vault, AWS Secrets Manager,
.envfiles - 🔐 Type-safe - Full TypeScript inference from schema
- ✅ Validation - Uses bylyt-env-guard's zero-dependency validation
- 🔗 Interpolation - Reference env vars with
${VAR}syntax in config files - 🔥 Hot reload - File watching with debouncing for development
- 🎯 Loose coupling - Optional dependencies for cloud integrations (AWS SDK, Vault)
Installation
npm install @yedoma-labs/bylyt-env-guard @yedoma-labs/turar-config
# or
pnpm add @yedoma-labs/bylyt-env-guard @yedoma-labs/turar-configQuick Start
1. Create config files
// config/default.json
{
"database": {
"host": "localhost",
"port": 5432,
"pool": {
"min": 2,
"max": 10
}
},
"server": {
"port": 3000
}
}// config/production.json
{
"database": {
"host": "${DB_HOST}",
"pool": {
"max": 100
}
},
"server": {
"port": 8080
}
}2. Define schema and load config
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";
const config = createConfigSync({
schema: {
database_host: eg.string().required(),
database_port: eg.integer().default(5432),
database_pool_min: eg.integer().default(2),
database_pool_max: eg.integer().default(10),
server_port: eg.port().default(3000),
},
configDir: "./config",
envFile: true, // Load .env file
prefix: "APP_", // Use APP_ prefix for env vars
});
console.log(config.database_host); // Type-safe access
console.log(config.server_port); // Type: numberConfiguration Cascading
Configuration values are merged with the following priority (highest to lowest):
- Environment variables -
process.env.APP_database_host .envfile - Values from.env(ifenvFile: true)- Secrets - HashiCorp Vault or other secrets providers (if configured)
- Environment config -
config/{NODE_ENV}.json - Base config -
config/default.json - Schema defaults -
.default()values in schema
Example Cascade
// config/default.json
{ "server": { "port": 3000, "host": "localhost" } }
// config/production.json
{ "server": { "port": 8080 } }
// process.env.NODE_ENV = "production"
// process.env.APP_server_port = "9000"
// Result:
// {
// server_port: 9000, // from env var
// server_host: "localhost" // from default.json
// }Variable Interpolation
Reference environment variables in config files using ${VAR} syntax:
{
"database": {
"url": "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/myapp"
},
"api": {
"key": "${API_KEY}"
}
}To escape interpolation, use \${VAR}:
{
"example": "This is a literal \\${NOT_INTERPOLATED}"
}API Reference
createConfig(options)
Async version supporting HashiCorp Vault secrets provider.
const config = await createConfig({
schema: { /* ... */ },
configDir: "./config",
envFile: true,
secrets: { provider: "env" },
prefix: "APP_",
strict: false,
});createConfigSync(options)
Synchronous version for simple use cases.
const config = createConfigSync({
schema: { /* ... */ },
configDir: "./config",
envFile: true,
prefix: "APP_",
strict: false,
});Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| schema | SchemaDefinition | required | Bylyt schema defining config structure |
| configDir | string | "./config" | Directory containing config files |
| envFile | boolean \| string | false | Load .env file (or custom path) |
| secrets | SecretsProviderConfig | undefined | Secrets provider config |
| prefix | string | undefined | Prefix for environment variables (e.g., "APP_") |
| strict | boolean | false | Throw on unknown prefixed env vars |
File Structure
your-project/
├── config/
│ ├── default.json # Base config (always loaded)
│ ├── development.json # Loaded when NODE_ENV=development
│ ├── test.json # Loaded when NODE_ENV=test
│ └── production.json # Loaded when NODE_ENV=production
├── .env # Optional environment file
└── src/
└── config.ts # Your config setupSecurity Considerations
✅ Safe:
- JSON files are parsed safely (no eval)
- Environment variables are never logged
- Sensitive values marked with
.sensitive()are hidden - Path traversal is prevented (resolved paths)
⚠️ Important:
- Never commit
.envfiles to version control - Use
.sensitive()for secrets in schema - Interpolation only resolves existing env vars (no code execution)
Examples
Basic Web Server
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";
const config = createConfigSync({
schema: {
port: eg.port().default(3000),
host: eg.string().default("0.0.0.0"),
database_url: eg.url().required(),
log_level: eg.enum(["debug", "info", "warn", "error"] as const).default("info"),
},
configDir: "./config",
envFile: true,
});
// Start server with type-safe config
startServer(config.host, config.port);With Prefix
const config = createConfigSync({
schema: {
database_host: eg.string(),
database_port: eg.port(),
},
prefix: "MYAPP_",
envFile: true,
});
// Reads MYAPP_database_host and MYAPP_database_port from envDeep Nesting
// config/default.json
{
"services": {
"redis": {
"cluster": {
"nodes": ["localhost:6379"]
}
}
}
}
const config = createConfigSync({
schema: {
services_redis_cluster_nodes: eg.array().separator(",").default(["localhost:6379"]),
},
configDir: "./config",
});Error Handling
import { eg, EnvValidationError } from "@yedoma-labs/bylyt-env-guard";
import {
createConfigSync,
ConfigFileError,
ConfigInterpolationError,
} from "@yedoma-labs/turar-config";
try {
const config = createConfigSync({
schema: {
required_field: eg.string().required(),
},
configDir: "./config",
});
} catch (error) {
if (error instanceof ConfigFileError) {
console.error("Failed to load config file:", error.path);
}
if (error instanceof ConfigInterpolationError) {
console.error("Undefined variable:", error.variable);
}
if (error instanceof EnvValidationError) {
console.error("Validation failed:", error.failures);
}
}Integration with bylyt-env-guard
turar-config is an orchestration layer built on top of bylyt-env-guard. Here's how they work together:
Architecture
┌─────────────────────────────────────────┐
│ Your Application │
└────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ turar-config (this package) │
│ • Load JSON files │
│ • Merge configs │
│ • Interpolate ${VAR} │
│ • Flatten objects │
└────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ bylyt-env-guard (peer dep) │
│ • Validate types │
│ • Type coercion │
│ • Type inference │
│ • Freeze result │
└─────────────────────────────────────────┘Responsibilities
| Feature | Handled By | Description |
|---------|-----------|-------------|
| File Loading | turar-config | Load config/*.json files |
| Cascading | turar-config | Merge configs by environment |
| Interpolation | turar-config | Resolve ${VAR} in JSON |
| Flattening | turar-config | {db: {host}} → {db_host} |
| Validation | bylyt-env-guard | Type checking, constraints |
| Type Safety | bylyt-env-guard | TypeScript inference |
| Freezing | bylyt-env-guard | Immutable config object |
When to Use What?
Use bylyt-env-guard alone if:
- ✅ You only need environment variables
- ✅ Simple .env file is sufficient
- ✅ No multi-environment configs
Use turar-config if:
- ✅ You have config files (JSON)
- ✅ Different configs per environment (dev/staging/prod)
- ✅ Need
${VAR}interpolation in configs - ✅ Want to centralize config management
Advanced Examples
Multi-Environment Setup
# File structure
config/
default.json # Base config (always loaded)
development.json # Local development
staging.json # Staging environment
production.json # Production// Automatically loads config/{NODE_ENV}.json
process.env.NODE_ENV = "production";
const config = createConfigSync({
schema: {
api_url: eg.url().required(),
database_pool_max: eg.integer().default(10),
},
configDir: "./config",
});
// In production: loads default.json + production.json
// Values from production.json override default.jsonExpress.js Integration
import express from "express";
import cors from "cors";
import session from "express-session";
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";
const config = createConfigSync({
schema: {
port: eg.port().default(3000),
cors_origins: eg.array().of("string"),
session_secret: eg.string().sensitive().required(),
database_url: eg.url().required(),
},
configDir: "./config",
envFile: true,
});
const app = express();
app.use(cors({ origin: config.cors_origins }));
app.use(session({ secret: config.session_secret }));
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});NestJS Integration
// config.service.ts
import { Injectable } from "@nestjs/common";
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";
@Injectable()
export class ConfigService {
private readonly config = createConfigSync({
schema: {
database_host: eg.string().required(),
database_port: eg.port().default(5432),
redis_url: eg.url().required(),
jwt_secret: eg.string().sensitive().required(),
},
configDir: "./config",
prefix: "APP_",
});
get database() {
return {
host: this.config.database_host,
port: this.config.database_port,
};
}
get redis() {
return this.config.redis_url;
}
}Nested Object Configs
// config/default.json
{
"services": {
"redis": {
"host": "localhost",
"port": 6379,
"options": {
"maxRetriesPerRequest": 3,
"enableReadyCheck": true
}
},
"database": {
"primary": { "host": "db1.local", "port": 5432 },
"replica": { "host": "db2.local", "port": 5432 }
}
}
}const config = createConfigSync({
schema: {
services_redis_host: eg.string(),
services_redis_port: eg.port(),
services_redis_options_maxRetriesPerRequest: eg.integer(),
services_database_primary_host: eg.string(),
services_database_primary_port: eg.port(),
services_database_replica_host: eg.string(),
services_database_replica_port: eg.port(),
},
configDir: "./config",
});
// Access flattened keys
const redisHost = config.services_redis_host;
const primaryDbHost = config.services_database_primary_host;Best Practices
File Structure
project/
├── config/
│ ├── default.json # Required: base config
│ ├── development.json # Optional: overrides for dev
│ ├── test.json # Optional: test environment
│ ├── staging.json # Optional: staging env
│ └── production.json # Optional: production env
├── .env # Optional: local overrides (gitignored)
├── .env.example # Commit this: documents required env vars
└── src/
└── config.ts # Config setupSecurity Guidelines
✅ DO:
- Commit
config/*.jsonfiles (non-sensitive defaults) - Commit
.env.example(template) - Use
.sensitive()for secrets in schema - Use
${VAR}interpolation for secrets in production.json - Set real secrets via environment variables
❌ DON'T:
- Commit
.envfiles (add to .gitignore) - Put secrets directly in JSON files
- Commit production credentials
- Log config values marked
.sensitive()
Performance Tips
- Use
createConfigSyncwhen possible - Faster startup - Keep config files small - Load time is linear with file size
- Minimize interpolations - Each
${VAR}is a lookup - Use flattened schemas - Avoid deep nesting (5+ levels)
Naming Conventions
// Consistent naming: lowercase with underscores
const schema = {
database_url: eg.url(), // ✅ Good
database_connection_timeout: eg.integer(), // ✅ Good
DatabaseURL: eg.url(), // ❌ Avoid
"database-url": eg.url(), // ❌ Avoid (kebab-case)
};
// Environment variable mapping
// With prefix="APP_":
// APP_database_url → config.database_url
// APP_database_connection_timeout → config.database_connection_timeoutTroubleshooting
Common Errors
"Invalid environment name"
// ❌ Error: path traversal attempt
loadConfigFiles("./config", "../etc/passwd");
// ✅ Fix: use alphanumeric names only
loadConfigFiles("./config", "production");"Undefined environment variable"
// config/production.json
{ "api_key": "${API_KEY}" }# ❌ Error: API_KEY not set
NODE_ENV=production node app.js
# ✅ Fix: set env var first
API_KEY=secret123 NODE_ENV=production node app.js"Config file must contain a JSON object"
// ❌ Invalid: array at root
["value1", "value2"]
// ✅ Valid: object at root
{ "values": ["value1", "value2"] }Debugging Config Loading
import { createConfigSync } from "@yedoma-labs/turar-config";
try {
const config = createConfigSync({
schema: { /* ... */ },
configDir: "./config",
});
// Log loaded config (excluding sensitive values)
console.log("Config loaded:", JSON.stringify(config, null, 2));
} catch (error) {
if (error instanceof ConfigFileError) {
console.error("Failed to load:", error.path);
console.error("Reason:", error.message);
} else if (error instanceof ConfigInterpolationError) {
console.error("Undefined variable:", error.variable);
} else if (error instanceof EnvValidationError) {
console.error("Validation failures:");
for (const failure of error.failures) {
console.error(` ${failure.field}: ${failure.message}`);
}
}
}Environment Variable Priority
If a value isn't what you expect, check the priority order:
# Priority (highest to lowest):
1. process.env.APP_database_host # Direct env var
2. .env file: APP_database_host=... # .env file (if envFile: true)
3. config/production.json # Environment config
4. config/default.json # Base config
5. schema: eg.string().default(...) # Schema defaultMigration Guides
See docs/migration.md for detailed guides:
YAML and TOML Support
turar-config automatically detects and loads configuration files in multiple formats:
Supported Formats
- JSON (
.json) - Traditional format - YAML (
.yaml,.yml) - Human-friendly, great for complex configs ✨ NEW - TOML (
.toml) - Minimal, clear syntax ✨ NEW
Format Priority
When multiple formats exist, files are loaded in this order:
.yaml/.yml.json.toml
Example:
config/
default.yaml # ← Loaded (YAML has priority)
default.json # Ignored (YAML exists)
production.toml # ← Loaded (no YAML/JSON for production)YAML Example
# config/default.yaml
server:
host: localhost
port: 3000
database:
host: localhost
port: 5432
pool:
min: 2
max: 10
features:
enableMetrics: false
enableDebug: true# config/production.yaml
server:
host: 0.0.0.0
port: 8080
database:
host: ${DB_HOST}
name: ${DB_NAME}
pool:
max: 100
features:
enableMetrics: true
enableDebug: falseTOML Example
# config/default.toml
[server]
host = "localhost"
port = 3000
[database]
host = "localhost"
port = 5432
name = "myapp_dev"
[database.pool]
min = 2
max = 10
[features]
enableMetrics = false
enableDebug = true# config/production.toml
[server]
host = "0.0.0.0"
port = 8080
[database]
host = "${DB_HOST}"
name = "${DB_NAME}"
[database.pool]
max = 100
[features]
enableMetrics = true
enableDebug = falseUsage
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfigSync } from "@yedoma-labs/turar-config";
// Automatically detects YAML/TOML/JSON
const config = createConfigSync({
schema: {
server_host: eg.string(),
server_port: eg.port(),
database_host: eg.string(),
features_enableMetrics: eg.boolean(),
},
configDir: "./config", // Looks for .yaml, .yml, .toml, .json
});Why YAML/TOML?
YAML Benefits:
- ✅ No quotes needed for strings
- ✅ Comments with
# - ✅ Multi-line strings with
|or> - ✅ More readable for complex nested configs
- ✅ Industry standard (Kubernetes, Docker Compose, GitHub Actions)
TOML Benefits:
- ✅ Clear, minimal syntax
- ✅ Tables for nested structures
- ✅ Explicit types (no ambiguity like YAML's Norway problem)
- ✅ Good for flat configs
- ✅ Popular in Rust ecosystem (Cargo.toml)
JSON Benefits:
- ✅ Universal support
- ✅ Strict syntax (no surprises)
- ✅ Easy to generate programmatically
- ✅ Good for machine-generated configs
Config File Watching (Hot Reload)
Watch config files for changes and automatically reload configuration in development. Perfect for updating feature flags, connection strings, or other settings without restarting your app.
Basic Usage
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { watchConfig } from "@yedoma-labs/turar-config";
const handle = await watchConfig({
schema: {
server_port: eg.port().default(3000),
database_host: eg.string().required(),
features_enableDebug: eg.boolean().default(false),
},
configDir: "./config",
onChange: (newConfig, change, oldConfig) => {
console.log(`Config changed: ${change.type} ${change.path}`);
console.log(`Debug mode: ${oldConfig.features_enableDebug} → ${newConfig.features_enableDebug}`);
// Update your app with new config
updateFeatureFlags(newConfig);
},
debounce: 500, // Wait 500ms after last change before reloading
});
// Get current config at any time
const currentConfig = handle.getConfig();
// Stop watching when done
await handle.stop();Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| onChange | function | undefined | Callback when config changes |
| debounce | number | 500 | Milliseconds to wait after last change |
| ignoreInitial | boolean | true | Don't trigger onChange on startup |
| All config options | | | Same as createConfig() |
onChange Callback
type OnChange = (
newConfig: ConfigResult,
change: ConfigChange,
oldConfig: ConfigResult
) => void;
interface ConfigChange {
type: "added" | "changed" | "removed";
path: string; // Full path to changed file
timestamp: Date; // When change was detected
}Watch Handle
interface WatchHandle {
stop(): Promise<void>; // Stop watching
getConfig(): ConfigResult; // Get current config
}Use Cases
Feature Flags
const handle = await watchConfig({
schema: {
features_newUI: eg.boolean().default(false),
features_betaFeatures: eg.boolean().default(false),
},
configDir: "./config",
onChange: (newConfig) => {
// Update feature flags without restart
featureFlags.update(newConfig);
},
});Database Connection Pool
const handle = await watchConfig({
schema: {
database_pool_min: eg.integer().default(2),
database_pool_max: eg.integer().default(10),
},
configDir: "./config",
onChange: (newConfig, change, oldConfig) => {
if (
newConfig.database_pool_max !== oldConfig.database_pool_max ||
newConfig.database_pool_min !== oldConfig.database_pool_min
) {
// Reconfigure pool without restart
databasePool.reconfigure({
min: newConfig.database_pool_min,
max: newConfig.database_pool_max,
});
}
},
});Development Mode Only
let handle: WatchHandle | null = null;
if (process.env.NODE_ENV === "development") {
handle = await watchConfig({
schema: mySchema,
configDir: "./config",
onChange: (newConfig) => {
console.log("🔄 Config reloaded:", newConfig);
},
});
}
// Cleanup on shutdown
process.on("SIGTERM", async () => {
if (handle) await handle.stop();
});What Files are Watched?
- All
.json,.yaml,.yml, and.tomlfiles inconfigDir - Recursive (watches subdirectories too)
- Changes, additions, and deletions are detected
- Uses efficient file system events (not polling)
Performance Notes
- Debouncing prevents excessive reloads during rapid changes
- Minimal overhead - uses native file system events
- Safe - validates config before calling
onChange - Graceful errors - logs errors without crashing your app
Best Practices
✅ Use in development only - Production apps should restart on config changes
✅ Set appropriate debounce - 500ms works well for most cases
✅ Handle partial updates - Check what changed before applying
✅ Test error scenarios - Ensure app handles invalid config gracefully
✅ Stop watching on shutdown - Call handle.stop() in cleanup hooks
HashiCorp Vault Integration
Load secrets from HashiCorp Vault at runtime. Supports both token and AppRole authentication.
Quick Start
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig } from "@yedoma-labs/turar-config";
const config = await createConfig({
schema: {
database_host: eg.string().required(),
database_password: eg.string().required(),
api_key: eg.string().required(),
},
configDir: "./config",
secrets: {
provider: "vault",
vault: {
url: "https://vault.example.com",
auth: {
type: "token",
token: process.env.VAULT_TOKEN!,
},
path: "myapp/config",
},
},
});
console.log(config.database_password); // Secret from VaultToken Authentication
Simplest method - use a Vault token directly:
const config = await createConfig({
schema: { /* ... */ },
secrets: {
provider: "vault",
vault: {
url: "https://vault.example.com",
auth: {
type: "token",
token: process.env.VAULT_TOKEN!, // Read from environment
},
path: "myapp/production/config",
mountPath: "secret", // Optional, defaults to "secret"
namespace: "my-namespace", // Optional, for Vault Enterprise
},
},
});AppRole Authentication
Recommended for production - use AppRole for better security:
const config = await createConfig({
schema: { /* ... */ },
secrets: {
provider: "vault",
vault: {
url: "https://vault.example.com",
auth: {
type: "appRole",
roleId: process.env.VAULT_ROLE_ID!,
secretId: process.env.VAULT_SECRET_ID!,
},
path: "myapp/production/config",
},
},
});Vault Configuration Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| url | string | Yes | Vault server URL |
| auth | VaultAuth | Yes | Authentication config |
| path | string | Yes | Path to secrets in KV store |
| mountPath | string | No | KV mount path (default: "secret") |
| namespace | string | No | Vault namespace (Enterprise only) |
Authentication Types
Token Auth:
auth: {
type: "token",
token: string,
}AppRole Auth:
auth: {
type: "appRole",
roleId: string,
secretId: string,
}Nested Secrets
Vault secrets are automatically flattened using underscore separators:
Vault KV Store:
// vault kv get secret/myapp/config
{
"database": {
"host": "db.example.com",
"credentials": {
"username": "admin",
"password": "secret123"
}
},
"api_key": "key-xyz"
}Flattened Config:
const config = await createConfig({
schema: {
database_host: eg.string().required(),
database_credentials_username: eg.string().required(),
database_credentials_password: eg.string().required(),
api_key: eg.string().required(),
},
secrets: { /* vault config */ },
});
console.log(config.database_host); // "db.example.com"
console.log(config.database_credentials_username); // "admin"
console.log(config.api_key); // "key-xyz"Priority Order
Secrets from Vault have higher priority than config files but lower priority than environment variables:
Environment Variables (highest priority)
↓
Vault Secrets
↓
.env file
↓
Config Files (lowest priority)Example:
// config/default.json
{ "api_key": "dev-key" }
// Vault secret
{ "api_key": "vault-key" }
// Environment variable
export API_KEY="prod-key"
// Result:
config.api_key === "prod-key" // Environment winsLegacy Configuration (Deprecated)
For backward compatibility, you can use the legacy flat configuration:
secrets: {
provider: "vault",
vaultUrl: "https://vault.example.com",
vaultToken: process.env.VAULT_TOKEN,
vaultPath: "myapp/config",
}⚠️ Legacy config only supports token auth. Use the new structured config for AppRole support.
Setting Up Vault
1. Enable KV v2 Secrets Engine:
vault secrets enable -path=secret kv-v22. Write Secrets:
vault kv put secret/myapp/config \
database_password="secret123" \
api_key="key-xyz"3. Create Token (Dev/Testing):
vault token create -policy=myapp-read4. Configure AppRole (Production):
# Enable AppRole
vault auth enable approle
# Create role
vault write auth/approle/role/myapp \
token_policies="myapp-read" \
token_ttl=1h \
token_max_ttl=4h
# Get role_id
vault read auth/approle/role/myapp/role-id
# Generate secret_id
vault write -f auth/approle/role/myapp/secret-idError Handling
Vault errors are wrapped in ConfigSecretError:
import { ConfigSecretError } from "@yedoma-labs/turar-config";
try {
const config = await createConfig({
schema: { api_key: eg.string().required() },
secrets: { provider: "vault", /* ... */ },
});
} catch (error) {
if (error instanceof ConfigSecretError) {
console.error("Vault error:", error.message);
// Handle: connection failure, auth error, missing secrets, etc.
}
}Security Best Practices
✅ Never commit tokens - Use environment variables
✅ Use AppRole in production - More secure than long-lived tokens
✅ Rotate secrets regularly - Vault supports automatic rotation
✅ Use short TTLs - Limit token lifetime
✅ Enable audit logging - Track all secret access
✅ Use namespaces - Isolate environments (Vault Enterprise)
Troubleshooting
Connection Refused:
# Check Vault is running
curl -s https://vault.example.com/v1/sys/health
# Check network connectivity
telnet vault.example.com 8200Permission Denied:
# Verify token has read permissions
vault token lookup
# Check policy allows reading the path
vault policy read myapp-readSecrets Not Found:
# Verify secrets exist
vault kv get secret/myapp/config
# Check mount path is correct
vault secrets listComplete Example
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig, ConfigSecretError } from "@yedoma-labs/turar-config";
async function loadConfig() {
try {
const config = await createConfig({
schema: {
// From config files
server_port: eg.port().default(3000),
server_host: eg.string().default("0.0.0.0"),
// From Vault
database_host: eg.string().required(),
database_port: eg.port().required(),
database_username: eg.string().required(),
database_password: eg.string().required(),
api_key: eg.string().required(),
},
configDir: "./config",
secrets: {
provider: "vault",
vault: {
url: process.env.VAULT_ADDR || "http://localhost:8200",
auth: {
type: "appRole",
roleId: process.env.VAULT_ROLE_ID!,
secretId: process.env.VAULT_SECRET_ID!,
},
path: `myapp/${process.env.NODE_ENV}/config`,
namespace: process.env.VAULT_NAMESPACE,
},
},
});
console.log("✅ Configuration loaded successfully");
return config;
} catch (error) {
if (error instanceof ConfigSecretError) {
console.error("❌ Vault error:", error.message);
process.exit(1);
}
throw error;
}
}
const config = await loadConfig();
// Use config
const app = express();
app.listen(config.server_port);AWS Secrets Manager Integration
Load secrets from AWS Secrets Manager at runtime. Supports IAM roles, explicit credentials, and custom endpoints.
Installation
AWS Secrets Manager integration requires the AWS SDK:
npm install @aws-sdk/client-secrets-manager
# or
pnpm add @aws-sdk/client-secrets-managerNote: This is an optional dependency. Only install if you use AWS Secrets Manager.
Quick Start
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig } from "@yedoma-labs/turar-config";
const config = await createConfig({
schema: {
database_host: eg.string().required(),
database_password: eg.string().required(),
api_key: eg.string().required(),
},
configDir: "./config",
secrets: {
provider: "aws-secrets-manager",
aws: {
region: "us-east-1",
secretName: "myapp/production/config",
},
},
});
console.log(config.database_password); // Secret from AWSIAM Role Authentication (Recommended)
In production, use IAM roles instead of explicit credentials:
const config = await createConfig({
schema: { /* ... */ },
secrets: {
provider: "aws-secrets-manager",
aws: {
region: "us-east-1",
secretName: "myapp/production/config",
// No credentials needed - uses IAM role
},
},
});IAM Policy Example:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/*"
}]
}Explicit Credentials
For development or CI/CD:
const config = await createConfig({
schema: { /* ... */ },
secrets: {
provider: "aws-secrets-manager",
aws: {
region: "us-east-1",
secretName: "myapp/dev/config",
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
},
});Temporary Credentials
For assumed roles or session tokens:
aws: {
region: "us-east-1",
secretName: "myapp/config",
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
sessionToken: process.env.AWS_SESSION_TOKEN,
}LocalStack Testing
Test with LocalStack locally:
const config = await createConfig({
schema: { /* ... */ },
secrets: {
provider: "aws-secrets-manager",
aws: {
region: "us-east-1",
secretName: "test/config",
endpoint: "http://localhost:4566", // LocalStack endpoint
accessKeyId: "test",
secretAccessKey: "test",
},
},
});Docker Compose for LocalStack:
services:
localstack:
image: localstack/localstack:latest
environment:
- SERVICES=secretsmanager
ports:
- "4566:4566"Configuration Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| region | string | Yes | AWS region (e.g., us-east-1, eu-west-2) |
| secretName | string | Yes | Secret name or ARN |
| endpoint | string | No | Custom endpoint (for LocalStack/testing) |
| accessKeyId | string | No | AWS access key (use IAM roles instead) |
| secretAccessKey | string | No | AWS secret key (use IAM roles instead) |
| sessionToken | string | No | Session token for temporary credentials |
Secret Format
AWS secrets must be JSON objects:
{
"database_host": "prod-db.example.com",
"database_password": "secret123",
"api_key": "key-xyz"
}Nested secrets are automatically flattened:
{
"database": {
"host": "prod-db.example.com",
"credentials": {
"username": "admin",
"password": "secret123"
}
}
}Becomes:
config.database_host // "prod-db.example.com"
config.database_credentials_username // "admin"
config.database_credentials_password // "secret123"Priority Order
AWS secrets have the same priority as Vault:
Environment Variables (highest priority)
↓
.env file
↓
AWS Secrets
↓
Config Files (lowest priority)Security Best Practices
✅ Use IAM roles - Don't hardcode credentials
✅ Enable secret rotation - Automatic password rotation
✅ Use KMS encryption - Encrypt secrets at rest
✅ Least privilege - Only grant GetSecretValue permission
✅ Use resource tags - Organize and control access
✅ Enable CloudTrail - Audit all secret access
✅ Short-lived credentials - Use temporary session tokens
Error Handling
import { ConfigSecretError } from "@yedoma-labs/turar-config";
try {
const config = await createConfig({
schema: { api_key: eg.string().required() },
secrets: {
provider: "aws-secrets-manager",
aws: { region: "us-east-1", secretName: "app/config" },
},
});
} catch (error) {
if (error instanceof ConfigSecretError) {
console.error("AWS error:", error.message);
// Handle: permission denied, secret not found, etc.
}
}Common Errors
AccessDeniedException:
Failed to load secrets from AWS Secrets Manager:
User is not authorized to perform secretsmanager:GetSecretValueFix: Attach IAM policy with GetSecretValue permission.
ResourceNotFoundException:
Failed to load secrets from AWS Secrets Manager:
Secrets Manager can't find the specified secretFix: Verify secret name and region.
InvalidRequestException:
AWS Secrets Manager secret 'myapp/config' is not valid JSONFix: Ensure secret contains valid JSON object.
Creating Secrets
CLI:
aws secretsmanager create-secret \
--name myapp/production/config \
--secret-string '{"database_password":"secret123","api_key":"key-xyz"}'Console:
- Go to AWS Secrets Manager
- Click "Store a new secret"
- Select "Other type of secret"
- Add key-value pairs
- Name:
myapp/production/config - Enable automatic rotation (optional)
Comparison: AWS vs Vault
| Feature | AWS Secrets Manager | HashiCorp Vault | |---------|---------------------|------------------| | Cloud native | ✅ AWS only | ✅ Any cloud/on-prem | | Managed service | ✅ Fully managed | ❌ Self-hosted | | Auto rotation | ✅ Built-in | ✅ Supported | | Cost | $0.40/secret/month + API calls | Infrastructure + maintenance | | Setup | Easy (IAM) | Complex (install, configure) | | Access control | IAM policies | ACL policies | | Best for | AWS-native apps | Multi-cloud, on-prem |
Complete Example
import { eg } from "@yedoma-labs/bylyt-env-guard";
import { createConfig, ConfigSecretError } from "@yedoma-labs/turar-config";
async function loadConfig() {
try {
const config = await createConfig({
schema: {
// From config files
server_port: eg.port().default(3000),
server_host: eg.string().default("0.0.0.0"),
// From AWS Secrets Manager
database_host: eg.string().required(),
database_port: eg.port().required(),
database_username: eg.string().required(),
database_password: eg.string().required(),
api_key: eg.string().required(),
},
configDir: "./config",
secrets: {
provider: "aws-secrets-manager",
aws: {
region: process.env.AWS_REGION || "us-east-1",
secretName: `myapp/${process.env.NODE_ENV}/config`,
},
},
});
console.log("✅ Configuration loaded successfully");
return config;
} catch (error) {
if (error instanceof ConfigSecretError) {
console.error("❌ AWS Secrets Manager error:", error.message);
process.exit(1);
}
throw error;
}
}
const config = await loadConfig();
// Use config
const app = express();
app.listen(config.server_port);Roadmap
- [x] HashiCorp Vault integration ✅
- [x] AWS Secrets Manager support ✅
- [x] YAML config file support ✅
- [x] TOML config file support ✅
- [x] Config file watching / hot reload ✅
- [ ] Config migration helpers
License
MIT
Related Projects
- @yedoma-labs/bylyt-env-guard - Zero-dependency env validation
