.offrails/guides/config.md
Config, Environments & Secrets
TL;DR
- One
Configstruct parsed from env vars once (Config::from_env); neverstd::env::vardeep in the code.APP_ENV= development | test | production;.envis loaded only in development;.env.examplelists every variable with dev defaults (no real secrets).- In development
APP_URLfollowsPORT; normalize it (no trailing slash).servelistens onAPP_BIND: 127.0.0.1 in development, 0.0.0.0 in production; an unsetAPP_ENV(= development) warns at every boot.- Secrets live in the deploy platform's secret store, never in git or images.
Read the rest when adding a setting, a secret, or environment-specific behavior.
| Rails | Off Rails |
|---|---|
RAILS_ENV | APP_ENV = development | test | production |
config/environments/*.rb | One match config.env per concern, inside Config::from_env or the kernel module that cares |
database.yml | DATABASE_URL (+ DATABASE_POOL) |
credentials.yml.enc + master key | Env vars, set through the deploy platform's secret store |
Rails.application.config.x.foo | A typed field on Config |
.env (dotenv-rails) | dotenvy, loaded only in development |
src/config.rs
/// The human product name: page titles, kernel UIs (/dev, /admin/jobs), the default MAIL_FROM.
/// Never derived from the crate name (env!("CARGO_PKG_NAME")): crates get renamed, products don't.
pub const APP_NAME: &str = "Bookshelf";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppEnv { Development, Test, Production }
#[derive(Debug, Clone)]
pub struct Config {
pub env: AppEnv,
pub database_url: String,
pub database_pool: u32, // DATABASE_POOL, default 4 on SQLite, 10 on Postgres
pub worker_threads: usize, // APP_WORKER_THREADS, default 2: Tokio workers (main builds the runtime from it)
pub blocking_threads: usize, // APP_BLOCKING_THREADS, default 16: cap on Tokio's blocking pool (Tokio's own: 512)
pub app_url: String, // APP_URL, absolute, no trailing slash: used in emails and the origin check
pub port: u16, // PORT, default 3000
pub inline_worker: bool, // APP_INLINE_WORKER: default true in dev, and in prod on SQLite
pub queues: Vec<(String, usize)>, // APP_QUEUES, default "default:10,mailers:5" (queue:concurrency per node)
pub jobs_prune_days: u32, // APP_JOBS_PRUNE_DAYS, default 7: how long finished jobs stay visible
pub jobs_shutdown_grace: u64, // APP_JOBS_SHUTDOWN_GRACE seconds, default 25
pub shutdown_drain: Duration, // APP_SHUTDOWN_DRAIN_SECS, default 5: after the signal, open connections get this long, then are dropped (realtime.md)
pub refresh_wait: Duration, // not an env var: 5 s (Config::test(): 100 ms); how long a live re-fetch waits at the gate before 503 (realtime.md)
pub admin_password: Option<String>, // APP_ADMIN_PASSWORD: HTTP Basic for /admin/jobs when the app has no admin role
pub smtp_url: Option<String>, // SMTP_URL: production delivery (ignored in development unless MAILER=smtp)
pub mailer: Option<String>, // MAILER = smtp | local | log: overrides the per-environment default (mailers.md)
pub mail_from: String, // MAIL_FROM, default "<App> <no-reply@localhost>"
pub trusted_proxy: bool, // APP_TRUSTED_PROXY: read the client IP from X-Forwarded-For (required behind any proxy; rate limiting)
pub trusted_proxy_hops: u8, // APP_TRUSTED_PROXY_HOPS (default 1): how many proxies append to the header; the client is that many hops from the right
pub log_format: LogFormat, // LOG_FORMAT = pretty | json (default: json in production)
pub revision: Option<String>, // APP_REVISION: git sha of the build, shown on /dev/system
pub tls: Option<TlsFiles>, // APP_TLS_CERT + APP_TLS_KEY (PEM paths): `serve` speaks HTTPS + h2 itself (deployment.md#direct-tls-optional); both or neither, one alone fails the boot.
pub dev_http_port: Option<u16>, // APP_DEV_HTTP_PORT: development only (any other APP_ENV fails the boot); with TLS on, the same router on plain HTTP beside it (dev-tools.md#https-in-development)
// The development APP_URL default follows it: https://localhost:{PORT} when set (CSRF compares origins).
pub bind: IpAddr, // APP_BIND: the address `serve` listens on. Default 127.0.0.1 in development (this machine only), 0.0.0.0 otherwise (security.md#development-is-this-machine-only)
pub allowed_hosts: Vec<String>, // APP_ALLOWED_HOSTS: extra Host names development answers (a tunnel, a LAN name); ".example.com" = its subdomains too
pub env_defaulted: bool, // always false: an unset APP_ENV refuses to boot (kept for parts that read it)
// apps with an API (api.md):
// pub cors_origins: Vec<String>, // APP_CORS_ORIGINS: exact origins, comma-separated; `*` fails at boot (config::parse_cors_origins)
// added with the first attachment field (storage.md):
// pub storage: StorageConfig, // STORAGE_SERVICE, STORAGE_PATH, STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_PUBLIC_HOST
// app-specific settings: APP_* prefixed, typed, documented in .env.example
}
impl Config {
/// Called once in main. Development: `dotenvy::dotenv().ok()` first.
/// Collects ALL missing/invalid vars, then fails with one message listing them.
pub fn from_env() -> anyhow::Result<Self>;
pub fn test() -> Self; // the test suite's config: APP_ENV=test, sqlite::memory:, never reads env
pub fn is_production(&self) -> bool;
pub fn is_development(&self) -> bool;
pub fn boot_warnings(&self) -> Vec<String>; // below; `main` logs each at warn right after tracing starts
}
/// "default:10,mailers:5" → [("default", 10), ("mailers", 5)]; also used by `app worker --queues`.
pub fn parse_queues(s: &str) -> Result<Vec<(String, usize)>, String>;Where serve listens (APP_BIND, APP_ALLOWED_HOSTS) and the unset-APP_ENV warning
Development mounts /dev (the mailbox with every login link, introspection, the jobs dashboard) without a login, so it listens on this machine only, and an unset APP_ENV is said out loud at every boot (security). Copy these functions and their tests into config.rs (from an app generated from these docs, 2026-10-16: compiled, clippy-clean, green on SQLite and Postgres):
use std::net::{IpAddr, Ipv4Addr};
/// `APP_BIND`, or the environment's default: loopback in development, every interface otherwise.
// SECURITY: development serves /dev tools without a login; on 0.0.0.0 anyone on the same Wi-Fi
// could open them. A dev container sets APP_BIND=0.0.0.0 explicitly
// (test: development_binds_loopback_unless_told_otherwise).
pub fn bind_address(env: AppEnv, raw: Option<&str>) -> Result<IpAddr, String> {
match raw.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => v.trim_start_matches('[').trim_end_matches(']').parse().map_err(|_| {
format!("APP_BIND (expected an IP address such as 127.0.0.1 or 0.0.0.0, got {v:?})")
}),
None if env == AppEnv::Development => Ok(IpAddr::V4(Ipv4Addr::LOCALHOST)),
None => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
}
}
/// `APP_ALLOWED_HOSTS`: comma-separated host names, lowercased.
pub fn parse_allowed_hosts(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or_default()
.split(',')
.map(|h| h.trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect()
}
impl Config {
/// Loud warnings `main` logs right after logging starts (parts add theirs here).
pub fn boot_warnings(&self) -> Vec<String> {
Vec::new()
}
}APP_ENV is required. from_env reads it; when it is unset it loads .env (bin/setup writes APP_ENV=development there) and reads again; still unset is an error naming the fix, never a default:
// SECURITY: an unset APP_ENV is an error, never a default. Development mounts /dev tools
// without a login, so "forgot to set it on a server" must not silently become development
// (test: an_unset_app_env_refuses_to_boot).
if get_env().is_none() {
dotenvy::dotenv().ok();
}
let env = match get_env().as_deref() {
Some("development") => AppEnv::Development,
Some("test") => AppEnv::Test,
Some("production") => AppEnv::Production,
Some(other) => anyhow::bail!("invalid APP_ENV: {other} (development | test | production)"),
None => anyhow::bail!("APP_ENV is not set. Locally: APP_ENV=development in .env (bin/setup creates it \
from .env.example). On every server: APP_ENV=production (the Dockerfile sets it)."),
};
// … with the other settings; a bad value joins the one "invalid env vars" message:
let bind = bind_address(env, get("APP_BIND").as_deref()).unwrap_or_else(|problem| {
invalid.push(problem);
IpAddr::V4(Ipv4Addr::LOCALHOST)
});
let allowed_hosts = parse_allowed_hosts(get("APP_ALLOWED_HOSTS").as_deref());Config::test() sets bind: IpAddr::V4(Ipv4Addr::LOCALHOST), allowed_hosts: Vec::new(), env_defaulted: false; the hand-written Debug prints all three (none is a secret). main runs for w in config.boot_warnings() { tracing::warn!("{w}"); } right after init_tracing (app routes returns before from_env: the route table needs no config), and serve binds SocketAddr::new(config.bind, config.port) (and APP_DEV_HTTP_PORT on the same config.bind). The boot test lives in tests/app/kernel_test.rs (an integration test, where CARGO_BIN_EXE_offrails_app exists), the rest in config.rs:
/// Runs the binary in a directory with no `.env`, so the fallback has nothing to load.
#[test]
fn an_unset_app_env_refuses_to_boot() {
let dir = tempfile::tempdir().unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_offrails_app"))
.arg("db").arg("status").env_remove("APP_ENV").current_dir(dir.path()).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("APP_ENV is not set"));
}
#[test]
fn development_binds_loopback_unless_told_otherwise() {
let (local, any) = (IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V4(Ipv4Addr::UNSPECIFIED));
assert_eq!(bind_address(AppEnv::Development, None), Ok(local));
assert_eq!(bind_address(AppEnv::Development, Some("")), Ok(local));
assert_eq!(bind_address(AppEnv::Development, Some("0.0.0.0")), Ok(any)); // a container opts in
assert_eq!(bind_address(AppEnv::Development, Some("[::1]")), Ok("::1".parse().unwrap()));
assert_eq!(bind_address(AppEnv::Production, None), Ok(any)); // behind the platform's proxy
assert!(bind_address(AppEnv::Development, Some("localhost")).is_err());
assert_eq!(parse_allowed_hosts(Some(" Dev.Example.com, .tunnel.example ,")), ["dev.example.com", ".tunnel.example"]);
}-
Configholds credentials, so implementDebugby hand as an allowlist: print the value only for settings that can never hold a secret (env,port,app_url,database_pool,queues, the numbers, durations and booleans,bind,allowed_hosts,log_format,revision,mail_from), the database as its backend and host only (redact_db), andset/not setfor everything else,smtp_url,admin_passwordand every field added later included, until you add it to the allowlist on purpose. A denylist ([redacted]for three named fields) leaks the next secret someone adds (STRIPE_SECRET_KEY, a webhook signing key) on/dev/introspect.jsonandapp introspect. Testdebug_shows_no_secret_values: aConfigwith a sentinel ("sentinel-secret") in every string field outside the allowlist, andformat!("{c:?}")contains no sentinel. -
Env var names are string literals in
config.rs(var("APP_URL"),parse("PORT", "3000"), never a name built withformat!), and every"SCREAMING_SNAKE"literal there is an env var:offrails-check's[env]rule reads them all and fails on any missing from.env.example. A literal that isn't one gets a trailing// not an env var. -
Read env vars only in
config.rs. Nowhere else callsstd::env::var(the AWS keys read byobject_store'sfrom_env()are the one sanctioned exception). -
Configlives inAppState(state.config), wrapped inArcif it gets large. -
Fail fast in production. Missing
DATABASE_URL,APP_URL, orSMTP_URL(when email is enabled) aborts boot with:missing required env vars: APP_URL, SMTP_URL (see .env.example). -
Development has defaults for everything except
APP_ENVitself, socargo runworks once.envsaysAPP_ENV=development(bin/setupwrites it):DATABASE_URL=sqlite://storage/development.sqlite3?mode=rwcAPP_URL=http://localhost:{PORT}: it followsPORT(config::dev_app_url(port)), so emailed links and the CSRF origin check still work when you run on another port. ParsePORTbeforeAPP_URL.
-
APP_ENVunset refuses to boot with a message naming the fix (above): on a server an unsetAPP_ENVis a missing setting that would mount/devand/admin/jobswithout a login, and a default is not a gate. The Dockerfile setsAPP_ENV=production, and so must every deploy config.TestAppbuildsConfigdirectly (Config::test()) and never reads the environment. -
servelistens onAPP_BIND: 127.0.0.1 in development, 0.0.0.0 in production (the container's interface, behind the platform's proxy). A dev container, or a phone on your LAN, needsAPP_BIND=0.0.0.0and its host name inAPP_ALLOWED_HOSTS(security).
Runtime threads (APP_WORKER_THREADS, APP_BLOCKING_THREADS)
main parses Config first, then builds the Tokio runtime from it (no #[tokio::main]): 2 worker threads and at most 16 blocking threads by default, instead of one worker per core and 512 blocking threads. Each idle thread holds a stack and an allocator arena, so a small app on a many-core box stays small. Raise APP_WORKER_THREADS when request handlers do real CPU work (rendering big pages, image processing) on a box with cores to spare, and APP_BLOCKING_THREADS when spawn_blocking work (password hashing, file I/O) queues under load. I/O-bound apps rarely gain past 2–4 workers. The file is template/app/src/main.rs; database has the SQLite side and the measured numbers.
Per-environment behavior
Put it in one place, as a match:
// src/state.rs: select_mailer(&config, &db). The kernel only has constructors (Mailer::smtp/local/memory, Mailer::Log).
Ok(match (c.env, c.mailer.as_deref()) {
(AppEnv::Test, _) => Mailer::memory(),
(_, Some("smtp")) => smtp(from)?, // needs SMTP_URL: try real delivery from development
(_, Some("local")) => Mailer::local(db.clone(), from),
(_, Some("log")) => Mailer::Log,
(_, Some(other)) => anyhow::bail!("MAILER must be smtp, local or log (got {other:?})"),
(AppEnv::Development, None) => Mailer::local(db.clone(), from), // ALWAYS the dev mailbox, even with SMTP_URL set
(AppEnv::Production, None) if c.smtp_url.is_some() => smtp(from)?,
(AppEnv::Production, None) => Mailer::Log, // EMAIL: not yet (logged only)
})Rate limits are per environment too (security): in development every limit is 1000 per window (rate_limit::DEV_LIMIT), so a person or agent retrying a form locally never locks themselves out; test and production use each call site's own limit (the 11th login is refused in tests exactly as in production). RateLimiter::new(config.env) decides it, once; call sites never branch on the environment.
Never use cfg!(debug_assertions) or #[cfg(debug_assertions)] for behavior. A release build running locally must behave like development when APP_ENV=development.
.env.example
This file is the documentation for every variable. One line each, grouped, with a comment:
# --- core ---
# Required. development | test | production; an unset APP_ENV refuses to boot (development mounts /dev with no login).
APP_ENV=development
PORT=3000
# APP_URL=https://myapp.example.com # required in production; in development it follows PORT (http://localhost:{PORT}), so leave it unset
DATABASE_URL=sqlite://storage/development.sqlite3?mode=rwc
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp_development
DATABASE_POOL=10
# APP_BIND=0.0.0.0 # development listens on 127.0.0.1 only (its /dev tools have no login); a dev container opts in here. Production default: 0.0.0.0
# APP_ALLOWED_HOSTS=dev.example.com,.lan.example # development answers only localhost and APP_URL's host (DNS rebinding); add a tunnel or LAN name ('.' = subdomains too)
APP_INLINE_WORKER=true
# APP_TRUSTED_PROXY=true # behind Fly, Render or any proxy: read the client IP from X-Forwarded-For (REQUIRED behind any proxy, or every user shares one rate-limit key)
# APP_TRUSTED_PROXY_HOPS=1 # proxies that append to X-Forwarded-For before the app: 1 = one platform proxy (Fly, Render), 2 = Cloudflare → Caddy → app
# --- realtime (channels.md) ---
# APP_NODE=web-1 # this process's name on /dev/sockets and in the cluster (default <hostname>:<pid>)
# APP_CLUSTER=true # Postgres only: share rooms and presence across nodes; refused at boot on SQLite
# APP_REALTIME_REVALIDATE_SECS=60 # long-lived sockets/streams re-check their session this often (logout closes them at once)
# APP_REALTIME_TRANSPORTS=websocket,sse,longpoll # drop `websocket` behind a proxy that mishandles it; clients fall back
# --- jobs ---
APP_QUEUES=default:10,mailers:5
APP_JOBS_PRUNE_DAYS=7
APP_JOBS_SHUTDOWN_GRACE=25
# APP_SHUTDOWN_DRAIN_SECS=5 # web: open connections get this long after SIGTERM (streams end first); keep it below the platform's kill timeout
# APP_ADMIN_PASSWORD= # production: protects /admin/jobs when there's no admin role
# --- email: development always uses the dev mailbox (/dev/mailbox); SMTP_URL is for production ---
# SMTP_URL=smtps://user:pass@smtp.provider.com:465
# MAILER=smtp # only to try real delivery from development (needs SMTP_URL)
MAIL_FROM="Myapp <no-reply@localhost>"
# --- storage (added with the first attachment field) ---
# STORAGE_SERVICE=local
# STORAGE_PATH=storage/files
# STORAGE_PUBLIC_HOST= # only when serving S3 presigned URLs (storage.md)
# APP_REVISION= # git sha, set by the deploy (shown on /dev/system)
# --- API (apps with an API; guides/api.md) ---
# APP_CORS_ORIGINS=https://app.example,http://localhost:5173 # browser apps on other origins; exact, never *
# --- direct TLS (optional; default: plain HTTP behind the platform's TLS proxy) ---
# APP_TLS_CERT=/etc/app/tls/cert.pem
# APP_TLS_KEY=/etc/app/tls/key.pem
# APP_DEV_HTTP_PORT=3001 # development only, with TLS on: the same app on plain http (bin/dev sets it with DEV_CERT_SELF_SIGNED=1)
# --- logging ---
RUST_LOG=info,myapp=debug
LOG_FORMAT=prettybin/setup copies it to .env if .env is missing. .env is git-ignored, always.
Secrets in production
There's no encrypted credentials file. Secrets come from the platform:
| Target | How |
|---|---|
| Fly.io | fly secrets set SMTP_URL=… |
| Kamal | .kamal/secrets (reads from a password manager or env; never commit raw values) + env.secret in config/deploy.yml |
| Railway / Render | Service variables in the dashboard or CLI |
When you add an env var: update Config, .env.example, and the deploy config. Then tell the human which secret to set and where. You can't set platform secrets for them unless they've given you CLI access.
Gotchas agents get wrong
- Reading
std::env::var("X")deep inside a handler. Add aConfigfield instead. - Calling
dotenvy::dotenv()whenAPP_ENVis already set, so a stray.envin a production image overrides real config. Only call it whenAPP_ENVis unset (to find it) ordevelopment. - Trailing slash on
APP_URL, which produces//in email links. Normalize it infrom_env. - Committing real values to
.env.example. Only dev defaults and placeholders go there. - Deriving the product name from
env!("CARGO_PKG_NAME"). Renaming the crate (for example, to avoid a binary-name clash with another app on the same machine) then renames the product in every page title. Useconfig::APP_NAME.