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=1 is 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.

Host-installed tools versus a pinned build image Tools installed on each machine drift independently, so output differs between developers and CI. A pinned image gives every build the same rustc, wasm-bindgen, Binaryen and emsdk, so the output is the same everywhere. tools installed per machine rustc updated by whoever ran rustup last wasm-opt from a distro package, often old wasm-bindgen-cli installed months ago CI image updated by the provider same commit, different modules pinned build image every tool version written in one Dockerfile image tag referenced by CI and the Makefile upgrade is a reviewed change to one file laptop and CI run the same binaries same commit, same module

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.

What lives where in a containerised build The image holds the tools and never changes between builds. Named volumes hold the cargo registry and the target directory so they survive container exits. The bind-mounted source is the only thing that changes per build, and the output lands back on the host. image layer (read-only) rustc, wasm32 targets, wasm-bindgen, wasm-opt, wasm-pack — pinned named volume cargo registry: crates downloaded once, reused by every run bind mount target-docker/ compiled dependencies: makes the second build incremental bind mount source your code, edited on the host output pkg/ written back to the host with your user id

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 sure CARGO_HOME points 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_64 and runs under emulation. Build a multi-arch image with docker 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.

Release build time on an ARM laptop, by image architecture Cold and warm release builds of the same crate in an emulated amd64 image and a native arm64 image. The Wasm output was byte-identical in all four cases; only the build time differed. wall-clock seconds, lower is better amd64 image, cold 552 s amd64 image, warm 108 s arm64 image, cold 160 s arm64 image, warm 31 s WebAssembly output does not depend on the build machine's architecture, so a native image costs nothing in consistency.

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.

← Back to Cross-Platform Build Automation