Skip to content
rusthacks.dev
DevOps

Configuration for Rust Services With Environment Variables

Load settings from environment variables into a typed struct at startup, fail fast on missing values, give safe defaults for the rest and keep secrets out of logs and Debug output.

3 min read
Summarize in AI
Flat illustration for the post “Configuration for Rust Services With Environment Variables”, composed around cli.

A service that crashes on the first request because PORT was "80a" has parsed its configuration too late. Configuration is the one input that is the same for every request, so read it once, in one place, strictly. Environment variables are the portable carrier: they work in systemd, Docker, Kubernetes and a shell. This post builds the typed struct and the checks. It is the settings step of shipping Rust to production.

Rust 1.90, dotenvy 0.15.

A typed Config

rust
use std::env;

#[derive(Clone)]
pub struct Config {
    pub port: u16,
    pub database_url: Secret,
    pub log_level: String,
}

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

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

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

impl Config {
    pub fn from_env() -> Result<Config, Vec<String>> {
        let mut errors = Vec::new();

        let port = match env::var("PORT").unwrap_or_else(|_| "3000".into()).parse::<u16>() {
            Ok(p) => p,
            Err(e) => { errors.push(format!("PORT: {e}")); 0 }
        };
        let database_url = match env::var("DATABASE_URL") {
            Ok(v) if !v.is_empty() => v,
            _ => { errors.push("DATABASE_URL is required".into()); String::new() }
        };
        let log_level = env::var("RUST_LOG").unwrap_or_else(|_| "info".into());

        if errors.is_empty() {
            Ok(Config { port, database_url: Secret(database_url), log_level })
        } else {
            Err(errors)
        }
    }
}

from_env collects all errors before returning, so the deployer sees every problem in one run:

Text
configuration is invalid:
  PORT: invalid digit found in string
  DATABASE_URL is required

main prints those lines to stderr and exits with code 1 before opening a socket. The Secret wrapper has a Debug impl that prints Secret(***), so tracing::info!(?config) cannot leak the database password.

Local development

rust
fn main() {
    #[cfg(debug_assertions)]
    let _ = dotenvy::dotenv();
    // ...
}

dotenvy reads .env into the process environment when the file exists. Gating it to debug builds makes sure production only uses real variables. Keep .env out of version control and commit .env.example (git ignore for Rust projects).

Which things are configuration

Values that differ per environment: ports, URLs, pool sizes, log level, feature switches, secrets. Not configuration: business rules, constants that never change, anything that would make behavior depend on a deploy in an untested way. Every option is a combination the tests must cover, so add them reluctantly.

Validation beyond parsing

Check relationships and ranges: pool_max >= pool_min, URLs have the expected scheme, timeouts are positive. Fail at startup. The same fail-fast approach applies to clap values for command line tools (value parsers).

Secrets

The variables that hold credentials need more care than the rest: how they get to the process, who can read them, and how they rotate. That is the subject of secrets management. The Twelve-Factor App describes the original reasoning in its configuration section.

Limits

Environment variables are visible to the process owner, appear in /proc/PID/environ and are inherited by child processes. For large or structured settings, a mounted file is easier to manage. Prefer one mechanism per deployment, document each variable, and list them in the README.

Also inCLI
Taggedconfig
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.