The Wasm Component Model on Fastly Compute
Config Store: Configuring a Compute App
August 12, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): added a comparison against the real Rust SDK's ConfigStore, which handles open-error very differently.
Every application framework has a way to configure an app independently of its code—environment variables, a settings file, an admin panel backed by a database somewhere. Fastly's Compute platform has two: Config Store and Secret Store. This post is about the first one.
Every interface we've met so far, you both read and wrote. You read request headers and wrote response ones. You read a body and wrote a body. Reasonable to expect a data store works the same way—open it, read from it, write to it. Config Store only does the first half of that.
The whole interface
Here it is, in full:
/// [Config Store] API.
///
/// [Config Store]: https://www.fastly.com/documentation/guides/concepts/edge-state/dynamic-config/#config-stores (opens in a new window)
interface config-store {
use types.{error, open-error};
/// A Config Store.
resource store {
/// Attempts to open the named config store.
///
/// Names are case sensitive.
open: static func(name: string) -> result<store, open-error>;
/// Fetches a value from the config store, returning `ok(none)` if it doesn't exist.
get: func(
key: string,
max-len: u64,
) -> result<option<string>, error>;
}
}
You might be surprised to know that this not an excerpt. store has exactly two functions: open and get. No set, no insert, no put—nothing that writes. Whatever you can do to a Config Store from inside a Compute program, you can do with those two calls and nothing else.
Where the values actually come from
If Compute code can't write to a Config Store, something else has to. That something is Fastly's own tooling—the UI or API—entirely outside the ABI, outside your deployed component, outside compute.wit altogether. Fastly's own dynamic config guide (opens in a new window) describes Config Store updates as "infrequent and outside the Compute platform." You publish a Compute service that reads a key; someone (possibly also you, but through a different door) updates that key through the Fastly control plane; your running service picks up the new value on its next get.
That's a real distinction, not a permissions restriction the ABI happens to enforce. There's no error variant here for "insufficient permissions to write," because there's no write function to be denied in the first place.
get's shape
get: func(
key: string,
max-len: u64,
) -> result<option<string>, error>;
The max-len/option-wrapped-result shape is the one we already know from reading request headers: an explicit buffer cap, and a result (did the call itself fail) wrapping an option (was the key even set). Guess max-len too small, and error.buffer-len tells you the size that would've worked, same as always.
One thing is different, though: the return type is option<string>, not option<list<u8>>.
Recall that header values came back as bytes because HTTP itself doesn't guarantee they're valid UTF-8. A Config Store value doesn't carry that uncertainty—it's typed as a real string at the ABI level, so there is no lossy conversion required on your end.
open-error, properly this time
store.open returns open-error on failure—the same shared type backend.open used back in Calling a Backend by Hand, but we glossed over its actual variants there. It's worth looking at them now, since one of them is exactly the mistake you're likely to make with a Config Store name:
/// An error returned by `open`-like functions.
enum open-error {
/// The given name of the entity to open was invalid.
invalid-syntax,
/// The given name is longer the maximum permitted length.
name-too-long,
/// The given name is a reserved name that may not be opened.
reserved,
/// No entity by the given name was found.
not-found,
/// Unsupported operation error.
///
/// This error is returned when some operation cannot be performed, because it is not supported.
unsupported,
/// Limit exceeded
///
/// This is returned when an attempt to allocate a resource has exceeded the maximum number of
/// resources permitted. For example, creating too many response handles.
limit-exceeded,
/// Generic error value.
///
/// This means that some unexpected error occurred.
generic-error,
}
Have a look at not-found here: this is how you know if you open the wrong name (a typo, a store that exists in one environment but not another), distinctly from "the name itself was malformed" (invalid-syntax) or "you're not allowed to open something called that" (reserved).
Fastly's Rust SDK trades most of that away. ConfigStore::open(name) (opens in a new window) returns a ConfigStore, not a Result, so there's no open-error at the call site to match on at all. The failure surfaces later, at read time: get(key) -> Option<String> is documented as able to panic, and try_get(key) -> Result<Option<String>, LookupError> is the version that hands the problem back to you instead.
The reasoning behind that is easy to reconstruct. A mistyped store name is a deployment mistake rather than a runtime condition worth branching on in every handler, so the default path gets to read like store.get("greeting") and the careful path lives one method over. The ABI can't make that bet for you. result<store, open-error> is the shape that leaves both readings available, and each binding on top of it decides which one it wants to be the easy one.
A deprecated twin
If you search compute.wit for something that reads a Config Store, you'll find declarations for a second interface, dictionary. Its own doc comment says, in plain words, "deprecated in favor of config-store." compute.wit doesn't have a dedicated deprecation marker in its grammar; this is what deprecating an interface looks like here: the old one just keeps existing, doc-commented as legacy. Use config-store—dictionary is outdated and you should never reach for it.
The same twin shows up a layer up, incidentally. Fastly's Rust SDK carries both a config_store module and a dictionary module (opens in a new window) marked deprecated, for the same reason compute.wit does: renaming a thing is easy, and removing one that somebody's code still calls isn't.
Reading a key
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{config_store, http_body, http_req, http_resp},
};
fn get_header_value(
request: &http_incoming::Request,
name: &str,
) -> Result<Option<String>, http_req::Error> {
let mut max_len: u64 = 128;
loop {
match request.get_header_value(name, max_len) {
Ok(Some(bytes)) => return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Ok(None) => return Ok(None),
Err(http_req::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
struct ConfigStoreExample;
impl http_incoming::Guest for ConfigStoreExample {
fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let key = get_header_value(&request, "x-key")
.map_err(|_| ())?
.unwrap_or_else(|| "greeting".to_string());
let store = config_store::Store::open("settings").map_err(|_| ())?;
let value = store.get(&key, 1024).map_err(|_| ())?;
let msg = match value {
Some(v) => format!("\"{key}\" = \"{v}\"\n"),
None => format!("\"{key}\" is not set in this Config Store\n"),
};
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, msg.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(ConfigStoreExample with_types_in bindings);
Two functions, and there's nothing more to the happy path than open then get. The x-key header (the same header-override trick from dynamic backends) picks which key to read, defaulting to greeting.
Running it
Full working code: full example on GitHub (opens in a new window). Local test data lives directly in fastly.toml:
[local_server]
[local_server.config_stores.settings]
format = "inline-toml"
[local_server.config_stores.settings.contents]
greeting = "Hello from the Config Store!"
"site.name" = "Behind the Panic"
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
"greeting" = "Hello from the Config Store!"
curl -H "x-key: site.name" http://127.0.0.1:7676/ (opens in a new window)
"site.name" = "Behind the Panic"
curl -H "x-key: does-not-exist" http://127.0.0.1:7676/ (opens in a new window)
"does-not-exist" is not set in this Config Store
Beyond the WIT
get never blocks waiting on anything, never hands you a pollable, never has an async sibling. That's not an oversight—it's a direct consequence of how Fastly actually runs this feature. The same dynamic config guide quoted above says Config Store data "is automatically cached in all Fastly POPs, providing low-latency read operations," and names the tradeoff outright: "the trade-off for high read performance is slower write operations." Every get your Compute program makes is reading something already sitting locally at the POP it's running on—there's nothing to wait for, because the slow part already happened, on someone else's schedule, before your request ever arrived.
Next: Secret Store looks identical to Config Store from a distance—open a store, look up a key by name. But if the data you're handling is sensitive, Secret Store is what you'll reach for, not Config Store. We'll go in to how we'll actually need to take an extra step to retrieve the secret value.