Configuration
Define behavior in specific environments
Configuration lets you set values your code reads at runtime, such as limits, feature flags, and endpoints, and override them for specific environments without changing the code.
In Encore.rs a config is a Rust type. Encore derives a schema from the type, you write the values in TOML or JSON files next to your code, and Encore checks every value against the schema before your service sees it.
Don't put credentials in config files. Store them as secrets, and reference them from a config secret field if needed.
Declaring a config
Define a type that derives serde's Deserialize and Encore's ConfigType, and declare the config as a module-level static:
orders/src/checkout.rsuse encore::config::{Config, ConfigType, Duration};
use serde::Deserialize;
#[derive(Deserialize, ConfigType)]
struct Checkout {
/// Most items one order may hold.
#[config(min = 1, max = 500)]
max_items: u32,
#[serde(default)]
promo: Option<Promo>,
payment_timeout: Duration,
}
#[derive(Deserialize, ConfigType)]
struct Promo {
code: String,
discount: u8,
}
static CHECKOUT: Config<Checkout> = Config::new("checkout");
fn too_many(items: usize) -> bool {
items > CHECKOUT.get().max_items as usize
}
Then write its value in a file named after the config, next to the service's encore.service.json:
orders/checkout.config.tomlmax_items = 50
payment_timeout = "1m30s"
[promo]
code = "FALL26"
discount = 10
Encore reads config declarations and their types from your source code, so a few rules apply:
- A config must be declared as a module-level
static, with its value type named in the static's type:static CHECKOUT: Config<Checkout>. - The name must be a string literal. It's an identifier: a letter or underscore, followed by letters, digits, and underscores.
- The type, and every type it contains, must derive
Deserialize. DeriveConfigTypeon any struct whose fields use#[config(...)].
The type is the schema
Encore builds a JSON Schema from your type: its fields and their types, serde attributes such as rename, rename_all, default, and flatten, enum tagging, the #[config(...)] constraints, and doc comments, which become field descriptions.
Every value is checked against that schema: while you edit a file under encore run, on deploy, and when the service starts. A service won't start with a value it can't read, which is why get() can't fail.
Supported types
bool, the integer types up to 64 bits,f32,f64,char, andStringOption<T>,Vec<T>,VecDeque<T>,HashSet<T>,BTreeSet<T>HashMap<K, V>andBTreeMap<K, V>with string keysBox<T>,Arc<T>,Rc<T>(includingBox<str>andArc<str>), andPathBufserde_json::Valueandserde_json::Mapfor free-form dataencore::config::Durationfor lengths of time- Your own structs (with named fields, or newtypes) and enums, except
#[serde(untagged)]enums
Encore reports an error at the part of the type it can't describe, for example a tuple, a borrowed &str, or std::time::Duration.
Constraints
#[config(...)] on a field adds a constraint to the schema:
Durations
encore::config::Duration is written as a number and a unit, or several: "30s", "250ms", "1h30m", "1.5h". The units are ns, us (or µs), ms, s, m, and h. It dereferences to a std::time::Duration, and converts into one with .into():
let timeout: std::time::Duration = CHECKOUT.get().payment_timeout.into();
Defaults
A field that no file sets takes its default from your code: #[serde(default)], #[serde(default = "path")], or None for an Option. If no file gives a config a value at all, get() returns the defaults.
Reading a config
get() returns a Value<T>: a snapshot of the config's current value that dereferences to T. It's cheap to make and to clone.
let checkout = CHECKOUT.get();
if let Some(promo) = &checkout.promo {
apply_discount(&promo.code, promo.discount);
}
A config's value can change while the app runs. Edit its file under encore run and the running service has the new value within a second, without a restart. A snapshot keeps the value it was made with, so hold one for as long as you need a consistent view (for example, one request), and call get() again for the next.
Reacting to changes
If you build something from a config, such as an HTTP client or a connection pool, rebuild it when the value changes with on_change:
CHECKOUT.on_change(|checkout| {
println!("max items is now {}", checkout.max_items);
});
The callback is called with each new value from then on. It runs on the runtime's threads, so keep it quick and spawn anything that waits.
Scopes
As with secrets, the type you declare a config with decides who it belongs to:
use encore::config::{ConfigType, SharedConfig};
use serde::Deserialize;
#[derive(Deserialize, ConfigType)]
struct Flags {
#[serde(default)]
new_checkout: bool,
}
static FLAGS: SharedConfig<Flags> = SharedConfig::new("flags");
Environment-specific values
A config's value is its base file plus any overlay files that apply to the current environment. Each file can be .toml or .json:
Overlays are applied in that order, lowest first, on top of the defaults in code. They're merged field by field using JSON Merge Patch: tables merge, any other value replaces the one below it, and null removes it. An overlay only needs the fields it changes:
orders/checkout.production.config.tomlmax_items = 200
Each layer is checked against the schema on its own, so a mistake is reported in the file that makes it, and then the merged value is checked in full.
The user override (<name>.user.config.toml) is for your own values on your own machine. It applies to encore run only, never to tests or deployed environments. Keep it out of version control: encore app new and encore app init add *.user.config.* to the app's .gitignore.
Secrets
A config field can read a secret instead of holding a value. Declare the field as encore::secrets::Secret and name the secret with #[config(secret = "...")]:
use encore::config::{ConfigType, GlobalConfig};
use encore::secrets::{GlobalSecret, Secret};
use serde::Deserialize;
#[derive(Deserialize, ConfigType)]
struct Payments {
stripe: Stripe,
}
#[derive(Deserialize, ConfigType)]
struct Stripe {
endpoint: String,
#[config(secret = "StripeKey")]
api_key: Secret,
}
static PAYMENTS: GlobalConfig<Payments> = GlobalConfig::new("payments");
// The secret is declared in code as any secret is, with the config's scope.
static STRIPE_KEY: GlobalSecret = GlobalSecret::new("StripeKey");
fn stripe_key() -> Result<String, encore::Error> {
Ok(PAYMENTS.get().stripe.api_key.value()?.to_string())
}
- The field's type is always
Secret; the secret has the config's own scope. AConfigreads its service's own secret, aSharedConfiga shared one, and aGlobalConfiga global one. - The secret must also be declared in code (
Secret::new,SharedSecret::new, orGlobalSecret::new). A secret field that names an undeclared secret is an error. - No config file sets the field. The secret holds the value, per environment, as secrets do.
- A secret field can't be inside a list, a map, or an enum.
Inspecting and editing config
The encore config command works with your app's configs:
A service's own config is named <service>.<name>, for example encore config show orders.checkout.
set and unset write to the user override by default. Pass --base, --env-type <type>, or --env <name> to change another layer:
$ encore config set orders.checkout max_items 100$ encore config set orders.checkout max_items 200 --env-type production
Testing
Under encore test, a config's value is what the files say for the test environment type. Use a checkout.test.config.toml overlay to change values for all tests.
To give one test a value of its own, use encore::testing::set_config. No other test sees it, and it's gone when the test ends. The value is checked against the config's schema:
#[encore::test]
async fn rejects_large_orders() -> Result<(), encore::Error> {
encore::testing::set_config(
&CHECKOUT,
serde_json::json!({"max_items": 2, "payment_timeout": "30s"}),
)?;
assert!(too_many(3));
Ok(())
}
See Testing for more.