.offrails/guides/config.md

Config, Environments & Secrets

TL;DR

  • One Config struct parsed from env vars once (Config::from_env); never std::env::var deep in the code.
  • APP_ENV = development | test | production; .env is loaded only in development; .env.example lists every variable with dev defaults (no real secrets).
  • In development APP_URL follows PORT; normalize it (no trailing slash).
  • serve listens on APP_BIND: 127.0.0.1 in development, 0.0.0.0 in production; an unset APP_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.

RailsOff Rails
RAILS_ENVAPP_ENV = development | test | production
config/environments/*.rbOne match config.env per concern, inside Config::from_env or the kernel module that cares
database.ymlDATABASE_URL (+ DATABASE_POOL)
credentials.yml.enc + master keyEnv vars, set through the deploy platform's secret store
Rails.application.config.x.fooA 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"]);
}
  • Config holds credentials, so implement Debug by 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), and set / not set for everything else, smtp_url, admin_password and 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.json and app introspect. Test debug_shows_no_secret_values: a Config with a sentinel ("sentinel-secret") in every string field outside the allowlist, and format!("{c:?}") contains no sentinel.

  • Env var names are string literals in config.rs (var("APP_URL"), parse("PORT", "3000"), never a name built with format!), 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 calls std::env::var (the AWS keys read by object_store's from_env() are the one sanctioned exception).

  • Config lives in AppState (state.config), wrapped in Arc if it gets large.

  • Fail fast in production. Missing DATABASE_URL, APP_URL, or SMTP_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_ENV itself, so cargo run works once .env says APP_ENV=development (bin/setup writes it):

    • DATABASE_URL=sqlite://storage/development.sqlite3?mode=rwc
    • APP_URL=http://localhost:{PORT}: it follows PORT (config::dev_app_url(port)), so emailed links and the CSRF origin check still work when you run on another port. Parse PORT before APP_URL.
  • APP_ENV unset refuses to boot with a message naming the fix (above): on a server an unset APP_ENV is a missing setting that would mount /dev and /admin/jobs without a login, and a default is not a gate. The Dockerfile sets APP_ENV=production, and so must every deploy config. TestApp builds Config directly (Config::test()) and never reads the environment.

  • serve listens on APP_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, needs APP_BIND=0.0.0.0 and its host name in APP_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=pretty

bin/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:

TargetHow
Fly.iofly 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 / RenderService 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 a Config field instead.
  • Calling dotenvy::dotenv() when APP_ENV is already set, so a stray .env in a production image overrides real config. Only call it when APP_ENV is unset (to find it) or development.
  • Trailing slash on APP_URL, which produces // in email links. Normalize it in from_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. Use config::APP_NAME.