Reading Arguments and Environment Variables in WASI

This page answers one question: a WASI program reads std::env::args() or std::env::var("API_URL") and gets nothing — how do arguments and environment variables actually reach a WebAssembly module, and how do you pass them from each kind of host?

Prerequisites

  • [ ] A program built for wasm32-wasip1 or wasm32-wasip2 — Rust, C with the WASI SDK, or Go.
  • [ ] A runtime: the wasmtime CLI, Node 20+ with node:wasi, or a host embedding wasmtime.
  • [ ] A clear list of which settings the program needs at startup.

A module only sees what the host hands it

A native process inherits its environment from the shell that started it, automatically. A WASI module inherits nothing. Arguments and environment variables are provided through four WASI functions — args_sizes_get, args_get, environ_sizes_get and environ_get in preview 1 — and the host decides what those functions return. If the host passes nothing, the module sees an empty argument list (sometimes with just a program name) and an empty environment.

This is the capability model working as designed. A module cannot read AWS_SECRET_ACCESS_KEY from the host’s environment unless the host explicitly passes it. That is precisely the property you want when running code you did not write, and precisely the surprise you get the first time you run your own program and its configuration is missing.

The standard library hides the mechanics. In Rust, std::env::args() calls the WASI functions on first use; in C, main(argc, argv) and getenv are wired up by wasi-libc’s startup code. Your code reads arguments and environment exactly as it would natively. Only the host side changes.

How an environment variable reaches a WASI module The host is configured with an explicit list of variables. When the module's standard library first calls environ_sizes_get and environ_get, the runtime copies only those variables into the module's linear memory, and std::env::var returns them. host process WASI runtime module (std) configure env: API_URL, LOG_LEVEL environ_sizes_get() 2 vars, 48 bytes environ_get(ptrs, buf) copies only the configured vars std::env::var("API_URL") → Ok Variables the host did not list do not exist inside the module, whatever the host's own environment contains.

Step 1 — the program

The same code that works natively:

// src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("program: {:?}", args.first());
    println!("args:    {:?}", &args[1..]);

    let url = env::var("API_URL").unwrap_or_else(|_| "http://localhost:8080".into());
    let level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into());
    println!("API_URL={url} LOG_LEVEL={level}");
    println!("total env vars visible: {}", env::vars().count());
}
cargo build --release --target wasm32-wasip1

Step 2 — pass them with the wasmtime CLI

Arguments after the module path go to the program. Environment variables need --env, once per variable:

wasmtime run \
  --env API_URL=https://api.example.com \
  --env LOG_LEVEL=debug \
  target/wasm32-wasip1/release/app.wasm -- --input data.csv --verbose
program: Some("app.wasm")
args:    ["--input", "data.csv", "--verbose"]
API_URL=https://api.example.com LOG_LEVEL=debug
total env vars visible: 2

--env NAME without a value forwards that variable from the host’s own environment, which is convenient in scripts. The -- separates wasmtime’s options from the program’s, so a program flag like --verbose is not mistaken for a runtime flag. Forwarding the whole host environment is possible with -S inherit-env, but treat that as a debugging convenience, not a deployment setting.

Step 3 — pass them from Node’s node:wasi

Node’s WASI implementation takes the arguments and environment as constructor options:

// run.mjs
import { readFile } from "node:fs/promises";
import { WASI } from "node:wasi";

const wasi = new WASI({
  version: "preview1",
  args: ["app.wasm", "--input", "data.csv"],
  env: { API_URL: process.env.API_URL ?? "https://api.example.com", LOG_LEVEL: "debug" },
});

const module = await WebAssembly.compile(await readFile("target/wasm32-wasip1/release/app.wasm"));
const instance = await WebAssembly.instantiate(module, wasi.getImportObject());
wasi.start(instance);

Two details differ from the CLI. args includes the program name as its first element — WASI programs expect argv[0] just like native ones, and leaving it out shifts every argument by one. And env is a plain object, so passing process.env wholesale would forward everything; pick the variables explicitly. More on the Node host in running WASI modules in Node.js.

Step 4 — pass them from an embedding host

A Rust program that embeds wasmtime builds the WASI context in code. The builder makes the capability explicit:

use wasmtime_wasi::preview1::{self, WasiP1Ctx};
use wasmtime_wasi::WasiCtxBuilder;

let wasi: WasiP1Ctx = WasiCtxBuilder::new()
    .args(&["app.wasm", "--input", "data.csv"])
    .env("API_URL", "https://api.example.com")
    .env("LOG_LEVEL", "debug")
    .inherit_stdout()
    .inherit_stderr()
    .build_p1();

There is no default inheritance at all: anything not set on the builder is absent. The full embedding — engine, store, linker and calling an export — is covered in embedding wasmtime in a Rust application.

How each host passes arguments and environment The wasmtime CLI, Node's node:wasi and an embedded wasmtime host compared on how they accept arguments and environment variables, and what they pass by default. host arguments environment default wasmtime CLI after the module path, after -- --env NAME=value nothing inherited node:wasi args: [argv0, ...] env: { ... } nothing unless given embedded wasmtime .args(&[...]) .env(k, v) nothing unless set wasmtime -S inherit-env unchanged whole host env everything leaks in

Step 5 — validate configuration at startup

Because a missing variable is silent, fail fast and clearly when required configuration is absent. Read everything once, validate it, and exit with a helpful message:

struct Config { api_url: String, level: String }

fn load_config() -> Result<Config, String> {
    let api_url = std::env::var("API_URL")
        .map_err(|_| "API_URL is not set (pass --env API_URL=... to the runtime)".to_string())?;
    if !api_url.starts_with("https://") {
        return Err(format!("API_URL must be https, got {api_url}"));
    }
    Ok(Config { api_url, level: std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()) })
}

fn main() {
    let cfg = load_config().unwrap_or_else(|e| { eprintln!("config error: {e}"); std::process::exit(2) });
    // ...
}

The error message names the runtime flag, which saves the next person a search. Exiting with a non-zero code goes through WASI’s proc_exit, so the host sees a normal failure rather than a trap.

Choosing between arguments, environment and files

The three channels a WASI program has for configuration suit different kinds of data, and choosing deliberately makes the program easier to run in every host. Arguments are best for what changes per invocation: the input file, the output path, a mode flag. They are visible in process listings and logs on most hosts, so they are the wrong place for secrets. Environment variables suit deployment-level settings that stay constant across invocations of the same deployment: an API base URL, a log level, a feature switch. They are also the conventional channel for secrets in container and serverless platforms, and WASI hosts follow that convention.

Files suit structured or large configuration: a rules file, a schema, a list of hundreds of entries. They require the host to preopen a directory, which is one more capability to grant, but they keep the argument list readable and avoid the size limits some platforms impose on environment variables.

Whatever the mix, document it in one place — ideally in the program’s --help output, which every host can display — and treat the list as part of the program’s interface. A WASI program is often run by a host its author never sees: a plugin system, a CI job, an edge platform. Clear configuration is what lets those hosts run it correctly the first time, without reading the source to find out which variables it expects.

Expected output

Without configuration, the validation catches it:

$ wasmtime run app.wasm
config error: API_URL is not set (pass --env API_URL=... to the runtime)
$ echo $?
2

With configuration, the program runs and sees exactly the two variables it was given.

Gotchas

  • args empty in Node. args was omitted or did not include argv[0]; the program skipped the first element and saw nothing. Always pass the program name first.
  • A variable is set in the shell but missing in the module. Expected — the runtime does not inherit. Pass it with --env NAME or the host API.
  • Quoting surprises. Shell quoting applies before the runtime sees the value, so --env MSG="a b" passes a b as one value, while an unquoted space splits it into another runtime argument. Quote values that contain spaces.
  • Secrets leaking into untrusted modules. Using inherit-env or forwarding process.env hands every secret on the host to the module. List variables explicitly.
  • Preview 2 components and the CLI world. Components that implement wasi:cli/run receive arguments and environment through wasi:cli/environment; the wasmtime flags are the same, but custom hosts must wire up that interface rather than the preview 1 functions.

Performance note

Reading arguments and environment is negligible: the host copies a few hundred bytes into linear memory once, on first access. The only measurable cost is in the module size — std::env support adds about 2 KB to a Rust WASI binary, which is already included in any program that uses println!.

Startup cost of reading configuration in a WASI program Time from instantiation to main() finishing configuration for a small Rust WASI program in wasmtime, with no configuration, two variables, and fifty variables. The difference is microseconds. microseconds from start to config loaded no env vars 41 µs 2 env vars + 3 args 44 µs 50 env vars 63 µs

Frequently Asked Questions

Can a module change its own environment variables? std::env::set_var works inside the module but only affects the module’s own copy. The host’s environment is never touched.

Is there a WASI equivalent of a config file? Files work if the host preopens a directory — see granting filesystem access with WASI preopens. Environment variables are simpler for a handful of settings; a file suits structured configuration.

Do edge platforms support environment variables? Most expose configuration through their own bindings rather than WASI environment variables. Check the platform’s documentation; Spin, for example, uses a variables interface, as shown in building HTTP services with Spin.

Can a module list all variables the host passed? Yes — std::env::vars() iterates them, and in C environ is populated. Logging that list once at startup in debug builds is a quick way to confirm what the host really provided.

What does argv[0] contain? Whatever the host passes. The wasmtime CLI uses the module’s file name; embedders choose their own.

← Back to WASI Target Builds & Runtimes