Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 035 / 100

Configuration in Rust services: best practices explained

Load settings safely.

Expona Admin8 min read

Configuration in Rust services: best practices explained

Most service outages we've seen trace back to configuration issues: a missing environment variable, a typo in a config file, or a secret logged to stdout. Configuration in Rust services requires careful thought because Rust's type system can either save you or leave you debugging at 2 AM. This guide walks through battle-tested patterns for loading settings safely, validating them early, and keeping secrets actually secret.

Why configuration matters in production

Configuration is the bridge between your code and its runtime environment. In a microservices world, the same binary runs across dev, staging, and production—the only difference is configuration. Get it wrong and you're connecting to the wrong database, using the wrong API keys, or worse, broadcasting secrets in logs.

Rust's approach differs from many languages. We have strong typing, compiler checks, and a culture of "fail fast." These are advantages we should leverage when building configuration systems.

Environment variables — std::env or envy for structs

Environment variables are the standard for Kubernetes, Docker, and cloud platforms. The simplest approach uses std::env:

rust
use std::env;

let db_host = env::var("DATABASE_HOST")
    .expect("DATABASE_HOST not set");
let db_port: u16 = env::var("DATABASE_PORT")
    .expect("DATABASE_PORT not set")
    .parse()
    .expect("DATABASE_PORT must be a valid u16");

This works for a handful of variables, but doesn't scale. Every variable requires manual parsing and error handling. For services with 10+ configuration values, you'll end up with repetitive code.

The envy crate lets you map environment variables directly to a struct:

rust
use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct Config {
    pub database_host: String,
    pub database_port: u16,
    pub database_user: String,
    pub log_level: String,
    pub api_key: String,
}

impl Config {
    pub fn from_env() -> Result {
        envy::from_env()
    }
}

fn main() {
    let config = Config::from_env()
        .expect("Failed to load configuration from environment");
    println!("Connecting to {}:{}", config.database_host, config.database_port);
}

The benefits are clear: one call loads everything, types are enforced, and missing variables surface immediately. envy also supports optional fields with Option<T> and default values:

rust
use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct Config {
    pub database_host: String,
    pub database_port: u16,
    #[serde(default)]
    pub max_connections: u32,  // Defaults to 0 if not set
    pub optional_feature_flag: Option,
}

In a real service, we pair this with .env files for local development. Tools like dotenv load these files before your application starts:

rust
use dotenv::dotenv;

fn main() {
    dotenv().ok();  // Load .env in development, ignore if not found
    let config = Config::from_env()
        .expect("Failed to load configuration");
    // ...
}

Note: Never commit .env files with real secrets to version control. Use .env.example with placeholder values instead.

Config files — config crate for layered settings

Some teams prefer configuration files for complex settings or when you need different profiles (dev, staging, production). The config crate merges multiple sources with a clear hierarchy:

rust
use config::{Config, ConfigError, File};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct AppConfig {
    pub database: DatabaseConfig,
    pub server: ServerConfig,
}

#[derive(Debug, Deserialize)]
pub struct DatabaseConfig {
    pub host: String,
    pub port: u16,
    pub pool_size: u32,
}

#[derive(Debug, Deserialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
}

impl AppConfig {
    pub fn from_file(env: &str) -> Result {
        let config = Config::builder()
            .add_source(File::with_name("config/default"))
            .add_source(File::with_name(&format!("config/{}", env)).required(false))
            .build()?;
        
        config.try_deserialize()
    }
}

This pattern loads a default configuration, then overlays environment-specific values. Your config/default.toml might look like:

rust
[database]
host = "localhost"
port = 5432
pool_size = 10

[server]
host = "0.0.0.0"
port = 8080

And config/production.toml overrides critical settings:

rust
[database]
host = "prod-db.internal"
pool_size = 50

[server]
port = 8443

The config crate also supports environment variable overrides with underscores mapping to nesting:

rust
let config = Config::builder()
    .add_source(File::with_name("config/default"))
    .add_source(config::Environment::with_prefix("APP").separator("_"))
    .build()?;

Now APP_DATABASE_HOST=prod-db.internal overrides the file value. This gives you flexibility: use files for static settings, environment variables for secrets and deployment-specific values.

Validate at startup — Fail fast on missing values

Rust's type system prevents many configuration bugs, but you still need runtime validation. Missing database credentials or an invalid port should fail during startup, not at 3 AM when a request hits a bad code path.

Build a validation layer:

rust
use std::net::SocketAddr;

