Skip to content

Migrating to the blob app-config ​

The blob app-config rewrite (spec §10) replaces per-leaf typed-config storage with a single JSON envelope per [stores.config] key. The runtime extractor swaps #[secret] field values for the resolved secret automatically; handlers no longer call config_store_default() or secret_store.require_str(...) by hand.

The cutover is atomic: there is no compatibility path. Pushing once with the new CLI brings the store into the new shape.

TL;DR ​

sh
# Push your typed C as one envelope blob per environment.
<app-cli> config push --adapter <name>          # remote
<app-cli> config push --adapter <name> --local  # local emulator state

# Read it back, see the diff against your local TOML.
<app-cli> config diff --adapter <name>                  # default: unified diff
<app-cli> config diff --adapter <name> --format json    # for jq / CI gates
<app-cli> config diff --adapter <name> --exit-code      # CI-friendly: exit 1 if changes

In handlers, swap hand-managed reads for the new extractor:

rust
// before
async fn greet(ctx: RequestContext) -> Result<Response, EdgeError> {
    let cfg: AppDemoConfig = ctx.config_store_default()?.get("app_config").await?;
    let api_token = ctx.secret_store_default()?.require_str(&cfg.api_token).await?;
    // ...
}

// after
use edgezero_core::extractor::AppConfig;

#[action]
async fn greet(AppConfig(cfg): AppConfig<AppDemoConfig>) -> Result<Response, EdgeError> {
    // cfg.api_token already holds the resolved secret value;
    // cfg.feature.new_checkout is typed as bool, etc.
}

Why this change ​

The pre-blob model stored each leaf at a separate Config Store key (feature.new_checkout, service.timeout_ms, api_token). Three problems made that untenable as projects grew:

  • Drift detection was per-leaf. Renaming a field in code left orphans in the store; finding them required a full key-by-key audit. The blob model embeds a SHA over the canonical-form data, so the whole config is one drift-detection unit.
  • Push wasn't atomic. Pushing N leaves was N round trips; a failure mid-push left the store in a partially-updated state. The blob model writes ONE envelope per [stores.config] key.
  • Secret resolution was per-handler. Every handler that read a #[secret] field had to remember to call require_str. The new AppConfig<C> extractor walks C::secret_fields() once and replaces each key NAME with the resolved value before handing cfg to the handler.

What's in the blob ​

The pushed value is a single JSON envelope:

json
{
  "version": 1,
  "generated_at": "2026-06-22T18:42:31Z",
  "sha256": "1f3a…",
  "data": {
    "api_token": "demo_api_token",
    "feature": { "new_checkout": false },
    "greeting": "hello",
    "service": { "timeout_ms": 1500 },
    "vault": "default"
  }
}

data carries every typed field VERBATIM — including #[secret] fields. Per spec §3.3 Model A, the value at rest in a secret-bearing field is the operator-supplied KEY NAME (api_token = "demo_api_token", vault = "default"); the runtime extractor reads those names from data and swaps each one for the resolved secret value at request time. The blob never contains the resolved secret bytes.

sha256 covers data only in canonical form (sorted keys, ryu-shortest floats, no trailing whitespace). version and generated_at are NOT part of the hash. Two pushes ten minutes apart with the same data produce blobs with identical sha256 and different generated_at; the skip-on-equal path catches the redundant write.

Per-adapter mechanics ​

Axum ​

The push writes to .edgezero/local-config-<id>.json next to your edgezero.toml. The file is a JSON map of { "<key>": "<envelope_json>" }; the dev server reads the envelope through AxumConfigStore::get. There is no --local flag because Axum's push IS always local.

Cloudflare ​

The push shells out to wrangler kv bulk put --namespace-id=<id> --remote with one entry: (<key>, <envelope_json>). With --local, the push runs wrangler kv bulk put <file> --binding <BINDING> --local against .wrangler/state, selecting the namespace by binding name rather than by namespace id.

The bundled edgezero binary calls wrangler from your shell; your project's wrangler.toml selects the namespace.

Fastly ​

The push uses fastly config-store-entry update --upsert --stdin to write the envelope as the value of one Config Store entry.

