Environment Configuration and Secrets Management — Secure Application Settings
In this tutorial, you'll learn about Environment Configuration and Secrets Management. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Environment configuration is the practice of managing application settings across different environments (development, staging, production) using environment variables, configuration files, and secure secrets management systems.
What You'll Learn
By the end of this tutorial, you will implement environment-specific configuration in Python and Node.js, manage secrets with HashiCorp Vault and AWS Secrets Manager, avoid common configuration pitfalls, and build a secure CI/CD pipeline for secrets.
Why It Matters
Hardcoded secrets and environment-specific values cause security breaches, deployment failures, and environment drift. Doda Browser uses environment-based configuration to run the same code across development, staging, and production without modification, while storing secrets in a vault.
Real-World Use
A developer commits a configuration file with a production database password to a public GitHub Repository. Within hours, the database is compromised. With proper secrets management, production credentials never appear in code, CI/CD logs, or developer workstations.
Configuration Architecture
Graph TD subgraph "Configuration Sources" A[.env file] --> D[Application] B[Environment Variables] --> D C[Vault/Secrets Manager] --> D end subgraph "Environments" E[Development] --> A F[Staging] --> B G[Production] --> B G --> C end subgraph "Validation" D --> H[Pydantic/Zod Schema] H --> I[Config Object] end style C fill:#f90,color:#fff style H fill:#4CAF50,color:#fff
Configuration is loaded from multiple sources in order of precedence: hardcoded defaults, .env files, environment variables, and vault secrets. A schema validates the final configuration and presents it as a typed config object.
Table: Configuration Approaches
| Method | Security | Flexibility | Use Case |
|---|---|---|---|
| .env files | Low | High | Development |
| OS Env Vars | Medium | High | Containerized apps |
| Config files | Low | Medium | Static settings |
| Vault (HashiCorp) | High | Medium | Production secrets |
| AWS Secrets Manager | High | Low | AWS-native apps |
| Kubernetes Secrets | High | Low | K8s-native apps |
| Encrypted Config | High | Low | Compliance |
Python: Configuration with Pydantic
# config.py
# Environment-aware configuration with Pydantic settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, field_validator, SecretStr
from typing import Optional, Dict, List
from enum import Enum
import JSON
class Environment(str, Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
class LoggingConfig(BaseSettings):
"""Logging configuration sub-model."""
level: str = Field(default="INFO", pattern=R"^(DEBUG|INFO|WARN|ERROR)$")
format: str = Field(default="JSON", pattern=R"^(JSON|text)$")
correlation_id_enabled: bool = True
Sentry_dsn: Optional[SecretStr] = None
class DatabaseConfig(BaseSettings):
"""Database configuration with pooled connections."""
host: str = "localhost"
port: int = 5432
name: str = "myapp"
user: str = "postgres"
password: SecretStr = Field(default="postgres")
pool_min: int = 2
pool_max: int = 10
pool_timeout: int = 30
@property
def URL(self) -> str:
"""Construct database URL from parts."""
return f"PostgreSQL://{self.user}:{self.password.get_secret_value()}@{self.host}:{self.port}/{self.name}"
@property
def URL_async(self) -> str:
return f"PostgreSQL+asyncpg://{self.user}:{self.password.get_secret_value()}@{self.host}:{self.port}/{self.name}"
class RedisConfig(BaseSettings):
"""Redis cache configuration."""
host: str = "localhost"
port: int = 6379
db: int = 0
password: Optional[SecretStr] = None
@property
def URL(self) -> str:
if self.password:
return f"Redis://:{self.password.get_secret_value()}@{self.host}:{self.port}/{self.db}"
return f"Redis://{self.host}:{self.port}/{self.db}"
class AppConfig(BaseSettings):
"""Root application configuration loaded from environment."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
env_nested_delimiter="__",
extra="ignore",
)
# Core
environment: Environment = Environment.DEVELOPMENT
debug: bool = False
secret_key: SecretStr = Field(..., min_length=32)
# Application
app_name: str = "myapp"
app_version: str = "1.0.0"
host: str = "0.0.0.0"
port: int = 8000
allowed_hosts: List[str] = ["*"]
CORS_origins: List[str] = ["HTTP://localhost:3000"]
# Sub-configs
database: DatabaseConfig = DatabaseConfig()
Redis: RedisConfig = RedisConfig()
logging: LoggingConfig = LoggingConfig()
# Feature flags
enable_rate_limiting: bool = True
enable_circuit_breaker: bool = True
enable_audit_logging: bool = Field(default=False, alias="AUDIT_LOGGING_ENABLED")
maintenance_mode: bool = False
@field_validator("allowed_hosts", mode="before")
@classmethod
def parse_allowed_hosts(cls, v):
"""Parse comma-separated list from env variable."""
if isinstance(v, str):
return [host.strip() for host in v.split(",")]
return v
@field_validator("debug")
@classmethod
def debug_only_in_development(cls, v, info):
"""Prevent debug mode in production."""
if v and info.data.get("environment") == Environment.PRODUCTION:
raise ValueError("Debug mode is not allowed in production")
return v
def is_production(self) -> bool:
return self.environment == Environment.PRODUCTION
def is_development(self) -> bool:
return self.environment == Environment.DEVELOPMENT
# Load configuration
config = AppConfig()
# Usage
print(f"Environment: {config.environment.value}")
print(f"Database host: {config.database.host}")
print(f"Debug: {config.debug}")
print(f"Rate Limiting: {config.enable_rate_limiting}")
Expected output:
Environment: development
Database host: localhost
Debug: False
Rate Limiting: True
Node.js: Configuration with Zod
// config.ts
// Type-safe environment configuration with Zod
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const EnvironmentSchema = z.enum(['development', 'staging', 'production']);
const LogLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
const DatabaseConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.coerce.number().default(5432),
name: z.string().default('myapp'),
user: z.string().default('postgres'),
password: z.string().default('postgres'),
poolMin: z.coerce.number().default(2),
poolMax: z.coerce.number().default(10),
ssl: z.boolean().default(false),
}).transform((db) => ({
...db,
url: `postgresql://${db.user}:${db.password}@${db.host}:${db.port}/${db.name}${db.ssl ? '?sslmode=require' : ''}`,
}));
const RedisConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.coerce.number().default(6379),
db: z.coerce.number().default(0),
password: z.string().optional(),
}).transform((r) => ({
...r,
url: r.password
? `redis://:${r.password}@${r.host}:${r.port}/${r.db}`
: `redis://${r.host}:${r.port}/${r.db}`,
}));
const AppConfigSchema = z.object({
environment: EnvironmentSchema.default('development'),
debug: z.boolean().default(false),
port: z.coerce.number().default(3000),
host: z.string().default('0.0.0.0'),
secretKey: z.string().min(32, 'Secret key must be at least 32 characters'),
allowedHosts: z.string().default('*').transform((s) => s.split(',')),
corsOrigins: z.string().default('http://localhost:3000').transform((s) => s.split(',')),
database: DatabaseConfigSchema,
redis: RedisConfigSchema,
logLevel: LogLevelSchema.default('info'),
enableRateLimiting: z.boolean().default(true),
enableAuditLogging: z.boolean().default(false),
maintenanceMode: z.boolean().default(false),
});
function loadConfig() {
const raw = {
environment: process.env.NODE_ENV,
debug: process.env.DEBUG,
port: process.env.PORT,
host: process.env.HOST,
secretKey: process.env.SECRET_KEY,
allowedHosts: process.env.ALLOWED_HOSTS,
corsOrigins: process.env.CORS_ORIGINS,
database: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
name: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
poolMin: process.env.DB_POOL_MIN,
poolMax: process.env.DB_POOL_MAX,
ssl: process.env.DB_SSL,
},
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
db: process.env.REDIS_DB,
password: process.env.REDIS_PASSWORD,
},
logLevel: process.env.LOG_LEVEL,
enableRateLimiting: process.env.ENABLE_RATE_LIMITING,
enableAuditLogging: process.env.AUDIT_LOGGING_ENABLED,
maintenanceMode: process.env.MAINTENANCE_MODE,
};
const result = AppConfigSchema.safeParse(raw);
if (!result.success) {
console.error('Configuration validation failed:');
result.error.issues.forEach((issue) => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1);
}
return result.data;
}
const config = loadConfig();
export default config;
Vault Integration
# vault_config.py
# Fetch secrets from HashiCorp Vault
import hvac
import json
from typing import Dict, Any, Optional
class VaultClient:
"""Client for fetching secrets from HashiCorp Vault."""
def __init__(self, vault_addr: str, vault_token: str):
self.client = hvac.Client(url=vault_addr, token=vault_token)
def get_secret(self, path: str, mount_point: str = "secret") -> Dict[str, Any]:
"""Fetch a secret from Vault's KV store."""
try:
response = self.client.secrets.kv.v2.read_secret_version(
path=path,
mount_point=mount_point,
)
return response['data']['data']
except Exception as e:
print(f"Failed to fetch secret from Vault: {e}")
raise
def get_database_credentials(self) -> Dict[str, str]:
"""Fetch dynamic database credentials from Vault."""
try:
response = self.client.secrets.database.generate_credentials(
name='myapp-db-role',
mount_point='database',
)
return {
'username': response['data']['username'],
'password': response['data']['password'],
'lease_duration': response['data']['lease_duration'],
'lease_id': response['lease_id'],
}
except Exception as e:
print(f"Failed to generate DB credentials: {e}")
raise
def renew_lease(self, lease_id: str, increment: int = 3600):
"""Renew a Vault lease before it expires."""
try:
self.client.sys.renew_lease(lease_id=lease_id, increment=increment)
print(f"Lease {lease_id} renewed for {increment}s")
except Exception as e:
print(f"Failed to renew lease: {e}")
# AWS Secrets Manager alternative
import boto3
from botocore.exceptions import ClientError
class AWSSecretsManager:
"""Fetch secrets from AWS Secrets Manager."""
def __init__(self, region_name: str = "us-east-1"):
self.client = boto3.client('secretsmanager', region_name=region_name)
def get_secret(self, secret_name: str) -> Dict[str, Any]:
"""Fetch a secret from AWS Secrets Manager."""
try:
response = self.client.get_secret_value(SecretId=secret_name)
if 'SecretString' in response:
return json.loads(response['SecretString'])
return {'secret_binary': response['SecretBinary']}
except ClientError as e:
print(f"Failed to fetch secret {secret_name}: {e}")
raise
def rotate_secret(self, secret_name: str):
"""Trigger immediate secret rotation."""
try:
self.client.rotate_secret(SecretId=secret_name)
print(f"Secret {secret_name} rotation initiated")
except ClientError as e:
print(f"Failed to rotate secret: {e}")
Environment-Specific Configuration
# config/development.yaml
environment: development
debug: true
database:
host: localhost
port: 5432
name: myapp_dev
pool_min: 2
pool_max: 5
redis:
host: localhost
port: 6379
db: 0
logging:
level: DEBUG
format: text
cors_origins:
- http://localhost:3000
- http://localhost:5173
enable_rate_limiting: false
# config/production.YAML
environment: production
debug: false
database:
host: ${DB_HOST}
port: 5432
name: myapp_prod
pool_min: 5
pool_max: 20
ssl: true
Redis:
host: ${Redis_HOST}
port: 6379
db: 0
logging:
level: INFO
format: JSON
CORS_origins:
- HTTPS://app.dodatech.com
- HTTPS://admin.dodatech.com
enable_rate_limiting: true
enable_circuit_breaker: true
Common Errors
1. Committing Secrets to Version Control
Accidentally committing .env files, configuration files with passwords, or hardcoded API keys to Git is the most common security breach. Add .env to .gitignore. Use .env.example with placeholder values. Scan commits for secrets with tools like Git-secrets or truffleHog.
2. Using the Same Configuration for All Environments
Development has different database credentials, log levels, and feature flags than production. Using the same config everywhere causes development changes to affect production. Use environment-specific config files and validate the environment before loading.
3. No Configuration Validation
A missing environment variable causes cryptic errors at runtime (AttributeError, undefined). Validate all configuration at startup with a schema (Pydantic, Zod). Fail Fast with clear error messages listing which variables are missing or invalid.
4. Hardcoding Feature Flags
Feature flags in if-statements scattered across the codebase are hard to find and remove. Centralize feature flags in the configuration object. Use a feature flag service (LaunchDarkly, Unleash) for runtime toggling without deployment.
5. Static Secrets Without Rotation
Database passwords that never change are a security risk. Use dynamic secrets (Vault database credentials with TTL) or rotate secrets regularly (AWS Secrets Manager automatic rotation). Update applications to handle credential refresh without restarts.
6. Configuration Drift
Over time, development configurations diverge from production, leading to "it works on my machine" problems. Use Docker Compose for consistent local development. Sync configuration schemas across environments. Use infrastructure-as-code (Terraform) for production config.
Practice Questions
1. Why should secrets never be stored in environment variables of the Process?
Environment variables are visible to all child processes and can be read via /proc/self/environ or ps auxwwe. They are logged by CI/CD systems and error reporting tools. Use a vault or secrets manager for production secrets.
2. What is the principle of Least Privilege for configuration?
Each environment should only have access to the secrets it needs. Development should not have production database credentials. Staging should use synthetic data, not real customer data. Access to production secrets should require elevated permissions and be audited.
3. How do you handle configuration for a 12-factor app?
The 12-factor app stores configuration in environment variables. Never group environment variables into a single config file. Each variable is independent and managed separately. This simplifies deployment across environments.
4. What is the difference between configuration and secrets?
Configuration includes non-sensitive settings (log level, port number, feature flags). Secrets include sensitive values (passwords, API keys, certificates). Secrets require encryption at REST and in transit, access control, rotation, and audit logging.
5. Challenge: Design a Configuration Management system for a Microservices application with 10 services running across development, staging, and production in Kubernetes. Each service needs: (1) environment-specific values for database URLs, Redis hosts, and API keys (2) secrets stored in HashiCorp Vault with automatic rotation (3) configuration validation at startup with clear error messages (4) feature flags that can be toggled without redeployment (5) audit logging of all configuration changes. Implement Vault Agent Sidecar for injecting secrets into pods. Store non-sensitive config in Kubernetes ConfigMaps. Use Helm templates for environment-specific values.
Mini Project: Configuration Validation CLI
# config_validator.py
# Command-line configuration validation tool
import os
import sys
import re
from typing import Dict, List, Optional
class ConfigValidator:
"""Validate environment configuration before application starts."""
REQUIRED_VARS = {
'DATABASE_URL': {
'pattern': R'^PostgreSQL://.+:.+@.+:\d+/.+$',
'description': 'PostgreSQL connection string',
'sensitive': True,
},
'SECRET_KEY': {
'min_length': 32,
'description': 'Django/Flask secret key (32+ chars)',
'sensitive': True,
},
'Redis_URL': {
'pattern': R'^Redis://.+:\d+/\d+$',
'description': 'Redis connection string',
'sensitive': False,
},
'APP_ENV': {
'allowed': ['development', 'staging', 'production'],
'description': 'Application environment',
'sensitive': False,
},
}
OPTIONAL_VARS = {
'LOG_LEVEL': {
'allowed': ['DEBUG', 'INFO', 'WARN', 'ERROR'],
'default': 'INFO',
'description': 'Logging level',
},
'CORS_ORIGINS': {
'description': 'Comma-separated allowed CORS origins',
'sensitive': False,
},
}
def __init__(self, env_file: Optional[str] = None):
self.errors: List[str] = []
self.warnings: List[str] = []
if env_file and os.path.exists(env_file):
self._load_env_file(env_file)
def _load_env_file(self, path: str):
"""Load variables from .env file."""
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, value = line.split('=', 1)
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
def validate_all(self) -> bool:
"""Validate all required and optional variables."""
all_valid = True
# Check required variables
for var_name, rules in self.REQUIRED_VARS.items():
value = os.getenv(var_name)
if not value:
self.errors.append(f"MISSING: {var_name} - {rules['description']}")
all_valid = False
continue
if 'pattern' in rules and not re.match(rules['pattern'], value):
self.errors.append(
f"INVALID: {var_name} does not match required pattern"
)
all_valid = False
if 'min_length' in rules and len(value) < rules['min_length']:
self.errors.append(
f"INVALID: {var_name} must be at least {rules['min_length']} characters"
)
all_valid = False
if 'allowed' in rules and value not in rules['allowed']:
self.errors.append(
f"INVALID: {var_name} must be one of: {', '.join(rules['allowed'])}"
)
all_valid = False
# Check optional variables
for var_name, rules in self.OPTIONAL_VARS.items():
value = os.getenv(var_name)
if not value:
if 'default' in rules:
self.warnings.append(
f"OPTIONAL: {var_name} not set, using default: {rules['default']}"
)
continue
if 'allowed' in rules and value not in rules['allowed']:
self.warnings.append(
f"WARNING: {var_name} = {value} (expected one of: {', '.join(rules['allowed'])})"
)
return all_valid
def print_report(self):
"""Print validation results."""
print("=== Configuration Validation Report ===\n")
if not self.errors and not self.warnings:
print("All configuration values are valid.")
return
if self.errors:
print("ERRORS (must fix):")
for err in self.errors:
print(f" [!!] {err}")
print()
if self.warnings:
print("WARNINGS (recommended fixes):")
for warn in self.warnings:
print(f" [!] {warn}")
print()
if self.errors:
print(f"Validation FAILED: {len(self.errors)} error(s) found")
else:
print(f"Validation PASSED with {len(self.warnings)} warning(s)")
# Usage
validator = ConfigValidator(env_file=".env")
if not validator.validate_all():
validator.print_report()
sys.exit(1)
else:
validator.print_report()
print("Starting application...")
FAQ
Related Concepts
What's Next
You now understand environment configuration and secrets management. Next, learn about backend logging patterns for configuring log levels per environment, then explore health check endpoints for environment-specific monitoring.
- Practice daily -- Move all hardcoded values in your current project to environment variables with a configuration schema
- Build a project -- Build a Configuration Management CLI that validates, encrypts, and deploys configuration across environments using Vault for secrets
- Explore related topics -- Check out 12-factor app methodology and Kubernetes ConfigMaps and Secrets
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro