The Wasm Component Model on Fastly Compute

Config Store: Configuring a Compute App

EDIT (2026-08-18): added a comparison against the real Rust SDK's ConfigStore, which handles open-error very differently.

EDIT (2026-09-28): added a section on resource links, the way a Config Store, and every store after it, is attached to a service.

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:

WIT
/// [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.

Linking the store to a service

A Config Store doesn't belong to a service. It belongs to your Fastly account, and a service reaches it through a resource link (opens in a new window), which Fastly's API reference describes as "a link between a shared resource (such as an kv store or config store) and a service version." With the CLI, that's two steps:

Bash
fastly config-store create --name=settings
fastly service resource-link create --resource-id=<store ID> --version=latest --autoclone

Then activate the new version. The link is part of a service version, like a backend is, so it takes effect when the version it's on does.

Once it's there, the link is what makes the read-only arrangement above work. Fastly's Config Store guide (opens in a new window) says that once a store is linked to a service, "any updates you make to the store are automatically reflected on the service without needing to increment the service's version. Changes immediately impact all service versions, including the active one." Adding the link needs a new version. Changing what's in the store never does.

The link also has a name of its own, and that's the name open takes: the link's, not the store's. The CLI gives the link the store's name unless you pass --name, so they usually match, but the API reference points out that the two are separate and "might not." If open answers not-found for a store you can see in your account, compare its name against the link's.

Locally, there's no account and no link. The [local_server.config_stores] table in fastly.toml, shown further down, stands in for both, and its key, settings, plays the part of the link name.

get's shape

WIT
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:

WIT
/// 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

Rust

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:

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"
Bash
fastly compute serve
Bash
curl http://127.0.0.1:7676/
Terminal output
"greeting" = "Hello from the Config Store!"
Bash
curl -H "x-key: site.name" http://127.0.0.1:7676/
Terminal output
"site.name" = "Behind the Panic"
Bash
curl -H "x-key: does-not-exist" http://127.0.0.1:7676/
Terminal output
"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.