This version is in beta. Some features may change before release.

Settings and environment variables

How umbral reads umbral.toml, .env, and UMBRAL_-prefixed env vars, plus how to access settings from handlers, plugins, or task bodies.

Every umbral app has one ambient Settings value loaded once at boot from configuration files and environment variables, with later sources overriding earlier ones:

  1. Built-in defaults: database_url = "sqlite::memory:", bind_addr = "127.0.0.1:8000", etc.
  2. umbral.toml at the project root.
  3. .env at the project root, merged like environment variables for local development.
  4. UMBRAL_-prefixed environment variables already set in the process.

After App::build runs, umbral::settings::get() returns the resolved value from anywhere in the app: handlers, plugin code, task bodies, signal handlers. No state plumbing, no extractors.

The known fields

Code
rust
pub struct Settings {
pub database_url: String, // "sqlite::memory:" by default
pub databases: HashMap<String, String>, // named secondary pools (optional)
pub db_max_connections: u32, // 10 (pool size)
pub db_acquire_timeout_secs: u64, // 30 (wait for a free connection)
pub db_min_connections: u32, // 0 (idle-connection floor)
pub db_idle_timeout_secs: Option<u64>, // Some(600); 0/empty disables
pub db_max_lifetime_secs: Option<u64>, // Some(1800); 0/empty disables
pub db_test_before_acquire: bool, // true (health-check on acquire)
pub secret_key: String, // insecure dev default; required for prod
pub environment: Environment, // Dev | Test | Prod (debug→Dev, release→Prod)
pub allowed_hosts: Vec<String>, // ["localhost", "127.0.0.1"] by default
pub log_level: String, // "info"
pub bind_addr: String, // "127.0.0.1:8000"
pub time_zone: Option<String>, // None (UTC everywhere) by default
pub static_url: String, // "/static/"
pub static_root: String, // "staticfiles/"
pub extra: HashMap<String, toml::Value>, // catch-all for app-defined keys
}
FieldEnv varumbral.toml keyDefault
database_urlUMBRAL_DATABASE_URLdatabase_url"sqlite::memory:"
databasesUMBRAL_DATABASES__<NAME> (double underscore)[databases] tableempty map
db_max_connectionsUMBRAL_DB_MAX_CONNECTIONSdb_max_connections10
db_acquire_timeout_secsUMBRAL_DB_ACQUIRE_TIMEOUT_SECSdb_acquire_timeout_secs30
db_min_connectionsUMBRAL_DB_MIN_CONNECTIONSdb_min_connections0
db_idle_timeout_secsUMBRAL_DB_IDLE_TIMEOUT_SECSdb_idle_timeout_secsSome(600) (0/empty disables)
db_max_lifetime_secsUMBRAL_DB_MAX_LIFETIME_SECSdb_max_lifetime_secsSome(1800) (0/empty disables)
db_test_before_acquireUMBRAL_DB_TEST_BEFORE_ACQUIREdb_test_before_acquiretrue
secret_keyUMBRAL_SECRET_KEYsecret_key"umbral-insecure-dev-key-change-me" (rejected in prod)
environmentUMBRAL_ENVIRONMENTenvironmentdebug build → Dev, release build → Prod (values: Dev / Test / Prod)
allowed_hostsUMBRAL_ALLOWED_HOSTS (comma-list)allowed_hosts["localhost", "127.0.0.1"]
log_levelUMBRAL_LOG_LEVELlog_level"info"
trusted_proxy_hopsUMBRAL_TRUSTED_PROXY_HOPStrusted_proxy_hops0 (trust no proxy — see below)
bind_addrUMBRAL_BIND_ADDRbind_addr"127.0.0.1:8000"
time_zoneUMBRAL_TIME_ZONEtime_zoneNone (treats naive datetimes as UTC)
static_urlUMBRAL_STATIC_URLstatic_url"/static/"
static_rootUMBRAL_STATIC_ROOTstatic_root"staticfiles/"
Warning

Set trusted_proxy_hops to the number of reverse proxies YOU run in front of the app. Rate-limiting and abuse controls (login/password-reset throttles, REST throttles) key on the caller's IP, derived from X-Forwarded-For. That header is client-controlled: with no trusted proxy, an attacker rotates it to get a fresh rate-limit bucket per request and bypasses every limit. So the default is 0trust nothing: the framework refuses to read an IP from the header, and throttles fall back to a shared bucket that can't be forged. Behind one nginx / cloud load balancer, set trusted_proxy_hops=1; the real client is then taken as the entry your proxy appended (the rightmost), ignoring anything the client prepended. Set it to the exact number of proxies that append to the header — too high lets a client spoof one hop deeper.

Warning

The db_* pool knobs don't reach a pool opened before App::build(). umbral::db::connect reads them from the ambient settings, and the ambient settings are only published during build(). In the canonical boot order (Settings::from_env()connect(&settings.database_url).database("default", pool).build()) the default pool is opened before that point, so it always gets the built-in defaults (max_connections = 10, acquire_timeout = 30s, min_connections = 0, idle_timeout = 600s, max_lifetime = 1800s, test_before_acquire = true) - setting UMBRAL_DB_MAX_CONNECTIONS etc. has no effect on it. Pools opened after build() (runtime tenant pools) do honour these settings. To size the default pool, build it directly with sqlx::postgres::PgPoolOptions / sqlx::sqlite::SqlitePoolOptions using the values from your loaded Settings, and pass that pool to .database("default", pool). Check the umbral: opening database pool boot-log line for the effective values.

umbral.toml

The project's committed configuration file. Lives at the project root next to Cargo.toml:

Code
toml
# umbral.toml
database_url = "sqlite://app.db?mode=rwc"
bind_addr = "0.0.0.0:8000"
secret_key = "replace-me-with-openssl-rand-hex-32"
environment = "Prod"
log_level = "info"
allowed_hosts = ["example.com", "www.example.com"]
 
# Named secondary database URLs. NOTE: the framework does NOT open pools
# from this map - each alias must be connected and registered on the
# builder yourself (see below). Models route to an alias via
# `#[umbral(database = "alias")]` or per-plugin via `Plugin::database()`.
[databases]
analytics = "postgres://analytics:pw@db-host/analytics"
cache = "sqlite://cache.db?mode=rwc"
Info

App::build() opens a pool for every settings.databases entry — lazily (the pool connects on first use), so a model routed to an alias via #[umbral(database = "alias")] (or a Plugin::database()) just works with no extra wiring. The lazy pools use the same UMBRAL_DB_* knobs and per-connection settings (SQLite PRAGMAs, the RLS session-var hook) as the default pool.

You only need to build a pool by hand if you want to control the alias's pool object directly (a custom PgPoolOptions, a shared pool, a test pool) — an explicit .database("alias", pool) overrides the settings.databases URL for that alias:

Code
rust
let settings = Settings::from_env()?;
let default_pool = umbral::db::connect(&settings.database_url).await?;
let app = App::builder()
.settings(settings)
.database("default", default_pool)
// "analytics" / "cache" from [databases] are opened automatically;
// register one explicitly only to override its pool object:
// .database("analytics", my_tuned_pool)
.build()?;

Commit umbral.toml to git for everything that isn't a secret. Keep secrets (secret_key, real database_url with passwords) out of the committed copy and supply them via env vars instead.

Environment variables (production / containers)

Code
bash
export UMBRAL_DATABASE_URL="postgres://app:pw@db/app"
export UMBRAL_BIND_ADDR="0.0.0.0:8080"
export UMBRAL_SECRET_KEY="$(openssl rand -hex 32)"
export UMBRAL_ENVIRONMENT="Prod"
 
cargo run -- serve

The UMBRAL_ prefix marks the env var as framework-owned. Anything else in your shell is invisible to umbral's settings loader, so there's no accidental capture.

Secure by default: release builds are Prod

When UMBRAL_ENVIRONMENT is unset, the default depends on the build profile: a debug build (cargo run, cargo test) is Dev, and a release build (cargo build --release) is Prod. So a production binary that forgets the env var still boots locked down — Host validation on, the insecure dev secret_key rejected at boot, prod error pages — rather than silently serving with dev protections off. Setting UMBRAL_ENVIRONMENT explicitly always wins, so you can still run a release binary as Dev (or a debug build as Prod) when you mean to.

Reading settings from anywhere

Code
rust
// In a handler:
async fn home() -> Html<String> {
let settings = umbral::settings::get();
Html(format!("running in {:?} mode", settings.environment))
}
 
// In a Plugin::on_ready:
fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
let settings = umbral::settings::get();
if matches!(settings.environment, umbral::Environment::Prod) {
// production-only setup
}
Ok(())
}
 
// In a #[task] body:
#[task]
async fn ping_external_api(payload: PingPayload) -> Result<(), String> {
let api_key = umbral::settings::get_opt()
.and_then(|s| s.extra_str("openai_api_key").map(str::to_string))
.ok_or("OPENAI_API_KEY not configured")?;
// ... call the API
Ok(())
}

umbral::settings::get() returns &'static Settings and panics if App::build hasn't run. umbral::settings::get_opt() returns Option<&'static Settings>; use it in code that may run before App::build finishes (rare, but possible in macro-emitted helpers, test fixtures, etc.).

Info
The `&'static` lifetime is real: the settings live for the process. You can stash field clones (`settings.secret_key.clone()`) freely; the underlying struct doesn't move.

Custom settings (the extra field)

Most real apps need keys umbral doesn't know about: OPENAI_API_KEY, STRIPE_SECRET, third-party plugin config. The extra: HashMap<String, toml::Value> field catches everything that doesn't match a known field name:

Code
bash
# Env var
export UMBRAL_OPENAI_API_KEY="sk-test"
Code
toml
# umbral.toml - both work
openai_api_key = "sk-test"
 
# Or nested
[external.openai]
api_key = "sk-test"
model = "gpt-4o"

Read with extra_str for the common scalar-string case:

Code
rust
let key = umbral::settings::get()
.extra_str("openai_api_key")
.ok_or("missing OPENAI_API_KEY")?;

For nested values ([external.openai]), index extra directly:

Code
rust
let cfg = umbral::settings::get();
let model: &str = cfg.extra
.get("external")
.and_then(|v| v.get("openai"))
.and_then(|v| v.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("gpt-4o");

.env files

Settings::from_env() automatically reads a project-root .env file before reading UMBRAL_ variables from the process environment. This is the local-dev place for values you do not want to commit:

Code
bash
cat > .env <<'EOF'
UMBRAL_DATABASE_URL=postgres://app:pw@localhost/app_dev
UMBRAL_SECRET_KEY=dev-only-secret
EOF
 
cargo run -- serve

Values already exported by the shell, Docker, systemd, or a hosting provider win over .env. That keeps production configuration explicit while still making local development convenient.

Precedence summary

Later wins:

  1. Struct defaults (in Settings::default_* functions).
  2. umbral.toml at the project root.
  3. UMBRAL_ variables from .env, unless the process already set the same key.
  4. UMBRAL_-prefixed env vars already present in the process environment.
  5. The --addr flag on umbral serve (one-off override; doesn't write back to settings).

When the server starts it logs the bound address:

Code
txt
INFO umbral_core::app: umbral serving on 127.0.0.1:8000

Loading settings yourself (rare)

App::builder().settings(...) accepts a pre-built Settings. The common shape uses from_env:

Code
rust
let settings = umbral::Settings::from_env()?; // figment-loaded
let app = App::builder().settings(settings).build()?;

For tests, build a Settings literal directly:

Code
rust
let settings = umbral::Settings {
database_url: "sqlite::memory:".into(),
environment: umbral::Environment::Test,
..umbral::Settings::from_env()?
};

See also

getting-startedsettingsenv