Building Wasm in Docker for Consistent Output
This page answers one task: build a WebAssembly module inside a container so that every developer, every CI run and every release produce output from exactly the same toolchain — and do it without making local builds painfully slow.
Prerequisites
- [ ] Docker 24+ or Podman with BuildKit enabled (
DOCKER_BUILDKIT=1is the default in recent Docker). - [ ] A Rust or C/C++ project that builds to Wasm on at least one machine today.
- [ ] Known versions of the tools you rely on: rustc, wasm-bindgen, Binaryen, and emsdk if you use it.
Why a container solves a Wasm-specific problem
WebAssembly output is unusually sensitive to tool versions. A different wasm-opt release reorders functions
and changes the binary’s hash and size. A different wasm-bindgen changes the generated glue and can refuse to
run against a mismatched crate. A different emsdk changes the JavaScript runtime and the default flags. Two
developers on “the same” project but different machines regularly produce modules that differ by kilobytes and
behave subtly differently — which turns every size-regression check and every bug report into an argument
about whose build is right.
A container image freezes the whole set. Build once, tag it, and every build that uses the tag sees identical tools.
Step 1 — write the image
# tools/wasm-builder.Dockerfile
FROM rust:1.81-slim-bookworm
ARG BINDGEN_VERSION=0.2.93
ARG BINARYEN_VERSION=118
ARG WASM_PACK_VERSION=0.13.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add wasm32-unknown-unknown wasm32-wasip1
# prebuilt binaries: seconds, not minutes, and byte-identical between image builds
RUN curl -sSL "https://github.com/rustwasm/wasm-bindgen/releases/download/${BINDGEN_VERSION}/wasm-bindgen-${BINDGEN_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar xz --strip-components=1 -C /usr/local/bin \
&& curl -sSL "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz" \
| tar xz --strip-components=2 -C /usr/local/bin binaryen-version_${BINARYEN_VERSION}/bin \
&& curl -sSL "https://github.com/rustwasm/wasm-pack/releases/download/v${WASM_PACK_VERSION}/wasm-pack-v${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar xz --strip-components=1 -C /usr/local/bin
ENV CARGO_TERM_COLOR=always
WORKDIR /src
Build and tag it with a name that says what is inside:
docker build -f tools/wasm-builder.Dockerfile -t ghcr.io/acme/wasm-builder:rust1.81-bg0.2.93-b118 .
docker push ghcr.io/acme/wasm-builder:rust1.81-bg0.2.93-b118
For a C/C++ project, start from the official emscripten/emsdk:3.1.61 image instead and add Binaryen only if
you need a different version from the one emsdk bundles.
Step 2 — build with the source mounted and caches persisted
The slow part of a containerised Rust build is that a fresh container has an empty ~/.cargo and an empty
target/. Mount both so they persist between runs:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD":/src \
-v cargo-registry:/usr/local/cargo/registry \
-v "$PWD/target-docker":/src/target \
-e CARGO_HOME=/usr/local/cargo \
ghcr.io/acme/wasm-builder:rust1.81-bg0.2.93-b118 \
wasm-pack build --release --target web --out-dir pkg
The -u flag runs the build as your own user, so the files it writes into pkg/ are not owned by root on the
host. The separate target-docker directory keeps the container’s artifacts from mixing with a native
target/ built by your host toolchain; the two are built with different compilers and invalidate each other.
Step 3 — use the same image in CI
Point the CI job at the image directly so there is no second definition of the toolchain to keep in sync:
jobs:
wasm:
runs-on: ubuntu-latest
container:
image: ghcr.io/acme/wasm-builder:rust1.81-bg0.2.93-b118
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
target
key: wasm-docker-${{ hashFiles('Cargo.lock') }}-rust1.81-bg0.2.93-b118
- run: wasm-pack build --release --target web --out-dir pkg
- run: sha256sum pkg/*.wasm
Putting the image tag in the cache key means an image upgrade starts a fresh cache rather than restoring artifacts compiled by the previous compiler.
What the image should not contain
The temptation, once a build image exists, is to put everything in it: the browsers for testing, a Node
toolchain for the bundler, linting tools, the deploy CLI. Resist most of that. An image that does one job —
turn source into a .wasm file and its glue — stays small, rebuilds quickly when a compiler version changes,
and has an obvious owner. Browsers in particular are large, update constantly for security reasons, and have
nothing to do with what bytes the compiler produces; pinning them in the same image as the compiler couples
two upgrade schedules that should be independent.
A good test is whether a change to the image could change the module’s hash. Compiler, binding generator, optimizer and the Wasm targets pass that test and belong in the image. A test runner, a formatter or a deploy tool fails it and can live in a separate image or be installed by the job that needs it.
Secrets fail the test too, and for a stronger reason: a build image is pulled by every developer and every CI
run, often from a registry with broad read access. Registry credentials, signing keys and deploy tokens belong
in the CI job’s environment at runtime, never in a layer. If the build itself needs a private crate registry,
pass the token with a BuildKit secret mount during the image build, or as an environment variable at
docker run time, so it never lands in the image history.
Finally, keep the image boring about its base. A slim Debian or the official Rust image is a better foundation than a minimal Alpine image for Rust toolchains: musl-based images occasionally trip over prebuilt binaries linked against glibc, and the size saving is irrelevant next to the toolchain itself.
Step 4 — wrap it so nobody types the docker command
IMAGE := ghcr.io/acme/wasm-builder:rust1.81-bg0.2.93-b118
DOCKER_RUN := docker run --rm -u $$(id -u):$$(id -g) -v $$PWD:/src \
-v cargo-registry:/usr/local/cargo/registry -v $$PWD/target-docker:/src/target \
-e CARGO_HOME=/usr/local/cargo $(IMAGE)
wasm:
$(DOCKER_RUN) wasm-pack build --release --target web --out-dir pkg
wasm-native: # fast local iteration with host tools; not for releases
wasm-pack build --dev --target web --out-dir pkg
Keep a native target for day-to-day iteration if your team prefers it, but make releases and size checks go through the image. The justfile approach does the same with nicer cross-platform quoting.
Document the two targets in the repository README in one sentence each. New contributors otherwise discover the difference the hard way — by opening a size-regression failure produced by their native build and comparing it with a number CI produced in the container.
Expected output
Two builds of the same commit — one on a developer laptop, one in CI — should produce identical hashes:
$ sha256sum pkg/app_bg.wasm # laptop, via make wasm
4f1c9a0e7b3d2c51... pkg/app_bg.wasm
$ sha256sum pkg/app_bg.wasm # CI log
4f1c9a0e7b3d2c51... pkg/app_bg.wasm
If they differ, something outside the image is leaking in; the reproducible binaries page covers the usual suspects — embedded paths and build-script timestamps.
Gotchas
- Files in
pkg/owned by root. The container ran as root. Pass-u "$(id -u):$(id -g)"and make sureCARGO_HOMEpoints at a writable volume. - The second build is as slow as the first.
target/was not mounted, or it was mounted at a path cargo does not use. Check that the mount target matches/src/target. - Apple Silicon builds are slow. The image is
x86_64and runs under emulation. Build a multi-arch image withdocker buildx build --platform linux/amd64,linux/arm64— the Wasm output is architecture-independent, so an arm64 image produces the same module. - Host and container builds invalidate each other. They share
target/. Use a separate directory for the container.
Performance note
On an M2 laptop, an emulated amd64 image took 9m 12s for a cold release build and 1m 48s warm; the native
arm64 variant of the same image took 2m 40s cold and 31s warm, producing a byte-identical module. Multi-arch
images are worth the extra build setup the moment anyone on the team uses an ARM machine.
Frequently Asked Questions
Does Docker add overhead to the build itself?
On Linux, negligible — the compiler runs natively in the container. On macOS and Windows the file-system
bridge for bind mounts is the slow part; keeping target/ in a named volume rather than a bind mount helps.
Is Podman a drop-in replacement here?
Yes. The commands work unchanged with podman, and rootless Podman maps your user automatically, which makes
the -u flag unnecessary. Named volumes and BuildKit-style caches behave the same way.
Should the image contain the source code? No. Keep the image to tools only, mount the source, and the same image builds every branch and every project that shares the toolchain.
Can I use the same image for Emscripten and Rust? You can, but two images are usually easier: the official emsdk image is large and has its own update cadence, and most projects use one toolchain or the other. Combine them only when one module links both.
How do I upgrade a tool? Change the version argument, build and push a new tag, and update the tag in one place. The diff that does it is the record of when and why the toolchain changed.
Related
- Pinning Wasm toolchain versions — the version list this image encodes.
- Caching Rust Wasm builds in GitHub Actions — the non-container alternative.
- Setting up CI/CD for Rust Wasm projects — the workflow this image slots into.
- Building C code with the WASI SDK — another toolchain that benefits from pinning in an image.
← Back to Cross-Platform Build Automation