Application configuration follows the twelve-factor methodology: store config in environment variables. Database connection strings, SMTP credentials, S3 keys, listen addresses, and feature flags all come from the environment. The same code runs in development and production. Only the source of the variables changes.
This section covers loading .env files in development with dotenvy, parsing environment variables into a typed Config struct at startup, protecting secrets with the secrecy crate, and managing secrets in production with Docker Compose and Terraform.
Dependencies
The app-config crate handles all environment variable parsing. It depends on dotenvy for .env file loading and secrecy for wrapping sensitive values.
# crates/config/Cargo.toml
[package]
name = "app-config"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
dotenvy.workspace = true
secrecy = "0.10"
Add secrecy to the workspace dependency table:
# Cargo.toml (workspace root)
[workspace.dependencies]
# ...existing dependencies...
secrecy = "0.10"
Then use secrecy.workspace = true in the config crate.
Loading .env files with dotenvy
dotenvy loads environment variables from a .env file at the project root. It is a maintained fork of the original dotenv crate, which was flagged as unmaintained in RUSTSEC-2021-0141.
dotenvy::dotenv() reads the .env file and calls std::env::set_var for each entry. Existing environment variables are not overridden, so production values injected by the deployment platform always take precedence over a stale .env file.
Rust 2024 edition safety
In Rust 2024 edition (the edition this project targets), std::env::set_var is unsafe. The underlying setenv libc call is not thread-safe: calling it while other threads read environment variables is a data race. Rust’s internal mutex only protects std::env calls, not libc-level getenv calls from other libraries.
The fix is to call dotenvy::dotenv() before the Tokio runtime starts, while the process is still single-threaded. Separate the synchronous entry point from the async runtime:
// crates/server/src/main.rs
use anyhow::Result;
use tracing_subscriber::EnvFilter;
fn main() {
dotenvy::dotenv().ok();
run();
}
#[tokio::main]
async fn run() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let config = app_config::load();
let db = app_db::connect(config.database_url.expose_secret()).await;
let listener = tokio::net::TcpListener::bind(&config.listen_addr)
.await
.expect("failed to bind listener");
tracing::info!("listening on {}", config.listen_addr);
let app = app_web::router(db);
axum::serve(listener, app).await.expect("server error");
}
dotenv().ok() silently ignores a missing .env file. In production, no .env file exists and all variables come from the deployment platform. In development, the .env file is present and its values are loaded before any threads are spawned.
The Config struct
Parse all environment variables into a single typed struct at startup. If a required variable is missing or malformed, the application panics immediately with a clear error message. Failing fast at startup is better than discovering a missing variable minutes later when a handler first needs it.
// crates/config/src/lib.rs
use secrecy::{ExposeSecret, SecretString};
pub struct Config {
// Server
pub listen_addr: String,
// Database
pub database_url: SecretString,
// Email
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_tls: bool,
pub smtp_username: Option<String>,
pub smtp_password: Option<SecretString>,
pub email_from: String,
// Object storage
pub s3_endpoint: String,
pub s3_region: String,
pub s3_bucket: String,
pub s3_access_key: SecretString,
pub s3_secret_key: SecretString,
}
pub fn load() -> Config {
Config {
listen_addr: format!(
"{}:{}",
optional("HOST", "127.0.0.1"),
optional("PORT", "3000"),
),
database_url: required_secret("DATABASE_URL"),
smtp_host: required("SMTP_HOST"),
smtp_port: parse("SMTP_PORT", 1025),
smtp_tls: optional("SMTP_TLS", "false") == "true",
smtp_username: std::env::var("SMTP_USERNAME").ok(),
smtp_password: optional_secret("SMTP_PASSWORD"),
email_from: required("EMAIL_FROM"),
s3_endpoint: required("S3_ENDPOINT"),
s3_region: optional("S3_REGION", "us-east-1"),
s3_bucket: required("S3_BUCKET"),
s3_access_key: required_secret("S3_ACCESS_KEY"),
s3_secret_key: required_secret("S3_SECRET_KEY"),
}
}
fn required(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set"))
}
fn required_secret(name: &str) -> SecretString {
SecretString::from(required(name))
}
fn optional_secret(name: &str) -> Option<SecretString> {
std::env::var(name).ok().map(SecretString::from)
}
fn optional(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}
fn parse<T: std::str::FromStr>(name: &str, default: T) -> T
where
T::Err: std::fmt::Debug,
{
match std::env::var(name) {
Ok(val) => val
.parse()
.unwrap_or_else(|_| panic!("{name} must be a valid {}", std::any::type_name::<T>())),
Err(_) => default,
}
}
Five helper functions cover every case: required panics on missing variables, required_secret wraps the value in SecretString, optional_secret returns Option<SecretString> for credentials that are only present in some environments, optional provides a default, and parse handles non-string types. These helpers include the variable name in the panic message, which std::env::var does not do on its own.
Sharing config through application state
Pass the Config into Axum’s application state alongside the database pool and other shared resources:
use app_config::Config;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub db: sqlx::PgPool,
}
Wrap Config in Arc because it does not derive Clone (the SecretString fields implement Clone, but wrapping in Arc avoids cloning secret data on every request). Handlers access configuration through the state extractor:
use axum::extract::State;
async fn some_handler(State(state): State<AppState>) {
let from = &state.config.email_from;
// ...
}Optional configuration with Option<T>
Use Option<T> for configuration that is only present in some environments. SMTP credentials are a common example: MailCrab in development accepts unauthenticated connections, while production SMTP providers require credentials.
pub smtp_username: Option<String>,
pub smtp_password: Option<SecretString>,
Read optional variables with .ok(), which converts Err(VarError::NotPresent) to None:
smtp_username: std::env::var("SMTP_USERNAME").ok(),
smtp_password: std::env::var("SMTP_PASSWORD").ok().map(SecretString::from),
This pattern works for any feature that is conditionally enabled: a Sentry DSN, a Redis URL for caching, external API keys for optional integrations. The handler checks if let Some(ref password) = config.smtp_password and adapts its behaviour.
Protecting secrets with secrecy
The secrecy crate wraps sensitive values in SecretBox<T>, which provides two protections:
-
Memory zeroing. When a
SecretBoxis dropped, its contents are overwritten with zeros before deallocation. This prevents secrets from lingering in freed memory where a crash dump or memory scan could recover them. -
Debug redaction. The
Debugimplementation printsSecretBox<str>([REDACTED])instead of the actual value. If aConfigstruct ends up in a panic message, log line, or error report, secrets are not exposed.
Access the underlying value explicitly with expose_secret():
use secrecy::ExposeSecret;
let pool = sqlx::PgPool::connect(config.database_url.expose_secret()).await?;
The explicit .expose_secret() call makes every point where a secret is used visible and auditable. A grep for expose_secret shows exactly where secrets flow in the codebase.
What to wrap
Wrap values that would cause damage if leaked: database connection strings (which contain passwords), API keys, SMTP passwords, S3 secret keys, session signing keys. Do not wrap non-sensitive values like hostnames, ports, or log levels.
.env.example
Commit a .env.example file to version control that documents every variable the application needs. New developers copy it to .env and fill in real values. Any time a new variable is added to the Config struct, add a corresponding entry to .env.example.
# Server
HOST=127.0.0.1
PORT=3000
# Database
DATABASE_URL=postgres://postgres:password@localhost:5432/myapp_dev
# Email (MailCrab in development)
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_TLS=false
EMAIL_FROM="MyApp <noreply@example.com>"
# Object storage (RustFS in development)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=myapp
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Optional
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SENTRY_DSN=
Add .env to .gitignore to prevent committing real secrets. The .env.example file is safe to commit because it contains only placeholder values and local development defaults.
.env
!.env.exampleSecrets in production
In production, environment variables come from the deployment platform, not a .env file. The Config::load() function does not care where the variables originate. It calls std::env::var(), which reads whatever is in the process environment.
Docker Compose
The simplest production pattern is an env_file directive in Docker Compose that points to a file on the host server:
# compose.prod.yaml
services:
app:
image: myapp:latest
env_file:
- .env.production
ports:
- "3000:3000"
The .env.production file lives on the server, not in the repository. It is created during provisioning and contains real credentials. Alternatively, set variables directly in the environment block:
services:
app:
image: myapp:latest
environment:
DATABASE_URL: postgres://user:${DB_PASSWORD}@db:5432/myapp
SMTP_HOST: smtp.example.com
SMTP_TLS: "true"
Variables referenced with ${} syntax are interpolated from the host shell environment, which allows Terraform or CI/CD to inject them without writing credentials into the Compose file.
Terraform provisioning
When deploying to a Hetzner VPS with Terraform, provision the .env.production file on the server during infrastructure setup:
resource "null_resource" "deploy_env" {
provisioner "file" {
content = templatefile("env.production.tftpl", {
database_url = var.database_url
smtp_host = var.smtp_host
smtp_password = var.smtp_password
s3_access_key = var.s3_access_key
s3_secret_key = var.s3_secret_key
})
destination = "/opt/myapp/.env.production"
}
}
The template file (env.production.tftpl) contains variable placeholders that Terraform fills in:
DATABASE_URL=${database_url}
SMTP_HOST=${smtp_host}
SMTP_PASSWORD=${smtp_password}
S3_ACCESS_KEY=${s3_access_key}
S3_SECRET_KEY=${s3_secret_key}
Terraform variables themselves come from terraform.tfvars (git-ignored) or TF_VAR_* environment variables set in CI/CD. The chain is: CI/CD secrets store → Terraform variables → provisioned file on server → Docker Compose env_file → process environment → Config::load().
GitHub Actions
For CI/CD pipelines, store secrets in GitHub Actions and pass them to deployment scripts:
jobs:
deploy:
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
run: ./deploy.sh
GitHub encrypts secrets at rest and masks them in log output. They are available only to workflows running on the repository.
Gotchas
std::env::var does not include the variable name in its error. Err(VarError::NotPresent) does not say which variable was missing. Always use a wrapper like the required() helper above, which includes the name in the panic message.
dotenvy does not override existing variables. If DATABASE_URL is already set in the shell, the .env file value is ignored. This is correct behaviour: production values set by the platform should not be overridden by a .env file. Use dotenvy::dotenv_override() if you need the opposite (rarely useful).
Parse errors are confusing without context. "abc".parse::<u16>().unwrap() panics with a bare ParseIntError. The parse() helper above includes the variable name in the error, making startup failures easy to diagnose.
Secrets in panic messages. If you unwrap a String containing a database password and the operation panics, the password appears in the panic output. Wrapping secrets in SecretString prevents this. The Debug output shows [REDACTED] instead of the value.
Compile-time environment variables for SQLx. SQLx’s query! macro reads DATABASE_URL at compile time to check queries against the database schema. This uses dotenvy internally (SQLx depends on it). The compile-time .env file must contain a valid DATABASE_URL that points to a running database, even though the application reads it at runtime too.