#[derive(Debug)]
pub struct ValidatedConfig {
    pub db_addr: SocketAddr,
    pub server_addr: SocketAddr,
    pub max_retries: u32,
}

impl ValidatedConfig {
    pub fn new(config: AppConfig) -> Result {
        // Validate database address is reachable format
        let db_addr = format!("{}:{}", config.database.host, config.database.port)
            .parse::()
            .map_err(|e| format!("Invalid database address: {}", e))?;

        // Validate server port is not privileged without good reason
        if config.server.port < 1024 && config.server.port != 0 {
            return Err("Server port must be >= 1024 or 0 (random)".to_string());
        }

        let server_addr = format!("{}:{}", config.server.host, config.server.port)
            .parse::()
            .map_err(|e| format!("Invalid server address: {}", e))?;

        // Validate pool size is reasonable
        if config.database.pool_size == 0 {
            return Err("Database pool size must be > 0".to_string());
        }
        if config.database.pool_size > 1000 {
            return Err("Database pool size seems too large".to_string());
        }

        Ok(ValidatedConfig {
            db_addr,
            server_addr,
            max_retries: 3,
        })
    }
}

#[tokio::main]
async fn main() {
    let config = Config::from_file("production")
        .expect("Failed to load config file");
    
    let validated = ValidatedConfig::new(config)
        .expect("Configuration validation failed");
    
    println!("Config validated. Server will listen on {}", validated.server_addr);
}

This approach means configuration errors surface before the application accepts traffic. In a Kubernetes environment with health checks, the pod won't become ready, and your orchestrator rolls back or alerts.

Secrets — Never log secret values

API keys, database passwords, and JWT secrets should never appear in logs, debug output, or error messages. Rust doesn't hide them by default—you must be deliberate.

Use a custom type that implements Debug without exposing the value:

rust
use serde::Deserialize;
use std::fmt;

#[derive(Deserialize, Clone)]
pub struct Secret(String);

impl fmt::Debug for Secret {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str("Secret(***)")
    }
}

impl fmt::Display for Secret {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str("***")
    }
}

impl Secret {
    pub fn expose(&self) -> &str {
        &self.0
    }
}

#[derive(Deserialize, Debug)]
pub struct Config {
    pub database_host: String,
    pub api_key: Secret,
}

fn main() {
    let config: Config = unimplemented!();
    
    // Safe: prints Secret(***)
    println!("Config: {:?}", config);
    
    // Only when needed
    let actual_key = config.api_key.expose();
    send_request_with_key(actual_key);
}

Additionally, avoid logging configuration at startup. If you must debug, log non-sensitive fields only:

rust
pub fn log_config(&self) {
    tracing::info!(
        database_host = %self.database_host,
        database_port = self.database_port,
        "Configuration loaded (secrets redacted)"
    );
}

For external secret management (AWS Secrets Manager, HashiCorp Vault), load secrets at runtime and store them in memory, not in config files:

rust
use aws_sdk_secretsmanager::Client;

pub async fn load_db_password(secret_name: &str) -> Result> {
    let config = aws_config::load_from_env().await;
    let client = Client::new(&config);
    
    let response = client
        .get_secret_value()
        .secret_id(secret_name)
        .send()
        .await?;
    
    Ok(response.secret_string().unwrap_or_default().to_string())
}

Common mistakes

  • Panicking on config load: Using expect() is fine for startup validation, but avoid it in the middle of request handling. Load and validate configuration once at startup.
  • Mixing files and environment variables without priority: If both exist, make the override order explicit. Document which takes precedence so teammates aren't surprised in production.
  • Logging full config structs: Even if individual fields look safe, logging a Debug output of the entire config can accidentally expose nested secrets. Use explicit logging for non-sensitive fields.
  • Assuming environment variables are strings: Port numbers, timeouts, and pool sizes are integers. Parse them at load time, not throughout your code.
  • No validation of config ranges: A database pool size of 0 or 1,000,000 are both type-safe integers, but both break your service. Validate semantics, not just types.

Key takeaways

  • Use envy for environment variables mapped to structs—it's cleaner and more maintainable than manual std::env calls.
  • Combine the config crate with environment variable overrides to support multiple deployment profiles without code changes.
  • Validate configuration at startup, not lazily. Fail fast so bad configs are caught before your service accepts traffic.
  • Never log or display secrets. Use wrapper types with custom Debug implementations to prevent accidental exposure.
  • Load secrets from external systems (AWS Secrets Manager, Vault) at runtime rather than storing them in files.
  • Document your configuration schema and required environment variables so operators know what they're setting.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles