Type-Safe Configuration Management in Rust
TL;DR: This guide covers implementing robust, type-safe configuration management in Rust applications. You’ll learn to use the config crate, validate settings at startup, handle environment-specific configs, and avoid runtime configuration errors through compile-time guarantees.
Introduction
Configuration management is critical for production applications. Unlike dynamically-typed languages where misconfigured values cause runtime errors, Rust can leverage its type system to catch configuration mistakes at compile time. This article explores patterns for type-safe configuration that scales from small CLI tools to complex microservices.
The Config Crate
The config crate provides a flexible configuration system with support for multiple sources.
Installation
[dependencies]
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
thiserror = "1.0"
Basic Configuration Structure
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub logging: LoggingConfig,
pub features: FeatureFlags,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub workers: usize,
pub timeout_seconds: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub min_connections: u32,
pub connection_timeout: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
pub level: String,
pub format: String,
pub output: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct FeatureFlags {
pub enable_cache: bool,
pub enable_metrics: bool,
pub enable_tracing: bool,
}
Loading Configuration
use config::{Config, Environment, File, FileFormat};
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Configuration error: {0}")]
Message(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] config::ConfigError),
}
pub fn load_config() -> Result<AppConfig, ConfigError> {
let config_dir = PathBuf::from(std::env::var("CONFIG_DIR").unwrap_or_else(|_| ".".to_string()));
let settings = Config::builder()
.add_source(File::from(config_dir.join("default.toml")))
.add_source(File::from(config_dir.join("local.toml")).required(false))
.add_source(
Environment::with_prefix("APP")
.prefix_separator("_")
.list_separator(",")
)
.build()?;
let config: AppConfig = settings.try_deserialize()?;
Ok(config)
}
Type-Safe Configuration with Validation
Add runtime validation to catch configuration errors early:
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedConfig {
pub server: ValidatedServerConfig,
pub database: ValidatedDatabaseConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedServerConfig {
pub host: String,
pub port: u16,
pub workers: NonZeroUsize,
pub timeout: std::time::Duration,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedDatabaseConfig {
pub url: Url,
pub pool_size: NonZeroU32,
pub connection_timeout: std::time::Duration,
}
#[derive(Debug, Error)]
pub enum ConfigValidationError {
#[error("Invalid port: {0}. Must be between 1 and 65535")]
InvalidPort(u16),
#[error("Invalid database URL: {0}")]
InvalidDatabaseUrl(String),
#[error("Workers must be at least 1")]
InvalidWorkerCount,
#[error("Database URL must use postgres:// or mysql:// protocol")]
InvalidProtocol,
}
impl ValidatedServerConfig {
pub fn new(host: String, port: u16, workers: usize, timeout_secs: u64)
-> Result<Self, ConfigValidationError>
{
if port == 0 || port > 65535 {
return Err(ConfigValidationError::InvalidPort(port));
}
let workers = NonZeroUsize::new(workers)
.ok_or(ConfigValidationError::InvalidWorkerCount)?;
Ok(Self {
host,
port,
workers,
timeout: std::time::Duration::from_secs(timeout_secs),
})
}
}
impl ValidatedDatabaseConfig {
pub fn new(url: String, pool_size: u32, timeout_secs: u64)
-> Result<Self, ConfigValidationError>
{
let url = Url::parse(&url)
.map_err(|_| ConfigValidationError::InvalidDatabaseUrl(url.clone()))?;
if url.scheme() != "postgres" && url.scheme() != "mysql" {
return Err(ConfigValidationError::InvalidProtocol);
}
let pool_size = NonZeroU32::new(pool_size)
.ok_or(ConfigValidationError::InvalidWorkerCount)?;
Ok(Self {
url,
pool_size,
connection_timeout: std::time::Duration::from_secs(timeout_secs),
})
}
}
Environment-Specific Configuration
Handle different environments with layered configuration:
use serde::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentConfig {
pub environment: Environment,
pub debug: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
Development,
Staging,
Production,
}
impl Environment {
pub fn from_env() -> Self {
match env::var("RUST_ENV").unwrap_or_default().as_str() {
"production" | "prod" => Environment::Production,
"staging" => Environment::Staging,
_ => Environment::Development,
}
}
pub fn is_production(&self) -> bool {
matches!(self, Environment::Production)
}
}
impl Default for Environment {
fn default() -> Self {
Environment::Development
}
}
// Environment-aware configuration builder
pub struct ConfigBuilder {
environment: Environment,
overrides: Vec<(String, String)>,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self {
environment: Environment::from_env(),
overrides: Vec::new(),
}
}
pub fn environment(mut self, env: Environment) -> Self {
self.environment = env;
self
}
pub fn override_value(self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.overrides.push((key.into(), value.into()));
self
}
pub fn load(self) -> Result<AppConfig, ConfigError> {
let env_suffix = match self.environment {
Environment::Development => "dev",
Environment::Staging => "staging",
Environment::Production => "prod",
};
let config_dir = PathBuf::from("config");
let mut builder = Config::builder()
.add_source(File::from(config_dir.join("default.toml")))
.add_source(File::from(config_dir.join(format!("{}.toml", env_suffix))));
// Add overrides (useful for testing)
for (key, value) in self.overrides {
builder = builder.set_override(&key, value)?;
}
let settings = builder.build()?;
let config: AppConfig = settings.try_deserialize()?;
// Validate based on environment
if self.environment.is_production() {
config.validate_for_production()?;
}
Ok(config)
}
}
Secrets Management
Handle sensitive configuration values securely:
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Secrets {
pub database_password: Secret<String>,
pub api_key: Secret<String>,
pub jwt_secret: Secret<String>,
}
#[derive(Debug, Clone)]
pub struct Secret<T> {
value: T,
source: SecretSource,
}
#[derive(Debug, Clone, Copy)]
pub enum SecretSource {
Environment,
Vault,
EncryptedFile,
}
impl<T> Secret<T> {
pub fn new(value: T, source: SecretSource) -> Self {
Self { value, source }
}
pub fn as_str(&self) -> &str
where
T: AsRef<str>,
{
self.value.as_ref()
}
}
// Don't log secrets!
impl<T> std::fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("source", &self.source)
.finish()
}
}
impl<'de, T> serde::Deserialize<'de> for Secret<T>
where
T: serde::Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = T::deserialize(deserializer)?;
Ok(Secret::new(value, SecretSource::Environment))
}
}
impl<T> serde::Serialize for Secret<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Never serialize the actual value in production
serializer.serialize_str("<REDACTED>")
}
}
// Loading secrets from environment
pub fn load_secrets() -> Result<Secrets, ConfigError> {
Ok(Secrets {
database_password: Secret::new(
std::env::var("DATABASE_PASSWORD")
.map_err(|_| ConfigError::Message("DATABASE_PASSWORD not set".into()))?,
SecretSource::Environment,
),
api_key: Secret::new(
std::env::var("API_KEY")
.map_err(|_| ConfigError::Message("API_KEY not set".into()))?,
SecretSource::Environment,
),
jwt_secret: Secret::new(
std::env::var("JWT_SECRET")
.map_err(|_| ConfigError::Message("JWT_SECRET not set".into()))?,
SecretSource::Environment,
),
})
}
Clap Integration for CLI Arguments
Combine with clap for CLI-driven configuration:
[dependencies]
clap = { version = "4.4", features = ["derive", "env"] }
use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct CliArgs {
/// Environment to run in (development, staging, production)
#[arg(short, long, env, default_value = "development")]
pub environment: Environment,
/// Configuration file path
#[arg(short, long, default_value = "config.toml")]
pub config: PathBuf,
/// Enable verbose logging
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Database URL (overrides config file)
#[arg(long, env)]
pub database_url: Option<String>,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum Environment {
Development,
Staging,
Production,
}
impl Default for Environment {
fn default() -> Self {
Environment::Development
}
}
// Merge CLI args with file config
pub fn merge_config(cli: &CliArgs, file_config: &AppConfig) -> Result<AppConfig, ConfigError> {
let mut config = file_config.clone();
// CLI args override file config
if let Some(db_url) = &cli.database_url {
config.database.url = db_url.clone();
}
// Set logging level based on verbose flag
config.logging.level = match cli.verbose {
0 => "info",
1 => "debug",
_ => "trace",
}.to_string();
Ok(config)
}
Configuration Testing
Test configuration with different environments:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
fn test_config() -> AppConfig {
AppConfig {
server: ServerConfig {
host: "localhost".to_string(),
port: 8080,
workers: 4,
timeout_seconds: 30,
},
database: DatabaseConfig {
url: "postgres://localhost/test".to_string(),
max_connections: 10,
min_connections: 2,
connection_timeout: 5,
},
logging: LoggingConfig {
level: "debug".to_string(),
format: "json".to_string(),
output: HashMap::new(),
},
features: FeatureFlags::default(),
}
}
#[test]
fn test_default_config() {
let config = test_config();
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.workers, 4);
}
#[test]
fn test_config_validation() {
// Test port validation
let result = ValidatedServerConfig::new(
"localhost".to_string(),
0, // Invalid port
4,
30,
);
assert!(result.is_err());
// Test valid config
let result = ValidatedServerConfig::new(
"localhost".to_string(),
8080,
4,
30,
);
assert!(result.is_ok());
}
#[test]
fn test_environment_parsing() {
std::env::set_var("RUST_ENV", "production");
let env = Environment::from_env();
assert_eq!(env, Environment::Production);
std::env::remove_var("RUST_ENV");
}
}
Example: Complete Configuration Setup
Putting it all together:
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("Validation error: {0}")]
Validation(String),
}
pub type Result<T> = std::result::Result<T, AppError>;
pub fn initialize_app() -> Result<AppConfig> {
// Load CLI arguments
let cli = CliArgs::parse();
// Determine config directory
let config_dir = if cli.config.exists() {
cli.config.parent().unwrap().into()
} else {
PathBuf::from("config")
};
// Build configuration
let config = ConfigBuilder::new()
.environment(cli.environment.into())
.load()?;
// Merge CLI overrides
let config = merge_config(&cli, &config)?;
// Log configuration (without secrets)
tracing::info!(
server.host = %config.server.host,
server.port = config.server.port,
workers = config.server.workers,
"Configuration loaded"
);
Ok(config)
}
// Run the application
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let config = initialize_app()?;
// Start your application with the config
run_server(config).await?;
Ok(())
}
Best Practices
1. Use Strong Types
// โ Weak typing - easy to make mistakes
pub struct Config {
pub timeout: u64,
pub max_connections: u32,
}
// โ
Strong typing - catches errors at compile time
pub struct Config {
pub timeout: std::time::Duration,
pub max_connections: NonZeroU32,
}
2. Validate Early
pub fn load_config() -> Result<Config, ConfigError> {
let config = load_raw_config()?;
// Validate immediately, fail fast
config.validate()?;
Ok(config)
}
3. Don’t Expose Secrets in Logs
impl Serialize for Config {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut s = serializer.serialize_struct("Config", 3)?;
s.serialize_field("host", &self.host)?;
s.serialize_field("port", &self.port)?;
s.serialize_field("api_key", &"<REDACTED>")?; // Always redact
s.end()
}
}
4. Use Configuration Schemas
// config.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["server", "database"],
"properties": {
"server": {
"type": "object",
"required": ["host", "port"],
"properties": {
"host": { "type": "string" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
}
}
}
Configuration Comparison
| Approach | Pros | Cons |
|---|---|---|
| Environment Variables | Easy, 12-factor standard | No validation, typos |
| config crate | Multiple sources, validation | Runtime errors possible |
| Strong Types + Validation | Compile-time safety | More setup required |
| Clap + Config | CLI overrides, type-safe | More code |
Conclusion
Type-safe configuration in Rust catches errors at compile time and provides excellent developer experience. Key takeaways:
- Use the
configcrate for flexible configuration from multiple sources - Create strong types that validate at construction
- Separate secrets from regular configuration
- Use environment variables following 12-factor app principles
- Test configuration with different environments
Invest in configuration earlyโit pays dividends as your application grows.
External Resources
Related Articles
- Building REST APIs with Axum and Actix-web
- Error Handling Patterns in Rust Web Services
- Production Deployment: Docker, CI/CD, Monitoring
- Building Microservices in Rust
Comments