Configuration and Secrets: Environments, dotenv, and Not Leaking Things

Configuration is boring until it isn't. The interesting failure modes are: the wrong database URL getting used in production, a secret getting committed to git, a missing environment variable causing a panic at startup, or a configuration change requiring a code deploy. This chapter is about avoiding all of those.

The Pattern That Works

  1. Define a typed Config struct that represents your entire application configuration.
  2. Load it at startup and fail loudly if anything is missing or invalid.
  3. Pass it through your application via AppState or Arc<Config>.
  4. Never read environment variables inside handlers.

The key insight is in point 4. If you call std::env::var("DATABASE_URL") inside a handler, you've hidden a startup dependency behind runtime execution. You won't discover the missing variable until a request hits that code path. Move all configuration loading to startup.

The config Crate

[dependencies]
config = "0.14"
serde = { version = "1", features = ["derive"] }
dotenvy = "0.15"

config supports layered configuration: defaults → config files → environment variables → whatever else you add. Environment variables override config files, which override defaults. This is the right behavior for twelve-factor applications.

#![allow(unused)]
fn main() {
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;

#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
    pub database: DatabaseConfig,
    pub server: ServerConfig,
    pub auth: AuthConfig,
    pub log_level: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
    pub url: String,
    pub max_connections: u32,
    pub min_connections: u32,
}

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

#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
    pub jwt_secret: String,
    pub jwt_expiration_seconds: u64,
}

impl AppConfig {
    pub fn load() -> Result<Self, ConfigError> {
        let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());

        Config::builder()
            // Base defaults
            .set_default("log_level", "info")?
            .set_default("server.host", "0.0.0.0")?
            .set_default("server.port", 3000)?
            .set_default("database.max_connections", 20)?
            .set_default("database.min_connections", 2)?
            .set_default("auth.jwt_expiration_seconds", 3600)?
            // Layer 1: Base config file
            .add_source(File::with_name("config/default").required(false))
            // Layer 2: Environment-specific config
            .add_source(File::with_name(&format!("config/{env}")).required(false))
            // Layer 3: Environment variables (APP_DATABASE_URL, APP_AUTH_JWT_SECRET, etc.)
            .add_source(
                Environment::with_prefix("APP")
                    .separator("_")
                    .try_parsing(true),
            )
            .build()?
            .try_deserialize()
    }
}
}

With Environment::with_prefix("APP").separator("_"), the environment variable APP_DATABASE_URL maps to config.database.url. The separator _ handles nested keys: APP_DATABASE_MAX_CONNECTIONSdatabase.max_connections.

dotenv for Development

In development, you don't want to set twenty environment variables every time you open a terminal. .env files solve this:

# .env (NEVER commit this file)
APP_DATABASE_URL=postgres://postgres:password@localhost/myapp_dev
APP_AUTH_JWT_SECRET=development-secret-not-for-production
fn main() {
    // Load .env file before reading config
    // In production, this is a no-op if the file doesn't exist
    dotenvy::dotenv().ok(); // .ok() silences the "file not found" error

    let config = AppConfig::load().expect("Failed to load configuration");
    // ...
}

dotenvy (the maintained fork of dotenv) loads the .env file into the process environment. It only sets variables that aren't already set, so actual environment variables always win. This means your production deployment doesn't need to care about .env files — they simply don't exist there.

Add .env to your .gitignore. Immediately. Before you put secrets in it.

# .gitignore
.env
.env.local
*.env

Commit a .env.example instead:

# .env.example — copy to .env and fill in values
APP_DATABASE_URL=postgres://localhost/myapp_dev
APP_AUTH_JWT_SECRET=change-me

Validating Configuration at Startup

The config crate's try_deserialize() will fail if a required field is missing or has the wrong type. But you can add richer validation:

#![allow(unused)]
fn main() {
use std::net::SocketAddr;

impl AppConfig {
    pub fn load_and_validate() -> anyhow::Result<Self> {
        let config = Self::load()?;
        config.validate()?;
        Ok(config)
    }

    fn validate(&self) -> anyhow::Result<()> {
        use anyhow::ensure;

        ensure!(
            !self.auth.jwt_secret.is_empty(),
            "AUTH_JWT_SECRET must not be empty"
        );
        ensure!(
            self.auth.jwt_secret.len() >= 32,
            "AUTH_JWT_SECRET must be at least 32 characters"
        );
        ensure!(
            self.database.max_connections >= self.database.min_connections,
            "DATABASE_MAX_CONNECTIONS must be >= DATABASE_MIN_CONNECTIONS"
        );

        // Validate that the bind address parses
        let addr = format!("{}:{}", self.server.host, self.server.port);
        addr.parse::<SocketAddr>()
            .map_err(|e| anyhow::anyhow!("Invalid server address {addr}: {e}"))?;

        Ok(())
    }
}
}

