Skip to main content
โšก Calmops

Type-Safe Configuration Management in Rust

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:

  1. Use the config crate for flexible configuration from multiple sources
  2. Create strong types that validate at construction
  3. Separate secrets from regular configuration
  4. Use environment variables following 12-factor app principles
  5. Test configuration with different environments

Invest in configuration earlyโ€”it pays dividends as your application grows.


External Resources


Comments