Configuration
The edgezero.toml manifest describes an EdgeZero application, providing a single source of truth for routing, middleware, adapters, and environment configuration.
Overview
New workspaces scaffolded with edgezero new include this manifest by default. The manifest drives both runtime routing and CLI commands.
[app]
name = "my-app"
entry = "crates/my-app-core"
middleware = ["edgezero_core::middleware::RequestLogger"]
[[triggers.http]]
id = "root"
path = "/"
methods = ["GET"]
handler = "my_app_core::handlers::root"
[adapters.fastly]
# Fastly-specific configuration
[adapters.cloudflare]
# Cloudflare-specific configurationApp Section
The [app] section defines application metadata:
[app]
name = "demo"
entry = "crates/demo-core"
middleware = ["edgezero_core::middleware::RequestLogger"]| Field | Required | Description |
|---|---|---|
name | No | Display name for the application (defaults to "EdgeZero App") |
entry | No | Path to the core crate containing handlers (recommended for tooling) |
version | No | Reserved for future compatibility; currently ignored |
kind | No | Reserved for future compatibility; currently ignored |
middleware | No | List of middleware to apply globally |
Middleware
Manifest-driven middleware are applied in order before routes:
[app]
middleware = [
"edgezero_core::middleware::RequestLogger",
"my_app_core::cors::Cors"
]Each item must be:
- A publicly accessible path
- Either a unit struct or zero-argument constructor
- Implementing
edgezero_core::middleware::Middleware
HTTP Triggers
The [[triggers.http]] array defines routes:
[[triggers.http]]
id = "root"
path = "/"
methods = ["GET"]
handler = "my_app_core::handlers::root"
[[triggers.http]]
id = "echo"
path = "/echo/{name}"
methods = ["GET", "POST"]
handler = "my_app_core::handlers::echo"
adapters = ["fastly", "cloudflare"]
body-mode = "buffered"| Field | Required | Description |
|---|---|---|
id | No | Stable identifier for tooling |
path | Yes | URI template ({param} for params, {*rest} for catch-all) |
methods | No | Allowed HTTP methods (defaults to GET) |
handler | No | Path to handler function (required for app! route wiring) |
adapters | No | Intended adapter filter (metadata; app! currently ignores) |
description | No | Human-readable description for docs or tooling |
body-mode | No | buffered or stream |
Adapter filters
The adapters field is currently metadata for tooling; app! wires all triggers regardless of adapter.
Environment Section
Declare environment variables and secrets:
[environment]
[[environment.variables]]
name = "API_BASE_URL"
env = "API_BASE_URL"
value = "https://example.com/api"
[[environment.secrets]]
name = "API_TOKEN"
adapters = ["fastly", "cloudflare"]
env = "API_TOKEN"Variables
| Field | Required | Description |
|---|---|---|
name | Yes | Variable name in application |
description | No | Human-readable description |
env | No | Environment key (defaults to name) |
value | No | Default value |
adapters | No | Limit to specific adapters |
Variables with a default value are injected when running CLI commands.
Secrets
| Field | Required | Description |
|---|---|---|
name | Yes | Secret name in application |
description | No | Human-readable description |
env | No | Environment key (defaults to name) |
adapters | No | Limit to specific adapters |
Secrets must be present in the environment; missing secrets abort CLI commands with an error.
These declarations are for CLI and deployment workflows. To expose a runtime secret store to request handlers, configure [stores.secrets].
Runtime Secret Stores
Use [stores.secrets] when your application reads secrets at request time via the Secrets extractor. This is separate from [[environment.secrets]]:
[[environment.secrets]]declares required environment variables for CLI commands[stores.secrets]enables runtime secret lookup during request handling
[stores.secrets]
ids = ["default"] # one id per logical secret store
# default = "default" # required when ids.len() > 1The portable [stores.<kind>] schema declares logical ids only. Platform names are resolved at runtime from EDGEZERO__STORES__SECRETS__<ID>__NAME (defaulting to the logical id when unset). Migrating from the pre-rewrite name / [stores.secrets.adapters.*] form? See the migration guide.
Adapter Behavior
| Adapter | Capability | Notes |
|---|---|---|
| axum | Single (env vars) | Every declared id maps to the same env-backed store |
| cloudflare | Single (Worker Secrets) | Per-id NAME variables are ignored |
| fastly | Multi (Fastly Secret Store) | Each id opens its own platform store via EDGEZERO__STORES__SECRETS__<ID>__NAME |
| spin | Single (flat Spin [variables]) | Per-id NAME variables are ignored |
If [stores.secrets] is omitted, the Secrets extractor is not attached and the runtime secret_store accessors on RequestContext return None.
Stores Section
Use [stores.config] for small read-only runtime configuration such as feature flags, JWKS metadata, or service settings:
[stores.config]
ids = ["app_config"] # one id per logical config store
# default = "app_config" # required when ids.len() > 1The portable schema is symmetric across [stores.kv], [stores.config], and [stores.secrets]: declare logical ids only; resolve platform names at runtime via EDGEZERO__STORES__<KIND>__<ID>__NAME. Config stores additionally honour EDGEZERO__STORES__CONFIG__<ID>__KEY to select which pushed blob the runtime reads (the staging/canary selector that pairs with config push --key; defaults to the logical id). The pre-rewrite name, enabled, [stores.config.defaults], and [stores.config.adapters.*] fields are a hard load error — see the migration guide.
Runtime behavior by adapter:
- Fastly reads from a Fastly Config Store resource link, one per id.
- Cloudflare reads from a KV namespace, one per id, asynchronously.
- Axum reads from
.edgezero/local-config-<id>.jsonper logical id (one file per declared config id). Seed entries withconfig push --adapter axum, which writes the same file the runtime reads (creates.edgezero/on first use). No shell-out; the file is human-editable for ad-hoc tweaks. - Spin reads a
spin_sdk::key_value::Storeper id, one label per declared[stores.config]id (multi-store). Labels must be declared inspin.toml's[component.<id>].key_value_stores—provisionwrites them automatically. Seed entries viaconfig push --adapter spin, which dispatches per-backend by readingruntime-config.toml:type = "spin"→ direct write into the local.spin/sqlite_key_value.db; Fermyon Cloud deploys → shellspin cloud key-value set; other backend types (redis, azure_cosmos) print an actionable error pointing at the backend's native CLI. See Spin adapter for the full resolution order.
When [stores.config] is present, the app! macro bakes the portable store registry into Hooks::stores(). Adapter run_app helpers build a per-request ConfigRegistry and inject it into request extensions so handlers can call ctx.config_store("app_config") (or ctx.config_store_default()).
Treat config-store keys like API surface: validate or allowlist any user-controlled lookup before calling ctx.config_store_default()?.get(...).
Application config
edgezero.toml describes the shape of the app — routes, adapters, stores. A separate <name>.toml file (e.g. my-app.toml, sitting alongside edgezero.toml) carries the typed values the app reads at request time: feature flags, timeouts, the keys it uses to look up secrets. edgezero new generates both, plus a <Name>Config struct in crates/<name>-core/src/config.rs that the file deserialises into.
// crates/my-app-core/src/config.rs
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)]
#[serde(deny_unknown_fields)]
pub struct MyAppConfig {
pub greeting: String,
pub service: ServiceConfig,
#[secret]
pub api_token: String,
}
#[derive(Debug, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct ServiceConfig {
#[validate(range(min = 100, max = 60_000))]
pub timeout_ms: u32,
}# my-app.toml — loaded into MyAppConfig
greeting = "hello from my-app"
api_token = "demo_api_token" # key into the default secret store
[service]
timeout_ms = 1500The file's top-level table maps 1:1 to the struct — no [config] wrapper. deny_unknown_fields makes typos in the TOML a hard load error rather than a silent drop.
Loading the config
use edgezero_core::app_config::load_app_config;
let cfg: MyAppConfig =
load_app_config(std::path::Path::new("my-app.toml"), "my-app")?;The function deserialises, runs the validator rules (e.g. #[validate(range(...))]), and returns the typed struct.
Secret annotations
| Attribute | Meaning |
|---|---|
#[secret] | The field's value is a key inside the default secret store declared by [stores.secrets]. |
#[secret(store_ref)] | The field's value is a logical store id that must appear in [stores.secrets].ids. |
#[secret] fields must be String or Option<String> (an absent optional secret is skipped at runtime — see Nested and array secrets); combining the annotation with #[serde(flatten)], #[serde(rename)], or #[serde(skip)] is a compile error. The config validate command (see CLI reference) checks that every #[secret(store_ref)] value matches a declared id.
Resolve secrets at request time from the secret store:
// #[secret] field — key in the default store
let token = ctx
.secret_store_default()?
.require_str(&cfg.api_token)
.await?;
// #[secret(store_ref)] field — value names the store itself
let value = ctx
.secret_store(&cfg.vault)?
.require_str("active")
.await?;Nested and array secrets
#[secret] fields don't have to live at the config root. They can sit inside nested structs and inside Vec<_> elements, resolved at runtime by their field path instead of a single top-level name.
To recurse into a nested type, mark the field with #[app_config(nested)] (it mirrors #[validate(nested)], which you'll usually want alongside it), and make the nested type itself derive AppConfig:
#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)]
#[serde(deny_unknown_fields)]
pub struct Settings {
#[app_config(nested)]
#[validate(nested)]
pub integrations: Integrations,
#[app_config(nested)]
#[validate(nested)]
pub partners: Vec<Partner>,
}
#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)]
#[serde(deny_unknown_fields)]
pub struct Integrations {
#[app_config(nested)]
#[validate(nested)]
pub datadome: DataDome,
}
#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)]
#[serde(deny_unknown_fields)]
pub struct DataDome {
#[secret]
pub server_side_key: String,
// A named-store secret: `token` is a key in the store named by its
// `vault` sibling. The `store_ref` sibling is resolved within the
// innermost object that contains the secret leaf.
#[secret(store_ref = "vault")]
pub token: String,
#[secret(store_ref)]
pub vault: String,
}
#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)]
#[serde(deny_unknown_fields)]
pub struct Partner {
#[secret]
pub api_key: String,
// An optional secret: absent (or `null`) at runtime -> skipped, not an error.
#[secret]
pub webhook_key: Option<String>,
}At request time the extractor walks each secret path and swaps the stored key name for the resolved value:
- Object nesting resolves the leaf at its full path (
integrations.datadome.server_side_key). Vec<_>arrays resolve the annotated field on every element (partners[*].api_key).- A
#[secret]onOption<String>is skipped when the value is absent ornull; a present value is resolved like any other secret. #[secret(store_ref = "…")]resolves against the store named by its sibling field — the one in the same innermost object as the secret leaf, not a root-level field.
The nested type must derive AppConfig; marking a field #[app_config(nested)] whose type does not is a compile error. Runtime resolution failures name the offending leaf by its dotted path, with concrete array indices — e.g. integrations.datadome.server_side_key or partners[3].api_key — so a bad key name points straight at the field.
Limitations of #[app_config(nested)]:
- Shape: opt-in supports a direct field (
T) orVec<T>only. Wrapper-nested forms likeOption<Inner>orBox<Inner>are not supported — restructure to a direct orVec<T>field. (Only the leaf may be optional, via#[secret] field: Option<String>.) - Required intermediates: only the leaf's own
Option<String>is skippable. A missing ornullintermediate object/array in the stored blob (e.g. the wholepartnersarray) is aConfigOutOfDateerror, not a silent skip — bothconfig validateand the runtime reject it. - No cyclic nesting: a type that directly nests itself by name (
#[app_config(nested)] x: Vec<Config>) is a compile error. A cycle the derive can't see one-type-at-a-time — a mutual cycle (AnestsB,BnestsA) or a path-qualified self-reference (Vec<crate::Config>) — is caught at runtime instead:secret_fields()panics with a clear message rather than overflowing the stack. Cyclic config is pathological (infinite data); don't do it.
Environment-variable overlay
Every key in <name>.toml can be overridden at runtime by an env var named <APP_NAME>__<SECTION>__…__<KEY> (uppercase, with - in the app name replaced by _, segments joined by a double-underscore). The overlay only applies to keys already present in the file — it can't introduce new ones — and the existing TOML value's type drives how the env string is coerced ("true" / "false" for bool, parsed integers for numeric fields, etc.).
# Override the nested service.timeout_ms key:
MY_APP__SERVICE__TIMEOUT_MS=2500 \
cargo run -p my-app-adapter-axumThe env-segment translation is uppercase-only — it does not substitute - for _, so dashed and underscored TOML keys remain distinct env segments. The only way two siblings collapse is when they differ only in letter case (e.g. greeting_a and GREETING_A, both uppercasing to GREETING_A). That case is rejected as an EnvOverlay error before any override is applied, so a misconfiguration leaves the file values intact.
Adapters Section
Each adapter has its own configuration block:
[adapters.fastly.adapter]
crate = "crates/demo-adapter-fastly"
manifest = "crates/demo-adapter-fastly/fastly.toml"
[adapters.fastly.build]
target = "wasm32-wasip1"
profile = "release"
[adapters.fastly.commands]
build = "cargo build --release --target wasm32-wasip1 -p demo-adapter-fastly"
serve = "fastly compute serve -C crates/demo-adapter-fastly"
deploy = "fastly compute deploy -C crates/demo-adapter-fastly"
[adapters.fastly.logging]
endpoint = "stdout"
level = "info"
echo_stdout = trueAdapter Metadata
| Field | Description |
|---|---|
crate | Path to adapter crate |
manifest | Path to provider manifest (fastly.toml, wrangler.toml) |
Build Configuration
| Field | Description |
|---|---|
target | Rust compilation target |
profile | Build profile (release, dev) |
features | Cargo features to enable |
Commands
| Field | Description |
|---|---|
build | Command for edgezero build --adapter <name> |
serve | Command for edgezero serve --adapter <name> |
deploy | Command for edgezero deploy --adapter <name> |
When commands are omitted, the CLI falls back to built-in adapter helpers.
Logging
Logging can be configured per adapter under [adapters.<name>.logging] or via a top-level [logging.<name>] block. If both are present, the adapter-specific block takes precedence.
| Field | Adapters | Description |
|---|---|---|
endpoint | Fastly | Log endpoint name |
level | All | Log level: trace, debug, info, warn, error, off |
echo_stdout | Fastly, Axum | Mirror logs to stdout |
Note: Cloudflare logging is not wired to a built-in logger yet.
Full Example
[app]
name = "my-app"
entry = "crates/my-app-core"
middleware = [
"edgezero_core::middleware::RequestLogger",
"my_app_core::middleware::Cors"
]
[[triggers.http]]
id = "root"
path = "/"
methods = ["GET"]
handler = "my_app_core::handlers::root"
[[triggers.http]]
id = "echo"
path = "/echo/{name}"
methods = ["GET"]
handler = "my_app_core::handlers::echo"
[[triggers.http]]
id = "api"
path = "/api/{*rest}"
methods = ["GET", "POST", "PUT", "DELETE"]
handler = "my_app_core::handlers::api_proxy"
body-mode = "stream"
[environment]
[[environment.variables]]
name = "API_URL"
value = "https://api.example.com"
[[environment.secrets]]
name = "API_KEY"
[stores.secrets]
ids = ["default"]
[adapters.fastly.adapter]
crate = "crates/my-app-adapter-fastly"
manifest = "crates/my-app-adapter-fastly/fastly.toml"
[adapters.fastly.build]
target = "wasm32-wasip1"
profile = "release"
[adapters.fastly.commands]
build = "fastly build -C crates/my-app-adapter-fastly"
deploy = "fastly compute deploy -C crates/my-app-adapter-fastly"
serve = "fastly compute serve -C crates/my-app-adapter-fastly"
[adapters.fastly.logging]
endpoint = "stdout"
level = "info"
echo_stdout = true
[adapters.cloudflare.adapter]
crate = "crates/my-app-adapter-cloudflare"
manifest = "crates/my-app-adapter-cloudflare/wrangler.toml"
[adapters.cloudflare.build]
target = "wasm32-unknown-unknown"
profile = "release"
[adapters.cloudflare.commands]
build = "wrangler build --cwd crates/my-app-adapter-cloudflare"
deploy = "wrangler deploy --cwd crates/my-app-adapter-cloudflare"
serve = "wrangler dev --cwd crates/my-app-adapter-cloudflare"
[adapters.cloudflare.logging]
level = "info"
[adapters.axum.adapter]
crate = "crates/my-app-adapter-axum"
manifest = "crates/my-app-adapter-axum/axum.toml"
host = "127.0.0.1"
port = 8787
[adapters.axum.commands]
build = "cargo build --release -p my-app-adapter-axum"
serve = "cargo run -p my-app-adapter-axum"Axum bind-address precedence is:
EDGEZERO__ADAPTER__HOST/EDGEZERO__ADAPTER__PORT(canonical; read directly by the runtime). The pre-rewriteEDGEZERO_HOST/EDGEZERO_PORTshim is gone — rename any CI scripts or local overrides to the canonical double-underscore form.edgezero.toml[adapters.axum.adapter]host/port(the CLI translates these intoEDGEZERO__ADAPTER__HOST/EDGEZERO__ADAPTER__PORTwhen spawning the subprocess; if a canonical env var is already set, it wins)axum.toml[adapter]host/portwhen launching through the Axum adapter CLI wrapper- default
127.0.0.1:8787
Example override:
EDGEZERO__ADAPTER__HOST=0.0.0.0 EDGEZERO__ADAPTER__PORT=3000 \
cargo run -p my-app-adapter-axumUsing the Manifest
app! Macro
Generate router wiring from the manifest:
// In your core crate's lib.rs
mod handlers;
edgezero_core::app!("../../edgezero.toml");The macro:
- Parses HTTP triggers
- Generates route registration
- Wires middleware from the manifest
- Bakes portable store metadata (
Hooks::stores()) from[stores.kv],[stores.config], and[stores.secrets]when present - Creates the
Appstruct that implementsHooks(useApp::build_app())
ManifestLoader
Load the manifest programmatically:
use edgezero_core::manifest::ManifestLoader;
use std::path::Path;
let manifest = ManifestLoader::from_path(Path::new("edgezero.toml"))?;
let app_name = manifest
.manifest()
.app
.name
.as_deref()
.unwrap_or("EdgeZero App");
println!("App name: {}", app_name);Validation
ManifestLoader validates:
- Non-empty string fields when present (names, paths, commands)
- Supported HTTP methods and
body-modevalues - Well-formed logging levels and adapter logging config
Errors are surfaced at startup or during macro expansion.
Next Steps
- Learn about CLI commands
- Explore adapter-specific configuration