Configuration Management
How do you manage configuration in production Elixir applications? This guide teaches the progression from hardcoded values through compile-time config.exs to runtime configuration with environment variables, secure secret management, and validation strategies.
Why Configuration Management Matters
Production applications need environment-specific configuration without code changes:
- Database connections - Different credentials for dev, staging, production
- API keys - Payment gateways, external services (never in source code)
- Feature flags - Enable/disable features per environment
- Resource limits - Connection pools, timeouts, memory limits
- Shariah compliance settings - Prayer time API endpoints, halal certification validation
Real-world configuration scenarios:
- Financial services - Database URLs, payment processor keys, audit log paths
- E-commerce platforms - Payment gateway credentials, shipping API keys, tax rates
- Microservices - Service discovery URLs, authentication tokens, circuit breaker thresholds
- Multi-tenant systems - Per-tenant database connections, feature toggles, rate limits
Production question: Should you hardcode values, use compile-time config, or load configuration at runtime? The answer depends on your deployment model and security requirements.
Standard Library - Hardcoded Configuration
Elixir’s standard library provides Application module for accessing configuration, but doesn’t solve how to set it safely.
Module Attributes - Compile-Time Constants
# Hardcoded configuration values
defmodule PaymentService do
@api_key "pk_live_1234567890abcdef" # => Hardcoded API key (DANGEROUS!)
# => Compiled into bytecode
# => Type: binary()
# => Cannot change without recompile
@database_url "postgresql://user:pass@localhost/db"
# => Database credentials in source
# => Version control exposes secrets
# => Type: binary()
def process_payment(amount) do
HTTPoison.post(
"https://api.payment.com/charge",
%{amount: amount, api_key: @api_key} # => Uses hardcoded key
# => Cannot switch environments
)
end
endHardcoded values are compiled into bytecode, cannot change without recompiling, and expose secrets in version control.
Application.get_env/3 - Reading Configuration
# Reading application configuration
defmodule ConfigReader do
def database_url do
Application.get_env(:myapp, :database_url) # => Read :myapp config
# => Key: :database_url
# => Returns: term() | nil
# => Still need to SET config somewhere
end
def database_url_with_default do
Application.get_env(
:myapp, # => Application name
:database_url, # => Config key
"postgresql://localhost/myapp_dev" # => Default value
) # => Returns: term()
# => Type-safe with defaults
end
endApplication.get_env/3 reads configuration but doesn’t solve where configuration comes from.
Limitations of Hardcoded Configuration
Problem 1 - Environment-Specific Config
# Cannot switch between environments
defmodule DatabaseConnection do
@dev_url "postgresql://localhost/myapp_dev"
@prod_url "postgresql://prod.db.com/myapp"
def connect do
# Which URL to use? Need code change!
Postgrex.start_link(hostname: @dev_url) # => Hardcoded to dev
# => Production deployment fails
# => Type: {:ok, pid()} | {:error, term()}
end
endChanging environments requires code modification and recompilation.
Problem 2 - Secrets in Source Code
# Security vulnerability
defmodule PaymentProcessor do
@stripe_key "sk_live_actual_secret_key" # => Secret in version control
# => All developers see key
# => Git history exposes forever
# => Security audit failure
@aws_secret "aws_secret_access_key_value" # => Cannot rotate without deploy
# => Compliance violation
endSecrets in source code create security risks, compliance violations, and rotation difficulties.
Problem 3 - No Configuration Validation
# Invalid configuration crashes at runtime
defmodule FeatureFlags do
@max_connections "100" # => String instead of integer
# => Type error at runtime
# => No compile-time validation
def get_pool_size do
@max_connections + 10 # => Runtime error!
# => Cannot add string and integer
# => Crash in production
end
endNo validation means configuration errors only surface at runtime.
Config Module - Compile-Time Configuration
Mix provides Config module for managing environment-specific configuration.
config/config.exs - Base Configuration
# config/config.exs - Compile-time configuration
import Config # => Import Config macros
# => Provides config/3, import_config/1
# Application configuration
config :myapp, :database,
pool_size: 10, # => Connection pool size
# => Type: pos_integer()
timeout: 5000, # => Query timeout (ms)
# => Type: pos_integer()
queue_target: 50 # => Queue target time (ms)
# => Type: pos_integer()
config :myapp, MyApp.Repo,
database: "myapp_dev", # => Database name
username: "postgres", # => Database user
password: "postgres", # => Hardcoded password (still not ideal)
hostname: "localhost" # => Database host
# => All values compile-time only
# Import environment-specific config
import_config "#{config_env()}.exs" # => Load dev.exs, test.exs, or prod.exs
# => config_env() returns :dev | :test | :prodconfig.exs provides structured configuration but values are still compile-time.
config/dev.exs - Development Environment
# config/dev.exs - Development-specific configuration
import Config
config :myapp, MyApp.Repo,
database: "myapp_dev", # => Development database
show_sensitive_data_on_connection_error: true, # => Debug mode
pool_size: 10 # => Smaller pool for dev
# => Type: pos_integer()
config :myapp, MyAppWeb.Endpoint,
http: [port: 4000], # => Development port
# => Type: [port: pos_integer()]
debug_errors: true, # => Show detailed errors
code_reloader: true # => Hot code reloadingconfig/prod.exs - Production Environment
# config/prod.exs - Production configuration
import Config
config :myapp, MyApp.Repo,
pool_size: 20, # => Larger pool for production
# => Type: pos_integer()
queue_target: 50, # => Queue management
queue_interval: 1000 # => Type: pos_integer()
config :myapp, MyAppWeb.Endpoint,
http: [port: 80], # => Production HTTP port
url: [host: "example.com", port: 443], # => Public URL
cache_static_manifest: "priv/static/cache_manifest.json"
# => Asset cache manifest
# => Type: binary()
# Note: Still has hardcoded values!
# Need runtime.exs for true runtime configEnvironment-specific files allow different settings per environment, but still compile-time.
Reading Config in Application Code
# Using configuration in modules
defmodule MyApp.PaymentService do
@api_base Application.compile_env(:myapp, :payment_api_base)
# => Read at compile time
# => Crash if not configured
# => Type: term()
def process_payment(amount) do
url = "#{@api_base}/charge" # => Uses compile-time value
# Make API call...
end
end
# Runtime configuration reading
defmodule MyApp.DynamicService do
def get_feature_flag(flag_name) do
flags = Application.get_env(:myapp, :feature_flags, %{})
# => Read at runtime
# => Returns map or default
# => Type: map()
Map.get(flags, flag_name, false) # => Get specific flag
# => Default: false
end
endMix of compile-time (Application.compile_env/2) and runtime (Application.get_env/3) configuration.
Runtime Configuration - config/runtime.exs
Mix 1.11+ provides config/runtime.exs for true runtime configuration with environment variables.
config/runtime.exs - Runtime Configuration
# config/runtime.exs - Loaded at application startup
import Config # => Import Config macros
# => Runs when application starts
# => NOT at compile time
# Only run in production
if config_env() == :prod do
# Database configuration from environment
database_url = System.get_env("DATABASE_URL") ||
raise "DATABASE_URL not set" # => Read from environment variable
# => Crash if missing
# => Type: binary()
config :myapp, MyApp.Repo,
url: database_url, # => Full database URL
# => Type: binary()
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
# => Convert string to integer
# => Default: 10
# => Type: pos_integer()
# API keys from environment
secret_key_base = System.get_env("SECRET_KEY_BASE") ||
raise "SECRET_KEY_BASE not set" # => Phoenix secret key
# => Must be 64+ chars
# => Type: binary()
config :myapp, MyAppWeb.Endpoint,
http: [
port: String.to_integer(System.get_env("PORT") || "4000")
# => Dynamic port
],
secret_key_base: secret_key_base # => Runtime secret
# => Type: binary()
# Payment service configuration
config :myapp, :payment,
api_key: System.get_env("STRIPE_API_KEY") ||
raise "STRIPE_API_KEY not set", # => Payment API key
webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET")
# => Webhook validation
# => Type: binary() | nil
endruntime.exs loads at application startup, reads environment variables, validates required values.
Environment Variables - Setting Configuration
# Setting environment variables for production
export DATABASE_URL="postgresql://user:pass@db.prod.com/myapp"
export POOL_SIZE="20"
export SECRET_KEY_BASE="very_long_secret_key_base_string_64_chars_min"
export STRIPE_API_KEY="sk_live_actual_production_key"
export PORT="8080"
# Start application with runtime config
mix run --no-halt
# => Reads environment variables
# => Configures application at startup
# => No recompilation neededEnvironment variables enable configuration changes without recompilation.
Configuration Validation
# config/runtime.exs - Validating configuration
import Config
if config_env() == :prod do
# Helper function for required env vars
get_required_env = fn name ->
System.get_env(name) ||
raise """
Environment variable #{name} is missing.
Set it before starting the application.
"""
end
# Validate and parse integer config
pool_size = System.get_env("POOL_SIZE", "10") # => Default: "10"
|> String.to_integer() # => Convert to integer
# => Type: integer()
if pool_size < 1 or pool_size > 100 do
raise "POOL_SIZE must be between 1 and 100" # => Validation at startup
# => Fast failure
end
# Validate URL format
database_url = get_required_env.("DATABASE_URL") # => Required variable
unless String.starts_with?(database_url, ["postgresql://", "postgres://"]) do
raise "DATABASE_URL must be PostgreSQL URL" # => URL format validation
end
config :myapp, MyApp.Repo,
url: database_url,
pool_size: pool_size # => Validated integer
endValidation in runtime.exs provides fast failure with clear error messages.
Production Configuration Strategies
Strategy 1 - Config Providers (Vault, AWS Parameter Store)
# lib/myapp/config_provider.ex - Custom config provider
defmodule MyApp.ConfigProvider do
@behaviour Config.Provider
def init(path) when is_binary(path) do
path # => Return path for later use
# => Type: binary()
end
def load(config, path) do
# Load secrets from Vault
vault_token = System.get_env("VAULT_TOKEN") # => Vault authentication token
# => Type: binary()
secrets = fetch_from_vault(vault_token, path) # => Fetch secrets from Vault
# => Type: map()
# Merge into configuration
Config.Reader.merge(
config,
myapp: [
repo: [
username: secrets["db_username"], # => Database user from Vault
password: secrets["db_password"] # => Database pass from Vault
# => Type: binary()
],
payment: [
api_key: secrets["stripe_key"] # => Payment key from Vault
]
]
)
end
defp fetch_from_vault(token, path) do
# Vault API call implementation
# Returns map of secrets
end
endConfig providers fetch secrets from external systems (Vault, AWS, GCP) at startup.
Strategy 2 - Structured Environment Variables
# config/runtime.exs - Structured env var parsing
import Config
if config_env() == :prod do
# Parse JSON configuration from environment
payment_config = System.get_env("PAYMENT_CONFIG") ||
~s({"api_key": "", "webhook_secret": ""}) # => JSON default
# => Type: binary()
payment_map = Jason.decode!(payment_config) # => Parse JSON
# => Type: map()
# => Crash if invalid JSON
# Shariah compliance configuration
shariah_config = System.get_env("SHARIAH_CONFIG") ||
~s({"prayer_api": "https://api.aladhan.com", "halal_cert_validation": true})
# => Shariah compliance settings
# => Type: binary()
shariah_map = Jason.decode!(shariah_config) # => Parse JSON config
config :myapp, :payment,
api_key: payment_map["api_key"], # => Extract API key
webhook_secret: payment_map["webhook_secret"] # => Extract webhook secret
config :myapp, :shariah,
prayer_api_url: shariah_map["prayer_api"], # => Prayer time API
halal_validation: shariah_map["halal_cert_validation"]
# => Halal certification validation
endStructured environment variables allow complex configuration in single env var.
Strategy 3 - Configuration Modules
# lib/myapp/config.ex - Configuration access module
defmodule MyApp.Config do
@moduledoc """
Centralized configuration access with validation and defaults.
"""
# Database configuration
def database_pool_size do
Application.get_env(:myapp, :database) # => Get database config
|> Keyword.get(:pool_size, 10) # => Get pool_size with default
# => Type: pos_integer()
end
def database_timeout do
Application.get_env(:myapp, :database)
|> Keyword.get(:timeout, 5000) # => Get timeout with default
# => Type: pos_integer()
end
# Payment configuration
def payment_api_key! do
case Application.get_env(:myapp, :payment)[:api_key] do
nil -> raise "Payment API key not configured" # => Crash if missing
"" -> raise "Payment API key is empty" # => Validate non-empty
key -> key # => Return valid key
# => Type: binary()
end
end
# Shariah compliance configuration
def shariah_prayer_api_url do
Application.get_env(:myapp, :shariah)
|> Keyword.get(:prayer_api_url, "https://api.aladhan.com")
# => Prayer time API URL
# => Type: binary()
end
def halal_validation_enabled? do
Application.get_env(:myapp, :shariah)
|> Keyword.get(:halal_validation, true) # => Halal certification validation
# => Type: boolean()
end
# Feature flags
def feature_enabled?(flag_name) do
Application.get_env(:myapp, :feature_flags, %{}) # => Get feature flags map
|> Map.get(flag_name, false) # => Get specific flag
# => Type: boolean()
end
endConfiguration modules provide centralized access, validation, type safety, and documentation.
Complete Example - Financial Application Configuration
# config/runtime.exs - Production financial app configuration
import Config
if config_env() == :prod do
# Helper for required config
require_env! = fn name ->
System.get_env(name) ||
raise "Missing required environment variable: #{name}"
end
# Database configuration
database_url = require_env!.("DATABASE_URL") # => Required database URL
pool_size = String.to_integer(System.get_env("POOL_SIZE") || "20")
# => Pool size with default
config :finance_app, FinanceApp.Repo,
url: database_url,
pool_size: pool_size,
queue_target: 50, # => Queue management
queue_interval: 1000, # => Queue interval (ms)
ssl: true, # => Require SSL
ssl_opts: [
verify: :verify_peer, # => Verify SSL certificate
cacerts: :public_key.cacerts_get() # => System CA certificates
]
# Payment processor configuration
config :finance_app, :payment,
stripe_key: require_env!.("STRIPE_API_KEY"), # => Stripe API key
stripe_webhook: require_env!.("STRIPE_WEBHOOK_SECRET"),
# => Webhook verification
currency: System.get_env("DEFAULT_CURRENCY") || "USD"
# => Default currency
# Audit logging configuration
config :finance_app, :audit,
log_path: System.get_env("AUDIT_LOG_PATH") || "/var/log/finance_app/audit.log",
# => Audit log file path
log_level: System.get_env("AUDIT_LOG_LEVEL") || "info"
# => Audit log level
# Shariah compliance configuration
config :finance_app, :shariah,
riba_check_enabled: System.get_env("RIBA_CHECK_ENABLED") == "true",
# => Enable Riba checking
zakat_calculation_endpoint: System.get_env("ZAKAT_API_URL") ||
"https://api.islamic-finance.com/zakat", # => Zakat calculation API
halal_investment_validator: System.get_env("HALAL_VALIDATOR_URL")
# => Halal investment validation
# Feature flags
feature_flags =
System.get_env("FEATURE_FLAGS") ||
~s({"new_dashboard": false, "advanced_charts": false})
# => JSON feature flags
|> Jason.decode!() # => Parse to map
config :finance_app, :features, feature_flags # => Set feature flags
# Validate critical configuration
unless String.length(database_url) > 0 do
raise "DATABASE_URL cannot be empty"
end
unless pool_size > 0 and pool_size <= 100 do
raise "POOL_SIZE must be between 1 and 100"
end
end
# lib/finance_app/config.ex - Configuration access module
defmodule FinanceApp.Config do
@moduledoc """
Centralized configuration for financial application.
"""
# Database configuration
def db_pool_size do
Application.get_env(:finance_app, FinanceApp.Repo)
|> Keyword.get(:pool_size, 10)
end
# Payment configuration
def stripe_api_key! do
Application.get_env(:finance_app, :payment)[:stripe_key] ||
raise "Stripe API key not configured"
end
def default_currency, do: get_payment_config(:currency, "USD")
# Audit configuration
def audit_log_path do
Application.get_env(:finance_app, :audit)[:log_path]
end
# Shariah compliance
def riba_check_enabled? do
Application.get_env(:finance_app, :shariah)[:riba_check_enabled] || false
end
def zakat_api_url do
Application.get_env(:finance_app, :shariah)[:zakat_calculation_endpoint]
end
def halal_investment_validator_url do
Application.get_env(:finance_app, :shariah)[:halal_investment_validator]
end
# Feature flags
def feature_enabled?(flag_name) do
Application.get_env(:finance_app, :features, %{})
|> Map.get(to_string(flag_name), false)
end
# Private helpers
defp get_payment_config(key, default) do
Application.get_env(:finance_app, :payment)
|> Keyword.get(key, default)
end
endComplete financial application configuration with database, payment processing, audit logging, Shariah compliance settings, and feature flags.
Key Takeaways
Progression:
- Hardcoded values (compile-time, insecure)
- config.exs (compile-time, environment-specific)
- runtime.exs (runtime, environment variables)
- Config providers (external secret management)
Best Practices:
- Use
config/runtime.exsfor production configuration - Read secrets from environment variables (never hardcode)
- Validate configuration at startup (fast failure)
- Provide sensible defaults where appropriate
- Use configuration modules for centralized access
- Consider config providers (Vault) for sensitive secrets
- Document required environment variables
- Separate compile-time and runtime configuration
Security:
- Never commit secrets to version control
- Use environment variables in production
- Rotate secrets without redeployment
- Validate configuration values
- Use SSL for database connections
- Enable certificate verification
Production Pattern: Use config/runtime.exs with environment variables for all environment-specific configuration, validate at startup, and provide centralized configuration access through dedicated modules.