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-wasip1orwasm32-wasip2— Rust, C with the WASI SDK, or Go. - [ ] A runtime: the
wasmtimeCLI, Node 20+ withnode: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.
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.
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
argsempty in Node.argswas omitted or did not includeargv[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 NAMEor the host API. - Quoting surprises. Shell quoting applies before the runtime sees the value, so
--env MSG="a b"passesa bas one value, while an unquoted space splits it into another runtime argument. Quote values that contain spaces. - Secrets leaking into untrusted modules. Using
inherit-envor forwardingprocess.envhands every secret on the host to the module. List variables explicitly. - Preview 2 components and the CLI world. Components that implement
wasi:cli/runreceive arguments and environment throughwasi: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!.
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.
Related
- Running Wasm modules with the wasmtime CLI — the CLI flags in context.
- Compiling Rust to wasm32-wasip1 — building the programs used here.
- Restricting what a module can import — the capability model in the browser.
- WASI preview 1 vs preview 2 — how the interfaces changed.
← Back to WASI Target Builds & Runtimes