Fail at startup with a clear message rather than failing at runtime with a cryptic one. If the configuration is wrong, the service should refuse to start, not silently degrade.

Secrets: What Not to Do

Do not:

  • Commit secrets to version control. (Yes, even in "private" repos. Rotate anything you accidentally committed.)
  • Log configuration values that contain secrets.
  • Print the full AppConfig struct in logs.
  • Store secrets in Docker environment variables in your docker-compose.yml that you then commit.
#![allow(unused)]
fn main() {
// BAD: Logs the JWT secret
tracing::info!("Loaded config: {:?}", config);

// GOOD: Implement Debug manually or use a redacting wrapper
impl std::fmt::Debug for AuthConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("AuthConfig")
            .field("jwt_expiration_seconds", &self.jwt_expiration_seconds)
            .field("jwt_secret", &"[REDACTED]")
            .finish()
    }
}
}

Or use the secrecy crate, which provides a Secret<T> wrapper that redacts the value in Debug and Display output by default:

[dependencies]
secrecy = { version = "0.8", features = ["serde"] }
#![allow(unused)]
fn main() {
use secrecy::{ExposeSecret, Secret};

#[derive(Deserialize, Clone)]
pub struct AuthConfig {
    pub jwt_secret: Secret<String>,
    pub jwt_expiration_seconds: u64,
}

// Using the secret requires explicitly calling .expose_secret()
let key = EncodingKey::from_secret(config.auth.jwt_secret.expose_secret().as_bytes());
}

The expose_secret() call is intentional friction. It makes secret access visible in code review and greppable.

Secrets Management in Production

For production secrets, environment variables are acceptable but not ideal. Better options:

HashiCorp Vault: Pull secrets at startup using the vaultrs crate. Rotate secrets without redeploys.

AWS Secrets Manager / GCP Secret Manager / Azure Key Vault: Cloud-native options. Credentials to access the secrets manager are handled by the cloud provider's IAM system (instance roles, workload identity), avoiding the bootstrap problem.

Kubernetes Secrets: Mounted as environment variables or files. Better than hardcoding; not as good as a dedicated secrets manager. Encode as base64 but are not encrypted at rest by default (you need to configure that separately).

A minimal secret-at-startup pattern with AWS Secrets Manager:

#![allow(unused)]
fn main() {
// Requires: aws-sdk-secretsmanager = "1"
// Not shown in full — illustrative only
pub async fn load_secret(secret_name: &str) -> anyhow::Result<String> {
    let config = aws_config::load_from_env().await;
    let client = aws_sdk_secretsmanager::Client::new(&config);

    let response = client
        .get_secret_value()
        .secret_id(secret_name)
        .send()
        .await
        .context("Failed to retrieve secret")?;

    response
        .secret_string()
        .map(|s| s.to_string())
        .ok_or_else(|| anyhow::anyhow!("Secret {secret_name} has no string value"))
}
}

The pattern is always the same: load secrets at startup, validate them, inject them into your AppConfig, then never access the secrets manager again during request handling.

Config File Example

A config/default.toml for local development defaults:

[server]
host = "127.0.0.1"
port = 3000

[database]
max_connections = 5
min_connections = 1

[auth]
jwt_expiration_seconds = 3600

log_level = "debug"

Environment-specific overrides in config/production.toml:

[server]
host = "0.0.0.0"

[database]
max_connections = 20
min_connections = 5

log_level = "info"

Secrets (database.url, auth.jwt_secret) never appear in committed config files — only in environment variables or a secrets manager.

Wiring It All Together

use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Load .env for development; no-op in production
    dotenvy::dotenv().ok();

    // Initialize tracing before doing anything else
    // so that config loading failures are logged
    init_tracing();

    let config = AppConfig::load_and_validate()
        .context("Configuration error — check environment variables")?;

    tracing::info!(
        host = %config.server.host,
        port = %config.server.port,
        "Starting server"
    );

    let pool = create_pool(&config.database.url, &config.database)
        .await
        .context("Failed to create database pool")?;

    run_migrations(&pool).await?;

    let state = AppState {
        pool,
        config: Arc::new(config),
    };

    let app = create_app(state.clone());
    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
    let listener = tokio::net::TcpListener::bind(&addr).await?;

    tracing::info!("Listening on {addr}");
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}

The main function returns anyhow::Result<()> — any unhandled error causes the process to exit with a message. This is fine for startup errors. During request handling, you use the typed error approach from the previous chapter.