Oversized envelopes are handled automatically. Fastly's per-entry limit is 8,000 characters. The adapter applies a conservative UTF-8 byte check (bytes are always ≥ characters, so an envelope that fits by bytes always fits by characters): if the envelope JSON is at most 8,000 bytes it's stored directly. This byte threshold is the v1 read/write contract — it is stable across upgrades, so a value stored by an earlier release always resolves. Otherwise the adapter:

  1. Splits the envelope JSON into UTF-8-safe chunks (target 7,000 bytes each).
  2. Writes each chunk under a content-addressed key: <KEY>.__edgezero_chunks.<envelope_sha256>.<index>.
  3. Writes a JSON root pointer at <KEY> LAST — { edgezero_kind: "fastly_config_chunks", version: 1, envelope_sha256, envelope_len, data_sha256, chunks: [...] }.

Reads (runtime, config diff, config push skip-on-equal) detect the pointer shape automatically and reassemble the envelope. The chunking is invisible to AppConfig<C> and to operators in normal cases. If the pointer JSON itself exceeds 8,000 characters (extremely large configs), push hard-errors before any platform write and asks you to restructure into multiple typed config structs.

config push --adapter fastly --local mirrors the same shape into fastly.toml's [local_server.config_stores.<id>.contents] table. Dotted chunk keys are written as literal TOML keys (quoted strings), NOT nested dotted-path tables — so the local store layout matches the remote store layout.

Spin ​

config push --adapter spin --local writes SQLite-directly into <spin.toml dir>/.spin/sqlite_key_value.db, using the vendored spin_key_value schema, at (store=<platform>, key=<key>).

Without --local, the push targets whatever the Spin runtime config declares for the store. The adapter mirrors the writer's four-branch dispatch on the read side too:

runtime-config.toml backendconfig diff behaviour
--local flagSQLite-direct read
Manifest deploy targets Fermyon CloudReturns Unsupported — Spin Cloud's key-value CLI has no get; remote read-back is not in v1
type = "redis" / "azure_cosmos" / unknownErrors with a pointer at the backend's native CLI (redis-cli GET <key>, etc.)
type = "spin" (default)SQLite-direct read, honouring path override

Spin Cloud's Unsupported means config diff --adapter spin cannot show you the remote state. config push --adapter spin --yes writes unconditionally; without --yes, the push prompts on a TTY or exits non-zero on a non-TTY (per spec §8.3's four-branch UX).

Operator runbook ​

First push of a new project ​

  1. Provision the backing stores. Each adapter has its own:

    sh
    <app-cli> provision --adapter axum         # no-op (file-based)
    <app-cli> provision --adapter cloudflare   # wrangler kv namespace create
    <app-cli> provision --adapter fastly       # fastly config-store create
    <app-cli> provision --adapter spin         # edits spin.toml in place
  2. Pre-populate [stores.secrets]. Push doesn't write secret values into the config store — your handlers will fail with ConfigOutOfDate at runtime if the operator-supplied key name doesn't resolve.

    sh
    # Cloudflare (per spec §10.2)
    wrangler secret put demo_api_token
    
    # Fastly
    fastly secret-store-entry create --store-id=<id> --name=demo_api_token --value=<value>
    
    # Spin local
    echo demo_api_token=<value> >> .env
    
    # Axum local
    demo_api_token=<value> cargo run -p <app-cli> -- serve --adapter axum
  3. Push the typed config:

    sh
    <app-cli> config push --adapter <name>

    The CLI prints an inline unified diff against the current remote state. --yes (-y) skips the consent prompt; --no-diff suppresses the render; --dry-run shows the diff and exits without writing — except against Spin Cloud, whose read-back is unsupported, so dry-run errors there (use --local or --yes).

Per-environment key override ​

Spec 5.4 + 12.7: a single <app-name>.toml covers dev / staging / production. Two mechanisms swap which blob the runtime reads.

For staging, use --staging. It writes the config under the <logical-id>_staging key in the same store, so it never overwrites the production key the live service reads:

sh
<app-cli> config push --adapter <name> --staging
<app-cli> config diff --adapter <name> --staging

