Fastly Compute@Edge
Deploy EdgeZero applications to Fastly's Compute@Edge platform using WebAssembly.
Prerequisites
- Fastly CLI
- Rust
wasm32-wasip1target:rustup target add wasm32-wasip1 - Viceroy for local execution and testing
Project Setup
When scaffolding with edgezero new my-app, the Fastly adapter includes:
crates/my-app-adapter-fastly/
├── Cargo.toml
├── fastly.toml
└── src/
└── main.rsfastly.toml
The Fastly manifest configures your service:
manifest_version = 3
name = "my-app"
language = "rust"
authors = ["you@example.com"]
[local_server]
[local_server.backends]
[local_server.backends."origin"]
url = "https://your-origin.example.com"edgezero provision --adapter fastly creates each declared store and writes a [setup.kv_stores], [setup.secret_stores] or [setup.config_stores] entry into fastly.toml, keyed by the store's logical ID. It writes that entry only when the physical store name equals the logical ID. A different physical name needs a selected service (service_id in fastly.toml or FASTLY_SERVICE_ID), and provision leaves fastly.toml unchanged for that store. Whenever a service is selected, provision prints the fastly service resource-link create command that links each new store under its logical ID, because an existing service does not re-run [setup]. Provision leaves the Viceroy-only [local_server.*] tables alone. The config-store stanzas there are written by your generated app CLI, <app-cli> config push --adapter fastly --local (the bundled edgezero binary has no typed app config and exits 2), and KV / secret local-server seeding is hand-edited.
Entrypoint
The Fastly entrypoint wires the adapter:
use my_app_core::App;
#[fastly::main]
fn main(req: fastly::Request) -> Result<fastly::Response, fastly::Error> {
edgezero_adapter_fastly::run_app::<App>(req)
}run_app builds per-id KV / Config / Secret registries from the portable store metadata baked into App by the app! macro. Logging settings are baked in at the same time. A managed deployment resolves EDGEZERO__* store selectors and links each selected Fastly resource under its logical ID; no edgezero.toml or deployment environment is read by the runtime. See the migration guide.
For fully manual wiring, FastlyService::new(&app) builds a dispatcher one store at a time: .with_config(name), .with_config_handle(handle), .with_kv(name), .with_secrets(), the matching .require_kv() / .require_secrets() flags, and finally .dispatch(req). Prefer run_app when the entrypoint should use the store and logging metadata baked into the app.
Migrating a custom entrypoint
Custom entrypoints previously loaded selectors from the edgezero_runtime_env Config Store on every request and passed an EnvConfig to dispatch_with_registries:
let stores = MyHooks::stores();
let env = edgezero_adapter_fastly::runtime_env_config(stores);
edgezero_adapter_fastly::request::dispatch_with_registries(
&app,
req,
stores,
&env,
extend,
)Remove the runtime_env_config call, any use of RUNTIME_ENV_STORE_NAME, and the env argument. Initialize logging from the app's baked Fastly settings:
use edgezero_adapter_fastly::{FastlyLogging, init_logger};
use edgezero_core::app::Hooks as _;
let stores = MyHooks::stores();
let logging = FastlyLogging::from(MyHooks::logging_for("fastly"));
if logging.use_fastly_logger && !MyHooks::owns_logging() {
let endpoint = logging.endpoint.as_deref().unwrap_or("stdout");
init_logger(endpoint, logging.level, logging.echo_stdout)?;
}
edgezero_adapter_fastly::request::dispatch_with_registries(
&app,
req,
stores,
extend,
)A hand-written Hooks impl inherits the default stores(), which is empty, so dispatch_with_registries opens no stores. Such an impl must override stores() or pass explicit StoresMetadata.
EDGEZERO__STORES__<KIND>__<ID>__NAME is now a deployment input. EdgeZero resolves it before provider mutation and binds that physical resource to the version under the stable logical <ID> alias. The runtime opens the alias directly. Fastly Config Stores always use <ID> as the entry key, so remove Fastly __KEY selectors as well.
Fastly logging endpoint, level, and stdout behavior now come from [adapters.fastly.logging] when the immutable application is built. Runtime EDGEZERO__LOGGING__* values cannot vary those settings between production and staging for the same release.
Capturing raw-request signals (JA4, H2 fingerprint)
run_app converts the fastly::Request into a neutral core request before dispatch. The client IP is carried across automatically — read it via FastlyRequestContext (see Context Access below). Other Fastly-only signals that are readable only on the raw request (get_tls_ja4(), get_client_h2_fingerprint()) aren't reachable from handlers by default. Use run_app_with_request_extensions, which runs an app closure against a scratch Extensions before conversion and merges the values into the core request — so a State/extractor or middleware can read them:
#[derive(Clone)]
struct Ja4(String);
#[fastly::main]
fn main(req: fastly::Request) -> Result<fastly::Response, fastly::Error> {
edgezero_adapter_fastly::run_app_with_request_extensions::<App, _>(req, |raw, ext| {
if let Some(ja4) = raw.get_tls_ja4() {
ext.insert(Ja4(ja4.to_owned()));
}
})
}run_app is exactly run_app_with_request_extensions::<App, _>(req, |_, _| {}). The closure runs once per request; insert whatever typed values your handlers need, then read them in a handler via a custom extractor or ctx.request().extensions().get::<Ja4>().
Owning your own logging
By default run_app initializes the Fastly logger when the baked [adapters.fastly.logging] table sets an endpoint. If your app already installs a log backend, opt out with the platform-neutral Hooks::owns_logging() flag — via the app! macro:
edgezero_core::app!("edgezero.toml", owns_logging = true);or on a hand-written Hooks impl (fn owns_logging() -> bool { true }). Every adapter's run_app honors it, so the app is responsible for logger setup.
Building
Build for Fastly's Wasm target:
# Using the CLI
edgezero build --adapter fastly
# Or directly
fastly compute build -C crates/my-app-adapter-fastlyThe compiled Wasm binary is placed in target/wasm32-wasip1/release/.
Local Development
Run locally with Viceroy (Fastly's local simulator):
# Using the CLI
edgezero serve --adapter fastly
# Or directly
fastly compute serve -C crates/my-app-adapter-fastlyThis starts a local server at http://127.0.0.1:7676.
Deployment
Deploy a verified application release with the adapter-managed lifecycle:
EDGEZERO_MANIFEST="$RELEASE_ROOT/edgezero.toml" \
edgezero deploy --adapter fastly \
--service-id "$FASTLY_SERVICE_ID" \
--application-release "$RELEASE_ROOT"The release fixes the package, edgezero.toml, and its referenced Fastly manifest before runtime configuration is selected. A bare edgezero deploy --adapter fastly remains only as store-free production compatibility for an existing manifest command. Staging and any deployment that declares a Config, KV, or Secret Store require the verified release-backed managed command above.
Backends
EdgeZero's Fastly proxy client uses dynamic backends derived from the target URI (host + scheme). You do not need to predeclare backends in fastly.toml for EdgeZero proxying.
use edgezero_adapter_fastly::proxy::FastlyProxyClient;
use edgezero_core::proxy::ProxyService;
let client = FastlyProxyClient;
let response = ProxyService::new(client).forward(request).await?;Logging
Fastly uses endpoint-based logging. Configure logging in edgezero.toml:
[adapters.fastly.logging]
endpoint = "stdout"
level = "info"
echo_stdout = trueTo initialize logging manually, call init_logger with explicit settings:
use edgezero_adapter_fastly::init_logger;
use log::LevelFilter;
fn main() {
init_logger("stdout", LevelFilter::Info, true).expect("init logger");
}Logging status
Fastly logging is wired when you call init_logger, or when run_app finds an endpoint in the baked [adapters.fastly.logging] table and owns_logging() is false; otherwise no logger is installed.
Store selection and deployment
Fastly Compute applications open Config, KV, and Secret Stores by resource-link name. EdgeZero bakes the logical IDs declared in edgezero.toml into the package. A managed deployment resolves these optional deployment selectors:
EDGEZERO__STORES__CONFIG__<ID>__NAME
EDGEZERO__STORES__KV__<ID>__NAME
EDGEZERO__STORES__SECRETS__<ID>__NAMEEach selected physical store is linked to the unpublished target version under the stable logical <ID> alias. An absent __NAME defaults to <ID>; a present blank or invalid value fails before provider mutation. The same logical ID can be used independently by Config, KV, and Secret Stores because link identity is the pair (resource kind, logical ID).
Config keys are deterministic on Fastly: production, staging, and local Viceroy all read <ID>. The selected Environment chooses the physical store through __NAME; the same name shares config and different names isolate it. A conflicting __KEY or --key fails before a write. Logging is resolved from [adapters.fastly.logging] when the package is built.
Before publication, EdgeZero:
- verifies the immutable release package and manifests;
- resolves complete Config, KV, and Secret Store inventories;
- selects the exact active, staged, or initialized-draft source;
- uploads the verified package to an unreachable draft;
- replaces declared links whose selected physical resource changed and creates missing declared links under their logical aliases;
- preserves links not declared by the application;
- re-reads the exact links, source state, draft state, and provider-visible package identity; and
- stages or activates the prepared version without another EdgeZero mutation.
Production and staging can select different physical resources while deploying identical package bytes. Secret Stores remain optional. Links the application does not declare are preserved.
Declaring and using stores
Declare portable logical IDs in edgezero.toml:
[stores.config]
ids = ["app_config"]
[stores.kv]
ids = ["cache"]
# Optional: omit this table when the app uses no Secret Store.
[stores.secrets]
ids = ["credentials"]For local Viceroy tests, expose the Config Store under its logical ID and write the production key under that store. Local Viceroy uses the production key because it has no Fastly staging publication state:
[local_server.config_stores.app_config]
format = "inline-toml"
[local_server.config_stores.app_config.contents]
greeting = "hello from config store"Handlers read values through the Config extractor or ctx.config_store(id):
async fn handler(config: Config) -> Result<Response, EdgeError> {
let store = config
.named("app_config")
.ok_or_else(|| EdgeError::service_unavailable("no `app_config`"))?;
let greeting = store.get("greeting").await?.unwrap_or_default();
// …
}See the store migration guide for store selection and the GitHub Actions guide for the immutable-release workflow.
Context Access
Access Fastly-specific APIs via the request context extensions:
use edgezero_core::context::RequestContext;
use edgezero_adapter_fastly::context::FastlyRequestContext;
async fn handler(ctx: RequestContext) -> Result<Response, EdgeError> {
// Access Fastly context from extensions
if let Some(fastly_ctx) = FastlyRequestContext::get(ctx.request()) {
let client_ip = fastly_ctx.client_ip;
// ...
}
// ...
}Streaming
Standard run_app and serve_app dispatch drain a Body::Stream response into a fastly::Body before returning, so the full payload is materialised rather than delivered chunk by chunk. Custom lifecycle::serve_custom callbacks can instead commit a native streaming response and pump chunks progressively; see Custom dispatch and streaming.
See the Streaming guide for examples and patterns.
Testing
Run contract tests for the Fastly adapter:
cargo install viceroy --locked
export CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run"
# Run tests
cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contractFastly SDK-linked Wasm binaries require Viceroy for execution; plain Wasmtime does not provide the fastly_* host imports needed by the adapter tests.
Local Execution
If Viceroy reports native certificate or keychain errors on macOS, use --no-run locally and rely on Linux CI for execution.
Manifest Configuration
Configure the Fastly adapter in edgezero.toml. See Configuration for the full manifest reference.
Reusing a sandbox and retaining an app
Opt in with an ordinary main in place of the single-request #[fastly::main]:
use edgezero_adapter_fastly::{Serve, serve_app};
use std::time::Duration;
fn main() -> Result<(), fastly::Error> {
serve_app::<MyApp>(
Serve::new()
.with_max_requests(10)
.with_timeout(Duration::from_millis(500)),
).into_result()
}serve_app_with_request_extensions additionally accepts an FnMut callback for fresh extensions on every request. The first request initializes logging and builds the app. Each request reads runtime configuration and constructs new store registries. App construction and configure execute once per sandbox; explicit work elsewhere still executes whenever the application calls it. The extension callback and its captures are retained for the whole serving loop; only the supplied extensions are fresh. Create request-specific mutable data inside the callback or store it in those extensions.
Logging takes the first configuration snapshot, unless the app owns logging. An unavailable optional runtime configuration store disables logging for that sandbox, even if subsequent reads recover store selectors. It does not silently retry or reconfigure logging. The current overlay enables logging when an endpoint exists and uses echo_stdout: true. Applications requiring another initialization policy should use lifecycle::serve_custom and setup_once. The resolved configuration does not distinguish intentionally disabled logging from an unavailable first snapshot. Warnings through the log facade may be lost before a logger is installed; absence of a warning does not establish a healthy configuration read.
SDK 0.12.1 exposes with_max_requests, with_timeout, with_max_lifetime, and with_max_memory. A value of zero disables the request-count or memory limit; it does not request zero callbacks or zero memory use. The lifetime limit is measured from Serve construction, including time before the first callback. with_timeout bounds only the idle wait for the next request; it does not bound handler or backend time. If your service is billed by memory × wall-clock time, idle waiting is billed too. Limits do not guarantee reuse; any request may start a fresh sandbox. The standalone Viceroy 0.17.0 pinned for the fixtures admits at most six requests per guest even with a larger SDK request limit. edgezero serve runs fastly compute serve, whose bundled Viceroy can differ. Neither local ceiling establishes deployed behavior. Lifetime and memory checks occur between callbacks and cannot interrupt blocked application work. Before using a local memory limit, verify that the guest's memory-snapshot call succeeds: an unsupported snapshot conservatively ends the SDK loop. CPU clock observations are not reliable cross-run benchmarks.
ServeSummary::requests() counts attempted callbacks, including a failed one; a crash can prevent a final summary. Handler EdgeErrors render as responses and serving continues. Any other returned error (logger setup, a required KV store that fails to open, request conversion, response-stream collection, or an EdgeError that itself fails to render) makes the SDK send a 500 with the error text and end the loop. Constructor panics remain sandbox failures.
Custom dispatch and streaming
The standard helper collects core response streams into a native response body. For progressive client streaming, mutable native requests, response-extension finalization, or post-send work, use lifecycle::serve_custom with a configured Serve builder and a callback receiving &mut lifecycle::Sandbox<T>. EdgeZero owns the retained slot and successful-only initialization; your callback chooses the application-owned T. Initialize lazily so health checks can bypass expensive construction. Use request::dispatch_with_registries when standard translation suffices. For custom response handling, use request::into_core_request_with_registries(req, MyApp::stores(), extend) then drive app.router().oneshot(request) with futures::executor::block_on. This inserts the same registries for the Kv, Config, Secrets and AppConfig extractors as standard dispatch while leaving the core response available for streaming and finalization. The bare into_core_request helper inserts no store registries.
For manual streaming, append every header value, commit once with stream_to_client, pump and flush chunks, then finish the stream. Handle errors after commitment locally: returning an error to the SDK can trigger another response attempt. Complete pending backend and post-send operations before the callback returns. Do not cache native store handles with the app.
Use Request::get_client_request_id() for request correlation. FASTLY_TRACE_ID describes the sandbox and must not be treated as a unique request ID. If a native ID is unavailable, generate a request-local fallback and label its source. Do not retain correlation fields in app state or global logger configuration.
Custom lifecycle compatibility contract
The custom path uses public APIs: lifecycle::{Sandbox, serve_custom, run_custom}, request::into_core_request_with_registries, and app.router().oneshot. EdgeZero owns the successful-only retained slot and delegates request reception, limits and HandlerResult completion to the SDK. Applications own setup, state, refresh policy, response finalization and sending. Direct SDK serving remains available when these helpers do not fit.
Follow this order inside a callback:
- Return lightweight health responses before expensive initialization.
- Use
sandbox.setup_onceto guard successful logger setup independently from app construction. Failed setup leaves the guard unset; partial side effects are not rolled back, so retries must be safe. - Call
sandbox.initializeonly for application-owned state. Success fills the slot; failure leaves it empty and returns the error unchanged. Read the value withstate(). A handled failure may send a 503 and returnOk(())to permit a later callback to retry. Propagating it with?sends a 500 and ends the loop. - Convert with
into_core_request_with_registriesand drive router dispatch withfutures::executor::block_on. Keep bodies, extensions, registries and native handles request-local. The bareinto_core_requestpath provides noKv,Config,SecretsorAppConfigstores. Registered app state still overwrites extensions of the same type. - Inspect response extensions and finalize before sending. Append repeated headers, commit once, flush progressive chunks and finish the writer. After commitment, handle errors locally and return
()orOk(()). - Complete pending backend and post-send work before returning. Constructors and callbacks that panic remain sandbox failures.
For example, after health checks, this policy permits an in-sandbox setup retry:
if let Err(error) = sandbox.setup_once(|| install_application_logger()) {
eprintln!("logger setup failed: {error}");
fastly::Response::from_status(503).send_to_client();
return Ok(());
}
sandbox.initialize(|| build_application())?;
// Read sandbox.state(), perform registry-aware dispatch, then explicitly send.
// The ? above deliberately terminates this sandbox if application construction fails.A complete callback looks like this. serve_custom returns a ServeSummary that you inspect or convert with into_result(), and the infallible Hooks::build_app needs an explicit error type for initialize to infer:
use edgezero_adapter_fastly::lifecycle::{self, Sandbox};
use edgezero_adapter_fastly::{Serve, request, response};
use edgezero_core::app::{App, Hooks};
use futures::executor::block_on;
fn main() -> Result<(), fastly::Error> {
lifecycle::serve_custom(Serve::new().with_max_requests(10), handle).into_result()
}
fn handle(req: fastly::Request, sandbox: &mut Sandbox<App>) -> Result<(), fastly::Error> {
if req.get_path() == "/health" {
fastly::Response::from_status(200).send_to_client();
return Ok(());
}
sandbox.initialize(|| Ok::<_, fastly::Error>(MyApp::build_app()))?;
let core = request::into_core_request_with_registries(req, MyApp::stores(), |_, _| {})?;
let app = sandbox.state().expect("initialized above");
let core_response = block_on(app.router().oneshot(core))?;
// Inspect response extensions and finalize here. To stream progressively,
// append headers to a native response and use `stream_to_client` instead.
response::from_core_response(core_response)?.send_to_client();
Ok(())
}The fixture's custom_dispatch is a fuller reference covering logger setup, handled initialization failure, response finalization, progressive streaming and post-send work.
Sandbox::requests() counts attempted callbacks, including early health responses and failed ones. initialization_attempts() counts only actual builder calls. These counters describe this sandbox and are part of the public lifecycle API. They do not establish client completion or provider reuse.
Use lifecycle::run_custom(Request::from_client(), callback) for the single-request branch. It creates fresh state without entering Serve. Both wrappers complete HandlerResult exactly once. Use an ordinary fn main; #[fastly::main] may attempt another error response if an already-completed error propagates. Registry-aware dispatch_with_registries still collects response streams and cannot replace this explicit streaming/finalization path.
Keep adapter, core and SDK versions compatible. Run the compatibility fixtures against the checkout being adopted:
./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smokeSee the fixture README for assertions, runtime requirements and evidence files. An unavailable runtime or missing reuse is unverified and exits 2. Local fixtures do not guarantee provider reuse, deployed resource lifetimes or application workload gains. Applications still own refresh, key rotation and bounded caches.
Warning caches persist too. Their bounded recent-name sets can evict entries, so warnings can recur; the number of warnings logged is not a failure count. Dynamic-backend capacity is service-wide, and registrations may wait for capacity (see Fastly's dynamic-backend limit behavior). A sandbox request limit alone does not bound origin diversity or request fan-out.
The local fixtures are in tests/fixtures/reusable-app. Local reuse and streaming results do not establish deployed eviction frequency, endpoint-handle validity, resource accounting, or performance. Named endpoint delivery must be verified separately from echoed stdout. Roll back by restoring the original single-request entry point and removing retained state, not merely setting a request limit of one.
Next Steps
- Learn about Cloudflare Workers as an alternative deployment target
- Explore Configuration for manifest details