Sealed variables

Variables are sealed in your browser, kept sealed by wabi, and opened only inside your service’s microVM.

Your wallet signs every change. Between your browser and your service's microVM, a value travels only as ciphertext: wabi stores it and delivers it without being able to read it.

  1. Your browserTyped, then sealed to your account’s key before it leaves.
  2. wabiHeld as ciphertext by the API, the database and the backups.
  3. Your microVMOpened by zkenv-init as your app starts.
The variable DATABASE_URL is readable in your browser, held only as sealed marks by wabi, and readable again inside your microVM. Its name stays readable at wabi; its value doesn’t.

Where a value is readable

Plaintext exists in two places:

  • your browser, while you type a value or reveal one,
  • your service's microVM, in the environment of your app's process. For a build, the values sit as files in a memory-backed directory inside the build's microVM.

A key you choose to mark as a Dockerfile build arg also becomes part of the image that build produces (Builds).

It never reaches:

  • the control plane that runs the dashboard and the API,
  • the control plane's database or its backups,
  • staff tools,
  • Kubernetes Secrets in the cluster,
  • the disk of the machine that runs your services, outside the images of keys you mark as build args.

Static sites run no code and take no variables.

How a value is sealed

Each account has its own sealing key pair, an X25519 key generated inside the cluster that runs your services. Only the public half leaves the cluster. Your browser encrypts every value to it as a sealed box (libsodium's crypto_box_seal: X25519 with XSalsa20-Poly1305). Only the secret half, inside the cluster, opens it.

Your browser checks the key before it seals anything. The cluster's identity key signs each account's sealing key together with the wallet that owns it, and the web app has that identity's public key built in. The browser seals only to a key whose signature verifies against the pinned identity and names your connected wallet. The control plane can't hand you a key of its own, or another account's.

Two copies of every value

CopySealed toOpened by
sealedyour account's sealing keyyour service's microVM
readbackyour owner key, derived from your walletyour browser, through Reveal with wallet

The manifest your wallet signs

Every change to a service's variables is a new manifest. It lists:

  • the service's id and name, the cluster it runs on, and the namespace (your account's space in that cluster),
  • the version,
  • the sealing key the values are sealed to,
  • every key name, with the SHA-256 hash of each of its two sealed copies,
  • every ${{name.KEY}} reference in the values, and the service each name points to,
  • the time it was issued.

Your wallet signs a text built from the manifest. This is what it shows:

wabi zkenv manifest v1
service: <serviceName> (<serviceId>)
namespace: <namespace> on <cluster>
version: <version>
variables: <number of vars>
references: <number of refs>
digest: <hex sha256 of the body string's UTF-8 bytes>

The digest is the SHA-256 of the manifest's exact bytes, so the signature covers every name, hash and reference in it. It is a signed message, never a transaction.

Saving replaces the service's whole set. wabi accepts a manifest only when your wallet signed it, it names your sealing key, its version is exactly one above the last, and every hash matches its sealed value. With a deployment live, saving starts a new deployment that runs with the new version.

The version floor

Versions start at 1 and go up by one with each change. For each service, the cluster keeps a floor: the highest version that has gone live. The floor is stored inside the cluster, where the control plane can't lower it. A version below the floor is refused, with all its values.

The signature and the floor together mean that no one, wabi included, can add, change or roll back a variable. Every version that runs is one your wallet signed, at or above the last one that went live. A deployment that fails leaves the floor where it was, and the instances still serving keep their variables.

When your service starts

zkenv-init runs as the first process of your container, inside the microVM. It is a static binary, so it works with any image, from Debian to distroless or scratch. Before your app starts, it:

  1. checks that the manifest is signed by your wallet, for this service and your account, at or above the floor,
  2. checks every sealed value against its hash in the manifest,
  3. opens each value into the process environment and resolves references,
  4. starts your app in its place, as the same process, so signals and exit codes are your app's own.

wabi runs the same checks before it hands a manifest to the cluster. A manifest that fails them fails the deployment with variables manifest rejected: <reason>. A check that fails inside the microVM fails the deployment with a line starting zkenv:. In both cases the live deployment keeps serving.

Reveal with wallet

wabi never returns a value in plaintext. To read one in the dashboard, use Reveal with wallet. It opens values with your owner key, which comes from one wallet signature per session, asked the first time you save or reveal variables:

wabi zkenv owner key v1
wallet: <base58 wallet address>
Signing this unlocks your sealed variables in this browser.

Your browser derives your owner key from the signature and keeps it in memory for the session. Ed25519 signatures are deterministic, so your wallet derives the same key every time. wabi holds only its public half, registered the first time, and your browser checks the derived key against it before opening anything.

Your browser then checks the value's read-back copy against its hash in the signed manifest, opens it, and shows it for 30 seconds.

References

A value can point at another service's variable with ${{name.KEY}}, where name is that service's name in your project. With a Postgres service named db:

DATABASE_URL=${{db.DATABASE_URL}}

References resolve inside your microVM when the service starts:

  • Each reference is listed in your signed manifest, which pins name to that service's id. A reference missing from the manifest stops the start with an error naming it.
  • The value comes from the other service's live variables, checked like your own: same owner, same account, at or above that service's floor.
  • A key the other service doesn't hold stops the start with an error naming it.
  • Keys starting WABI_KEYLESS_ can't be referenced.

The dashboard suggests the other services' key names as you type. The control plane resolves nothing: it carries the reference, never the value behind it.

Generated secrets

Secrets a service needs from the start are made in your browser. When you create a Postgres service, your browser generates a 32-byte random password and sets POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB, plus DATABASE_URL, PGHOST, PGPORT, PGUSER, PGPASSWORD and PGDATABASE built from them. Your wallet signs them as version 1, and the database starts once that version is applied. Other services reach it through references such as ${{db.DATABASE_URL}}. See Postgres.

Builds, pre-deploy commands and backups

A pre-deploy command runs in its own microVM with the same variables as the deployment it precedes, opened the same way.

A build from GitHub gets the same variables. zkenv-init writes each one as a file in a memory-backed directory inside the build's microVM. A buildpacks build reads that directory as its platform environment. A Dockerfile build receives every variable as a BuildKit secret, which never enters the image:

RUN --mount=type=secret,id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL npm run build

A key you mark as a build arg in the service's settings is also passed as a build arg, for a Dockerfile that reads it with ARG; its value is then recorded in the image (Deploy from GitHub).

Daily Postgres backups are encrypted to your sealing key as well, so they open only inside your account's space in the cluster.

Rollbacks and older values

A rollback restores an older deployment's image and settings and keeps the current variables. To bring older values back, open the service's variables history in the dashboard: your browser opens an earlier set with your owner key, and your wallet signs it again as the next version.

Reserved names

  • PORT is set by the platform to the service's port (8080 by default, 5432 for Postgres). A manifest that includes PORT is refused; change the port in the service's settings. See Services.
  • Keys starting WABI_KEYLESS_ belong to the keyless signer. They open only for the signer and never enter your app's environment. See Keyless backends.

Key recovery

wabi backs up every account's sealing key, with the cluster identity, once a day, encrypted to a recovery key held offline. A rebuilt node restores them, so every sealed value and every database backup opens as before, and the identity pinned in the web app stays valid.

When a sealing key is rotated, the cluster issues a new one and your dashboard reseals every service from your read-back copies, each as a new version your wallet signs.