--staging is mutually exclusive with --key, because the staging key is derived from the store's logical id. On Fastly a staged deploy provisions a per-service edgezero_runtime_env_staging_<service-id> selector store and links it automatically, so a staged version reads staged config without any manual key override. Do not hand-set a _staging key in the production edgezero_runtime_env store; that would make production serve staged config.

For any other environment, --key is the general per-environment mechanism. Each push lands at its own key:

sh
<app-cli> config push --adapter <name> --key app_config
<app-cli> config push --adapter <name> --key app_config_canary

The override variable is EDGEZERO__STORES__CONFIG__<ID>__KEY -- double-underscore separators, upper-case <ID>. The runtime extractor packs default_key into the ConfigStoreBinding at adapter init. Where you set the override depends on the platform's variable mechanism.

AdapterWhere to set EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY
AxumProcess env: EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary <app-cli> serve --adapter axum
Cloudflare.dev.vars (local) or wrangler.toml [vars] (deployed) -- wrangler surfaces it to env.var(...) in the worker
Spin[application.variables] in spin.toml (defaulted) plus SPIN_VARIABLE_EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary spin up for a per-invocation override
FastlyA dedicated edgezero_runtime_env Config Store (Compute@Edge has no process env). See below.

Fastly specifically ​

Compute@Edge has no std::env, so EdgeZero reads runtime overrides from a Fastly Config Store named edgezero_runtime_env. The store is created automatically by edgezero provision --adapter fastly. After provisioning:

sh
# Look up the platform store id (matches by name).
fastly config-store list --json | jq -r '.[] | select(.name=="edgezero_runtime_env") | .id'

# Set the override for one service. Config Store keys are case-sensitive.
fastly config-store-entry update \
  --store-id=<STORE-ID> \
  --key=EDGEZERO__SERVICES__<SERVICE_ID>__STORES__CONFIG__APP_CONFIG__KEY \
  --value=app_config_canary \
  --upsert

This store is the one the ACTIVE (production) version reads, so never point it at a _staging key. Staged config is isolated by the per-service edgezero_runtime_env_staging_<service-id> store that a staged deploy creates and links for you.

Fastly runtime overrides are service-scoped because the Config Store can be linked to multiple services. Legacy unscoped EDGEZERO__STORES__... entries are not read; migrate manually managed entries by rewriting them under the service prefix shown above. Provisioning a non-default store-name mapping requires service_id in fastly.toml or FASTLY_SERVICE_ID so the command cannot write into an ambiguous namespace. If both are set, they must match.

Locally, Viceroy reports the fixed service ID 0000000000000000000000, regardless of the deployment service_id in fastly.toml. Put local overrides under that namespace:

toml
[local_server.config_stores.edgezero_runtime_env.contents]
EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging"

If the local edgezero_runtime_env store is missing, EdgeZero logs a one-line warning and falls back to the binding's default id. The runtime keeps serving, but the per-environment override is inactive.

Drift detection in CI ​

sh
# Exit non-zero if the deployed remote differs from your local TOML.
<app-cli> config diff --adapter <name> --exit-code --format json

config diff's exit codes per spec Q10:

  • 0 — no changes (or --exit-code not set).
  • 1 — changes with --exit-code set.
  • 2 — adapter / config / read error.

Pair with --format json for machine-readable output:

json
{
  "local_sha256": "1f3a…",
  "remote_sha256": "a472…",
  "added": { "vault": "default" },
  "removed": {},
  "changed": {
    "feature.new_checkout": { "from": false, "to": true },
    "service.timeout_ms": { "from": 1500, "to": 2000 }
  }
}

Cleanup of orphan per-leaf keys ​

After the cutover, your store may still hold the pre-blob per-leaf entries (feature.new_checkout, service.timeout_ms, etc.) that nothing reads. The blob model leaves them inert — they're not referenced — but they consume store quota.

AdapterCleanup command
Axumrm .edgezero/local-config-*.json (the blob push writes a fresh file)
Cloudflarewrangler kv bulk delete <tempfile.json> --namespace-id=<id> --remote with the orphan keys listed
Fastlyfastly config-store-entry delete --store-id=<id> --key=<orphan-key> per key
Spin localsqlite3 .spin/sqlite_key_value.db "DELETE FROM spin_key_value WHERE store='<id>' AND key NOT IN ('app_config', 'app_config_staging', ...)" -- preserve every key your runtime might select via EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY

Listing the orphans before deletion:

sh
# Cloudflare
wrangler kv key list --namespace-id=<id> --remote | jq -r '.[].name'

# Fastly (the JSON field is `item_key`, not `key`)
fastly config-store-entry list --store-id=<id> --json | jq -r '.[].item_key'

# Spin
sqlite3 .spin/sqlite_key_value.db "SELECT key FROM spin_key_value WHERE store='<id>'"

config gc does NOT do this per-leaf cleanup. It reclaims a different kind of orphan — the chunk entries an oversized blob leaves behind when it is re-pushed (each generation is content-addressed, so a change orphans the whole previous chunk set). It never touches your old per-leaf keys. Run the per-leaf deletes above for the migration cutover; run config gc (below) as an ongoing reclamation for chunked stores.

Reclaiming orphaned chunk entries (Fastly, oversized configs) ​

If your app-config blob exceeds Fastly's 8 000-byte entry limit (the adapter applies a conservative UTF-8 byte check — see the storage-model note above — so the trigger is stable regardless of multi-byte characters) it is stored as content-addressed chunks, and every re-push orphans the previous generation. config gc reclaims them safely — it derives the live set from the store and deletes only unreferenced chunk entries you have asserted are old enough:

sh
# Preview every orphan and its age (dry-run by default; `--dry-run` states it
# explicitly and is the same thing — it deletes nothing and conflicts with `--yes`):
<app>-cli config gc --adapter fastly

# Delete, asserting that NO root in this store changed in the last 7 days AND no
# writer is targeting the store (config gc sweeps every root, store-wide):
<app>-cli config gc --adapter fastly --older-than 7d --yes

--older-than is REQUIRED for --yes and is YOUR safety assertion; do not run config gc while a config push to the same store is in flight (Fastly has no compare-and-swap). Cloudflare/Spin orphan cleanup remains manual.

Fastly chunk-pointer hygiene ​

If your envelope ever pushed as chunked, then later shrinks back under the 8 000-byte trigger, the old chunks remain in the store unreferenced (the root pointer is the only active commit record). To find them:

sh
fastly config-store-entry list --store-id=<id> --json \
  | jq -r '.[].item_key | select(contains(".__edgezero_chunks."))'

config gc --adapter fastly (shown above) reclaims these safely — it will only delete chunk keys no live pointer references.

Troubleshooting ​

ConfigOutOfDate at runtime ​

The runtime read either:

  • couldn't find the blob key (push hasn't run for the deployed code revision),
  • found a blob whose data shape doesn't match the deployed C (push ran for a different code revision), or
  • couldn't resolve a #[secret] field's key name against the secret store (the operator-supplied key isn't pre-provisioned).

Retry-After: 60 accompanies the response so callers know to back off. Run <app-cli> config push --adapter <name> for the deployed code revision to fix the first two cases; provision the missing secret to fix the third.

config push reports "this command requires a typed app-config struct" ​

You ran edgezero config push from the BUNDLED binary instead of your project's typed downstream CLI. The bundled binary intentionally cannot push (it has no typed C in scope). Run <your-app>-cli config push instead — edgezero new generates this CLI for you.

Local fastly.toml writer prunes old chunks on re-push ​

The local writer upserts the keys it is pushing and prunes the chunks the prior generation of those roots referenced — except any prior chunk key whose VALUE is itself a runtime-readable root (a valid envelope or pointer), which is kept with a warning rather than deleted. It does not wholesale-replace the per-store contents block, so keys it isn't pushing (including sibling roots and their chunks) are preserved. Pruning is safe here in a way it is not in the cloud: fastly.toml is a single file that Viceroy reads at startup, so there is no propagation window and no POP that could still be serving the prior pointer.

This is STRONGER than the remote behaviour, where a config push reclaims nothing and orphan chunks remain inert until you run config gc. The runtime correctness property holds either way: a read after push B reconstructs envelope B, not A.

Reference ​

Released under the Apache License 2.0.