M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 7 of 42

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.

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:

bash
touch src/config.rs
rust
pub 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:

rust
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:

rust
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:

rust
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:

bash
cargo run
text
Listening on http://0.0.0.0:3000

Port 3000 is the default. Stop it, and put PORT=4000 in front:

bash
PORT=4000 cargo run
text
Listening on http://0.0.0.0:4000

6. 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:

bash
cargo add dotenvy

Create .env in the project root (next to Cargo.toml):

bash
touch .env
text
HOST=0.0.0.0
PORT=8080

Then add one line at the start of from_env:

rust
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:

text
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 backtrace

It 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!:

rust
let port = port
    .parse()
    .unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));
text
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 backtrace

Now we know exactly what to fix. And with PORT=70000, the u16 catches it:

text
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 backtrace

Fix 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:

text
/target
.env

And create .env.example:

text
HOST=0.0.0.0
PORT=8080

10. Run it again

bash
cargo run

In a second terminal:

bash
curl -i localhost:8080/posts
text
HTTP/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

rust
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

rust
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::var reads an environment variable and returns a Result
  • unwrap_or gives a default when it is missing; parse turns text into a u16
  • unwrap_or_else with panic! gives a clear message that names the value
  • OnceLock holds a config that is read once and used everywhere
  • .env with dotenvy keeps the values in a file; shell variables still win
  • .env goes into .gitignore; .env.example shows the names

Next episode: logging. We replace println! with tracing and see every request the server gets. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam