Skip to content

Environment Configuration and Secrets Management — Secure Application Settings

DodaTech Updated 2026-06-22 12 min read

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

Should I use .env files in production? No. .env files are for development convenience. In production, set environment variables directly in the container or Orchestration system (Kubernetes ConfigMaps/Secrets, Docker Compose env_file, CI/CD pipeline variables).

How do I handle configuration for multiple environments? Use environment-specific overlays. Define a BASE configuration with defaults, then override values per environment. Kubernetes uses Helm values files. Docker Compose uses multiple compose files. Pydantic settings use env file chaining.

What is the best way to handle secret rotation? Use dynamic secrets (Vault) that expire after a TTL. The application requests new credentials before the old ones expire. For static secrets, use AWS Secrets Manager automatic rotation. Always test rotation procedures in staging before production.

How do I configure feature flags? Store feature flags in configuration with default values. For runtime changes without redeployment, use a feature flag service (LaunchDarkly, Unleash, AWS AppConfig). Cache flag values and refresh periodically.

What is configuration drift and how do I prevent it? Configuration drift occurs when environment configurations diverge over time due to manual changes. Prevent it by using infrastructure-as-code, immutable deployments, configuration validation in CI/CD, and periodic audits comparing environments.

Related Concepts

Backend Logging
Health Check Endpoints
Graceful Shutdown

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