Running a Wasm Dev Environment in a Container
This page answers one task: setting up a WebAssembly toolchain on every developer’s machine is slow and inconsistent, so you want a container — usable with VS Code Dev Containers, GitHub Codespaces or plain Docker — that provides the whole toolchain and a working dev server the host browser can use.
Prerequisites
- [ ] Docker (or a compatible runtime) on the host, or a cloud development environment such as Codespaces.
- [ ] VS Code with the Dev Containers extension, or another devcontainer-aware editor, if you want editor integration.
- [ ] The project’s toolchain versions: Rust, wasm-bindgen, Emscripten, Binaryen, Node.
What belongs in the container and what stays on the host
The container holds everything that builds and serves the project: compilers, CLIs, the dev server and its dependencies. The host keeps the browser,
because browsers in containers lack GPU access and DevTools integration is clumsier. The bridge between them is a forwarded port: the dev server listens
inside the container, and the host browser opens http://localhost:5173 through the forwarded port.
That split works well for WebAssembly because the module is just an asset the dev server serves; the browser on the host compiles and runs it. Three things
need attention: the dev server must bind to 0.0.0.0 inside the container so forwarding works; isolation headers and MIME types must be set by the server
inside the container exactly as without one; and file watching must notice edits made from the host when the source is on a bind mount.
Step 1 — write the image
Start from a base image with the toolchains, pinning versions as you would in CI:
FROM mcr.microsoft.com/devcontainers/rust:1-bookworm
ARG NODE_VERSION=22
RUN rustup target add wasm32-unknown-unknown wasm32-wasip1 \
&& cargo install wasm-bindgen-cli --version 0.2.93 --locked \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && apt-get install -y nodejs binaryen \
&& git clone https://github.com/emscripten-core/emsdk /opt/emsdk && /opt/emsdk/emsdk install 3.1.73 && /opt/emsdk/emsdk activate 3.1.73
ENV PATH="/opt/emsdk:/opt/emsdk/upstream/emscripten:${PATH}"
Install only what the project uses; each toolchain adds hundreds of megabytes. Publish the image to a registry with multi-architecture builds so developers on Apple Silicon pull a native image, as discussed in running Wasm builds on ARM64 runners.
Step 2 — describe the dev container
// .devcontainer/devcontainer.json
{
"name": "wasm-dev",
"image": "ghcr.io/acme/wasm-dev:2024.10",
"forwardPorts": [5173],
"portsAttributes": { "5173": { "label": "Vite", "onAutoForward": "openBrowser" } },
"mounts": [
"source=wasm-cargo-registry,target=/usr/local/cargo/registry,type=volume",
"source=wasm-target,target=${containerWorkspaceFolder}/target,type=volume"
],
"postCreateCommand": "npm ci",
"customizations": { "vscode": { "extensions": ["rust-lang.rust-analyzer", "dbaeumer.vscode-eslint"] } }
}
Named volumes keep the Cargo registry and the target directory out of the bind-mounted source tree, which matters for speed on macOS and Windows, where
bind mounts are slow.
Step 3 — make the dev server reachable and correct
Inside the container, bind the dev server to all interfaces and keep the headers:
// vite.config.ts
export default defineConfig({
server: {
host: "0.0.0.0",
port: 5173,
strictPort: true,
headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp" },
watch: { usePolling: process.env.CHOKIDAR_USEPOLLING === "true" },
},
});
The host browser opens http://localhost:5173, which is a secure context because it is localhost, so cross-origin isolation and SharedArrayBuffer
work exactly as natively. Configuration for headers and Wasm handling is covered in
configuring the Vite dev server for Wasm.
Step 4 — wire up rebuild-on-save
Combine a Rust watcher with the dev server so edits rebuild the module automatically:
cargo watch -w crates/core -s "wasm-pack build crates/core --target web --dev --out-dir ../../web/pkg" &
npm run dev
File-change notifications from the host do not always reach containers through bind mounts, especially on macOS and Windows; if rebuilds do not trigger,
enable polling (CHOKIDAR_USEPOLLING=true for Node watchers, cargo watch --poll). Polling uses more CPU, so restrict watched paths. The full hot-reload
setup is in
hot reloading a Rust Wasm crate during development.
Step 5 — debug from the host browser
DevTools in the host browser debug the module served from the container as if it were local. DWARF-based C/C++ debugging maps source paths recorded at
compile time — paths inside the container, such as /workspaces/app/src/main.c. Configure path mapping in the DWARF extension, or compile with
-fdebug-prefix-map=/workspaces/app=. (and Rust’s --remap-path-prefix) so paths are relative and resolve on the host. Source maps generated for
JavaScript glue work without changes.
Performance on macOS and Windows
Containers on macOS and Windows run inside a Linux virtual machine, and file access across the boundary is the main cost. Compiling Rust with the target
directory on a bind mount can be several times slower than natively. Keeping target, node_modules and tool caches on named volumes inside the VM
removes most of that penalty; only the source files remain on the bind mount. An alternative is to clone the repository inside a volume entirely
(“clone in container volume” in VS Code), which gives near-native performance at the cost of accessing files from host tools. Measure a clean build and
an incremental rebuild in each configuration before standardising.
Cloud development environments
The same devcontainer.json runs in GitHub Codespaces and similar services. Port forwarding there goes through an HTTPS URL on the provider’s domain
rather than localhost, which affects WebAssembly in two ways: the URL is a secure context (good), and cross-origin isolation requires the forwarded
response to keep COOP and COEP headers, which the provider’s proxy generally passes through. Check crossOriginIsolated in the forwarded page. Prebuilds of
the container image, including compiled dependencies, make new environments start in seconds instead of minutes.
Keeping the image maintainable
Development images tend to grow into everything anyone ever needed. Keep them focused: one image per project or per closely related group of projects,
built from a Dockerfile in the repository, rebuilt in CI on a schedule and whenever the toolchain pins change. Tag images with the date or the toolchain
version rather than latest, and reference the tag in devcontainer.json, so updating the environment is a reviewed change like any other dependency
bump. Split rarely used tools — a second Emscripten version, profiling utilities — into optional features (Dev Container Features) that developers enable
when needed, rather than baking them into the base. Document in the README how to work without the container, using the same pinned versions, because
some developers will prefer native toolchains, and the container should be a convenience rather than the only way to build.
Security considerations
Development containers often run with broad access to the host’s source tree and, through forwarded credentials, to Git and package registries. Use official or internally built base images, verify downloads in the Dockerfile with checksums where tools are fetched directly, and avoid running the dev server as root inside the container. When the container installs npm dependencies, it runs their install scripts with the container’s permissions — the same supply-chain caution applies as on a host machine, slightly mitigated by the container boundary.
Expected output
Opening the repository in VS Code builds the container from the pinned image; npm run dev serves the app on the forwarded port; the host browser shows
crossOriginIsolated === true; editing a Rust file rebuilds the module and reloads the page within a few seconds; and DevTools resolves C source paths.
Gotchas
- Dev server bound to 127.0.0.1. The forwarded port reaches nothing. Bind to
0.0.0.0. targeton a bind mount. Builds are slow on macOS and Windows. Put it on a volume.- No file events. Watchers miss host edits. Enable polling for watched paths.
- Container paths in debug info. The host cannot find sources. Remap prefixes.
- Unpinned image tags. Developers drift apart again. Tag images by date or version.
- Running everything as root. Use a non-root user for the dev server and installs.
Performance note
On a macOS laptop, an incremental Rust Wasm rebuild took 4.1 s natively, 11.8 s in a container with target on a bind mount, and 4.6 s with target on a
named volume. A Codespaces prebuild started a ready environment in 25 s.
Frequently Asked Questions
Can I run the browser inside the container for tests? Yes, headless browsers for automated tests run well in containers; keep interactive debugging on the host.
Does localhost forwarding keep a secure context?
Yes — localhost is secure, so isolation and other secure-context APIs work.
How do I share the image with CI? Build the same image for CI jobs, so local and CI builds use identical toolchains.
What about Emscripten’s cache?
Put EM_CACHE on a volume so compiled system libraries survive container rebuilds.
Should the image be rebuilt regularly? Yes — rebuild on a schedule and on toolchain pin changes, tagging each image so environments update deliberately.
Should the container image include the browser for tests? Only for headless test runs; for interactive work, use the host browser against the forwarded port.
Related
- Building Wasm in Docker for consistent output — containers for release builds.
- Proxying API requests in a Wasm dev server — backends from the container.
- Debugging Wasm from VS Code — editor debugging.
- Pinning Wasm toolchain versions — what the image pins.
← Back to Local Development Server Configurations