Rust Web Server with Axum 0.8: Configuration with .env, std::env and OnceLock
October 29, 2026
Episode 7 of the Rust Backend series, where we build the backend of a blog step by step. After episode 6 our project has four modules, but the server still always listens on port 3000, written in the code. Today the settings come from the environment: std::env::var, defaults, a .env file loaded with the dotenvy crate, a config we read once with OnceLock, and a clear message when a value is wrong. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 6 (axum 0.8.9, tokio, serde, serde_json, thiserror). One new crate today: dotenvy.
1. Environment variables
Every program gets environment variables. std::env::var("PORT") reads one. It returns a Result<String, VarError>, because the variable may not exist, so we decide what happens when it is missing.
2. config.rs: the Config struct
Create src/config.rs:
touch src/config.rspub struct Config {
pub host: String,
pub port: u16,
}The port is a u16, because ports go from 0 to 65535.
3. from_env
from_env reads HOST and PORT. If one is missing, unwrap_or gives a default. Then parse turns the port text into a u16:
impl Config {
fn from_env() -> Config {
let host = std::env::var("HOST").unwrap_or("0.0.0.0".to_string());
let port = std::env::var("PORT").unwrap_or("3000".to_string());
let port = port.parse().unwrap();
Config { host, port }
}
}4. OnceLock: read the config once
We want to read the config once and use it everywhere. A static OnceLock holds a value that is set the first time and never changes. config() gets it, or creates it on the first call. Add use std::sync::OnceLock; at the top of config.rs, and at the bottom:
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn config() -> &'static Config {
CONFIG.get_or_init(Config::from_env)
}5. main.rs
Add mod config; at the top, read the config at the start of main, and bind to its host and port:
mod config;
// in main():
let cfg = config::config();
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
println!("Listening on http://{addr}");Run it:
cargo runListening on http://0.0.0.0:3000Port 3000 is the default. Stop it, and put PORT=4000 in front:
PORT=4000 cargo runListening on http://0.0.0.0:40006. A .env file with dotenvy
Typing that every time is tiring. A .env file keeps the values, and the dotenvy crate loads it into the environment:
cargo add dotenvyCreate .env in the project root (next to Cargo.toml):
touch .envHOST=0.0.0.0
PORT=8080Then add one line at the start of from_env:
dotenvy::dotenv().ok();Run cargo run: now it listens on port 8080, from the file. A variable you set in the shell still wins over .env, so PORT=4000 cargo run gives 4000.
7. A typo in .env, and the unwrap panic
Change the file to PORT=8O80, with the letter O instead of a zero, and run again:
thread 'main' (14189) panicked at src/config.rs:13:33:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceIt panics, but which value? The message does not say.
8. A clearer panic with unwrap_or_else
Replace unwrap with unwrap_or_else and our own panic!:
let port = port
.parse()
.unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));thread 'main' (14640) panicked at src/config.rs:15:33:
PORT "8O80" is not a port number: invalid digit found in string
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceNow we know exactly what to fix. And with PORT=70000, the u16 catches it:
thread 'main' (15092) panicked at src/config.rs:15:33:
PORT "70000" is not a port number: number too large to fit in target type
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceFix the file back to PORT=8080. The config is read at the very start of main, so a bad value stops the server before it accepts a single request.
9. .gitignore and .env.example
.env often holds secrets later, like passwords, so it goes into .gitignore. A .env.example shows which names exist, without real values. Add a line to .gitignore:
/target
.envAnd create .env.example:
HOST=0.0.0.0
PORT=808010. Run it again
cargo runIn a second terminal:
curl -i localhost:8080/postsHTTP/1.1 200 OK
content-type: application/json
content-length: 2
[](date: line left out.) Same API as before, now on the port from .env.
The final config.rs
use std::sync::OnceLock;
pub struct Config {
pub host: String,
pub port: u16,
}
impl Config {
fn from_env() -> Config {
dotenvy::dotenv().ok();
let host = std::env::var("HOST").unwrap_or("0.0.0.0".to_string());
let port = std::env::var("PORT").unwrap_or("3000".to_string());
let port = port
.parse()
.unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));
Config { host, port }
}
}
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn config() -> &'static Config {
CONFIG.get_or_init(Config::from_env)
}The final main.rs
mod config;
mod error;
mod handlers;
mod models;
use axum::{routing::get, Router};
use models::Db;
#[tokio::main]
async fn main() {
let cfg = config::config();
let addr = format!("{}:{}", cfg.host, cfg.port);
let db = Db::default();
let app = Router::new()
.route("/posts", get(handlers::list_posts).post(handlers::create_post))
.route(
"/posts/{id}",
get(handlers::get_post).put(handlers::update_post).delete(handlers::delete_post),
)
.with_state(db);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
println!("Listening on http://{addr}");
axum::serve(listener, app).await.unwrap();
}Recap
std::env::varreads an environment variable and returns aResultunwrap_orgives a default when it is missing;parseturns text into au16unwrap_or_elsewithpanic!gives a clear message that names the valueOnceLockholds a config that is read once and used everywhere.envwithdotenvykeeps the values in a file; shell variables still win.envgoes into.gitignore;.env.exampleshows the names
Next episode: logging. We replace println! with tracing and see every request the server gets. Watch the series on YouTube.