Fermyon Spin
Run EdgeZero applications on Fermyon Spin, a WebAssembly-first application platform with a wasm32-wasip2 target and component-scoped KV / variable stores.
Prerequisites
- Rust toolchain with
wasm32-wasip2target (rustup target add wasm32-wasip2) - Spin CLI (install)
Project Setup
When scaffolding with edgezero new my-app, the Spin adapter includes:
crates/my-app-adapter-spin/
├── Cargo.toml
├── spin.toml
└── src/
└── lib.rsEntrypoint
The Spin entrypoint wires the adapter via #[http_service]:
use spin_sdk::{http::IntoResponse, http::Request, http_service};
use my_app_core::App;
#[http_service]
async fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
edgezero_adapter_spin::run_app::<App>(req).await
}run_app reads the portable store metadata baked into App by the app! macro plus EDGEZERO__* environment variables; it does not require an edgezero.toml to be present at runtime.
Building
Build the Spin component:
# Using the CLI
edgezero build --adapter spin
# Or directly
cargo build --target wasm32-wasip2 --release -p my-app-adapter-spinLocal Development
# Using the CLI
edgezero serve --adapter spin
# Or directly
spin up --from crates/my-app-adapter-spinDeployment
# Using the CLI
edgezero deploy --adapter spin
# Or directly
spin deploy --from crates/my-app-adapter-spinKV Storage
Spin KV is label-backed and multi-store — each logical id in [stores.kv].ids maps to a Spin store label declared in spin.toml. Override the label per id with EDGEZERO__STORES__KV__<ID>__NAME; with the variable unset the label defaults to the logical id.
# edgezero.toml
[stores.kv]
ids = ["sessions", "cache"]
default = "sessions"# spin.toml
[component.my-app]
key_value_stores = ["sessions", "cache"]Two Spin-specific KV constraints (see §6.7 of the design spec for the full rationale):
- TTL is unsupported. Spin's
key_value::Store::setaccepts no expiry.put_bytes_with_ttlreturnsKvError::Unsupported { operation: "put_bytes_with_ttl" }(mapped to HTTP 501); never silently strips the TTL. - Listing is capped.
Store::get_keys()is unbounded, so the adapter materialises the key list, filters by prefix, sorts, and pages client-side. Amax_list_keyscap (default1000, override viaEDGEZERO__STORES__KV__<ID>__MAX_LIST_KEYS) guards against runaway lists and yieldsKvError::LimitExceeded(HTTP 503) when exceeded.
Config Store
Spin config is KV-backed and multi-store — each logical id in [stores.config].ids opens a separate spin_sdk::key_value::Store at runtime. The store accepts arbitrary UTF-8 keys, so the canonical dotted key (service.timeout_ms) is read back verbatim — no key translation. Override the label per id with EDGEZERO__STORES__CONFIG__<ID>__NAME; with the variable unset the label defaults to the logical id.
# edgezero.toml
[stores.config]
ids = ["app_config", "feature_flags"]
default = "app_config"# spin.toml — declare every label in the component's `key_value_stores`
[component.my-app]
key_value_stores = ["app_config", "feature_flags"]# runtime-config.toml — register each custom label with a backend
# (the default `default` label is auto-provided by Spin; everything
# else needs an entry here, or `spin up` errors with
# "unknown key_value_stores label <name>").
[key_value_store.app_config]
type = "spin"
[key_value_store.feature_flags]
type = "spin"edgezero new --adapter spin scaffolds both files; edgezero serve --adapter spin runs spin up --runtime-config-file runtime-config.toml so locally-declared labels resolve to the SQLite-backed Spin KV implementation. For production, swap type = "spin" for a managed backend (type = "azure_cosmos", type = "redis", …) per the Spin runtime-config docs.
provision writes the [component.<id>].key_value_stores array for you (it does NOT touch runtime-config.toml — keep that one hand-edited).
Seeding the store
edgezero config push --adapter spin reads runtime-config.toml and dispatches to the right per-backend writer — no embedded HTTP endpoint, no token to manage. Resolution order:
--localset: forces SQLite-direct against<spin.toml dir>/.spin/sqlite_key_value.db(Spin's local KV file). Useful for poking values in your local dev loop without authenticating against Fermyon Cloud. Even under--local, every non-defaultlabel MUST be declared inruntime-config.toml(see point 4 below) — without the stanza,spin uperrors withunknown key_value_stores label <name>and the file you just wrote is unreadable from the running app, so the dispatcher refuses the push and tells you exactly which stanza to add.- Manifest's
deploycommand targets Fermyon Cloud (auto-detected from[adapters.spin.commands].deploycontainingspin deployorspin cloud deploy): one batched shellout per ≤96 KiB chunk ofspin cloud key-value set --app <APP> --label <LABEL> KEY=VALUE [KEY=VALUE …].<APP>comes from[application].nameinspin.toml;<LABEL>is the env-resolved platform label (your[stores.config].idafter theEDGEZERO__STORES__CONFIG__<ID>__NAMEoverlay). EdgeZero uses Fermyon's app-scoped label model, so the operator pre-links the label to a cloud KV store viaspin cloud link key-value(or the Fermyon dashboard) before the first push; an unlinked label produces an actionable error suggesting the link command. Authentication comes fromspin cloud login— EdgeZero does not store cloud credentials. runtime-config.tomldeclares this label's backend:type = "spin"→ SQLite-direct (honours an explicitpathfield if set; otherwise the default.spin/sqlite_key_value.db).type = "redis"/azure_cosmos/ unknown → clear error pointing at the backend's native CLI (e.g.redis-cli -u <url> SET <key> <value>). Native-CLI dispatch for redis / azure is planned for a follow-up release.
- Default: only the
defaultlabel falls through to SQLite without a runtime-config stanza (Spin auto-providesdefault). Any other label MUST have a[key_value_store.<label>]entry — absent it, the dispatcher errors before any write.
Schema-coupling note: the SQLite writer uses the exact spin_key_value schema and INSERT … ON CONFLICT DO UPDATE statement vendored from spinframework/spin's crates/key-value-spin/src/store.rs. A contract test in edgezero-adapter-spin/src/cli/push_sqlite.rs asserts byte-equality against the upstream string, and the workspace's spin-sdk = "~6.0" pin blocks any Spin minor bump that would change the schema until the operator opts in.
# Local dev: writes through to .spin/sqlite_key_value.db.
edgezero config push --adapter spin --local
# Production (Fermyon Cloud): shells `spin cloud key-value set`.
spin cloud login # one-time
edgezero config push --adapter spinSecret Store
Spin secrets use spin_sdk::variables, which exposes a single flat variable namespace per component (no notion of multiple named secret stores). [stores.secrets].ids.len() > 1 while targeting Spin is caught by config validate --strict. Secret variables are declared manually in spin.toml with secret = true:
# spin.toml
[variables]
api_token = { required = true, secret = true }
[component.my-app.variables]
api_token = "{{ api_token }}"config validate runs a within-secrets canonicalisation check: each #[secret] field value is lowercased to mirror the runtime SpinSecretStore::get_bytes lookup, must be a valid Spin variable name (^[a-z][a-z0-9_]*$), and must not collide with another #[secret] value that lowercases to the same form.
Spin component discovery
provision and config push (Stages 6 and 7) write [component.<id>.*] blocks to spin.toml, which requires knowing the component id. Resolution:
- The CLI parses
spin.tomland enumerates[component.*]ids. - If exactly one component exists, it is used.
- If more than one exists,
[adapters.spin.adapter]must carry an explicitcomponent = "<id>"field; otherwise the command errors.
config validate --strict performs this resolution as part of its adapter-set checks when spin is in the target list, so the failure surfaces before provision / config push run.
Manifest Configuration
Configure the Spin adapter in edgezero.toml. See Configuration for the full manifest reference.
Next Steps
- Migration guide — moving from the pre-rewrite store schema
- Adapters overview — cross-adapter contracts
- Configuration — full manifest reference