Axum (Native)
Run EdgeZero applications natively using Axum and Tokio for local development, testing, and container deployments.
Overview
The Axum adapter provides:
- Local development server - Fast iteration without Wasm compilation
- Native testing - Run tests with standard
cargo test - Container deployments - Deploy to any platform supporting native binaries
Project Setup
When scaffolding with edgezero new my-app, the Axum adapter includes:
crates/my-app-adapter-axum/
├── Cargo.toml
├── axum.toml
└── src/
└── main.rsEntrypoint
The Axum entrypoint wires the adapter:
use edgezero_adapter_axum::dev_server::run_app;
use my_app_core::App;
fn main() -> anyhow::Result<()> {
run_app::<App>()
}run_app installs simple_logger (unless owns_logging = true), builds the app, and reads bind / store / logging config at runtime from EDGEZERO__* environment variables (see the migration guide). The portable store metadata baked into App by the app! macro drives which logical stores are exposed; no edgezero.toml needs to be loaded by the runtime.
Development Server
Run your project locally on the Axum adapter:
edgezero serve --adapter axumThis starts a server at http://127.0.0.1:8787 with standard logging to stdout.
Manual Start
Run the Axum entrypoint directly:
# Using the CLI
edgezero serve --adapter axum
# Or directly with cargo
cargo run -p my-app-adapter-axumBuilding
Build a native release binary:
# Using the CLI
edgezero build --adapter axum
# Or directly
cargo build -p my-app-adapter-axum --releaseThe binary is placed in target/release/my-app-adapter-axum.
Proxy Client
The Axum adapter provides a native HTTP client for proxying:
use edgezero_adapter_axum::proxy::AxumProxyClient;
use edgezero_core::proxy::ProxyService;
let client = AxumProxyClient::try_new()?;
let response = ProxyService::new(client).forward(request).await?;This uses reqwest under the hood for outbound HTTP requests. try_new is fallible because it builds a reqwest::Client; it returns a reqwest::Error if the TLS backend cannot be initialised on the host.
Logging
The Axum adapter's run_app helper installs simple_logger at the level read from EDGEZERO__LOGGING__LEVEL, falling back to info when the variable is unset or unparseable. It does not read edgezero.toml, and echo_stdout has no effect on the runtime. To install a different logger, set owns_logging = true on your app! declaration so run_app skips its own logger, then install yours in main. Wiring App::build_app() and AxumDevServer by hand remains the fallback if you also need to control the bind address or store setup.
Logging status
run_app wires logging automatically; custom entrypoints should install a logger explicitly.
Testing
The Axum adapter enables standard Rust testing:
#[cfg(test)]
mod tests {
use super::*;
use edgezero_core::app::Hooks;
use edgezero_core::http::{Request, Method};
#[tokio::test]
async fn test_handler() {
let app = App::build_app();
let router = app.router();
let request = Request::builder()
.method(Method::GET)
.uri("/hello")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), 200);
}
}Run tests:
cargo test -p my-app-core
cargo test -p my-app-adapter-axumKV Storage
Each declared [stores.kv] id resolves to a redb-backed store on disk under .edgezero/, so values persist across dev-server restarts. The file name is derived from the platform store name, which comes from EDGEZERO__STORES__KV__<ID>__NAME or defaults to the logical id:
.edgezero/kv-<slug>-<hash>.redbThe database file grows over time and does not shrink after deletions. To reclaim space, delete the file in .edgezero/; the data is lost. See KV Storage for the portable API.
Secret Store
A declared [stores.secrets] id resolves to an EnvSecretStore, which reads secret key api_key from the environment variable EDGEZERO__SECRETS__API_KEY, which is the EDGEZERO__SECRETS__ prefix plus the key in uppercase. Keys may contain only ASCII letters, digits, and _. config validate rejects other keys, and two keys that differ only in case. Axum lists secrets in its single_store_kinds, so only one secrets id may be declared:
EDGEZERO__SECRETS__API_KEY=mysecret edgezero serve --adapter axumConfig Store
For local development, each declared [stores.config] id resolves to a local-file config store backed by .edgezero/local-config-<id>.json. The portable manifest carries no inline defaults — the pre-rewrite [stores.config.defaults] table is gone (see the migration guide).
[stores.config]
ids = ["app_config"]
# default = "app_config" # required when ids.len() > 1// .edgezero/local-config-app_config.json — what `<your-cli> config push` writes.
// The outer object maps the selected config key (defaults to the logical store
// id; override with `--key`, e.g. for a `staging` blob) to ONE BlobEnvelope,
// JSON-encoded as a string (see the blob migration guide for the envelope's
// fields). `data` holds the typed config verbatim; `#[secret]` fields store
// their key NAMES, which the runtime resolves at request time — never the
// secret values.
{
"app_config": "{\"version\":1,\"generated_at\":\"…\",\"sha256\":\"…\",\"data\":{\"greeting\":\"hello\",\"feature\":{\"new_checkout\":false},\"service\":{\"timeout_ms\":1500},\"api_token\":\"demo_api_token\",\"vault\":\"default\"}}",
}Typed apps read the whole config in one shot with the AppConfig<C> extractor, which parses the envelope and resolves #[secret] fields before handing you cfg:
async fn handler(AppConfig(cfg): AppConfig<MyConfig>) -> Result<Response, EdgeError> {
let greeting = &cfg.greeting;
// …
}The lower-level Config extractor / ctx.config_store(id) exposes the raw key/value store — store.get("app_config") returns the envelope string, and a hand-seeded flat file returns individual values. Do not pass raw user input straight to store.get(…) in production handlers; validate or allowlist keys first.
Seed the per-id files with <your-cli> config push --adapter axum (the typed flow — the bundled edgezero config push errors), which writes the same .edgezero/local-config-<id>.json files the runtime reads — no shell-out, no server to authenticate against.
Container Deployment
Build and deploy as a standard container:
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN cargo build -p my-app-adapter-axum --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/my-app-adapter-axum /usr/local/bin/
EXPOSE 8787
CMD ["my-app-adapter-axum"]Configuration
Configure the Axum adapter in edgezero.toml. See Configuration for the full manifest reference.
The axum.toml file is used by the Axum CLI helper to locate the crate and carry a default port. edgezero serve --adapter axum resolves the bind address with this precedence, highest first: the EDGEZERO__ADAPTER__HOST / EDGEZERO__ADAPTER__PORT environment variables, then [adapters.axum.adapter] in edgezero.toml, then axum.toml, then 127.0.0.1:8787. The CLI passes the resolved address to the child process as EDGEZERO__ADAPTER__HOST / EDGEZERO__ADAPTER__PORT. Running the binary directly bypasses that resolution: it reads only those two environment variables and otherwise falls back to 127.0.0.1:8787.
Development Workflow
A typical development workflow:
- Run locally:
edgezero serve --adapter axum - Make changes to handlers in
my-app-core - Test locally with curl or browser
- Run tests:
cargo test - Build for edge:
edgezero build --adapter fastly - Deploy:
edgezero deploy --adapter fastly
Differences from Edge Adapters
| Aspect | Axum | Fastly/Cloudflare |
|---|---|---|
| Compilation | Native | Wasm |
| Cold start | ~0ms | ~0ms (Wasm) |
| Memory | Unlimited | 128MB typical |
| Filesystem | Full access | Sandboxed |
| Network | Direct | Backend/fetch |
| Concurrency | Multi-threaded | Single-threaded |
Development Parity
While Axum provides a convenient development environment, always test on actual edge platforms before deploying. Provider-specific behaviour such as store backends and request context differs on the real targets.
Application lifetime
dev_server::run_app already constructs one app per server startup and serves clones of its router. No additional reuse option is needed. Retained application state must support concurrent requests; each request gets its own metadata, extensions, and body. Restarting the server creates a fresh app. Native Axum measurements are a useful ownership reference but do not establish WASM runtime performance or resource lifetimes.
Ingress provenance
Conversion captures runtime URI parts and retains the original HeaderMap and extensions. JSON buffering and non-JSON streaming are unchanged. The standard plain HTTP listener supplies a private transport marker. Origin is available only with that marker and one valid Host consistent with any URI authority and scheme; forwarded headers do not supply a scheme. Generic conversion without the marker leaves origin unavailable.
Raw TCP tests retain extension methods, tested path spelling, same-name duplicates and FF values. Hyper folds identical Content-Length values and rejects conflicting lengths before the hook. Common field multiplicity remains Unknown, including Content-Length: the runtime binding does not reveal whether the parser folded original fields. Global wire order remains unavailable. See the capability matrix.
Origin validation checks HTTP(S) URI authority syntax, including registered names and trailing-dot hosts. It does not establish DNS resolution or ownership. Consumers that require DNS-label syntax must apply that additional policy.
Next Steps
- Deploy to Fastly Compute for production
- Deploy to Cloudflare Workers as an alternative
- Explore Configuration for manifest options