blog :: Post "dockerizing-ocaml-binaries"
Dockerizing OCaml Binaries
2026-07-26 · 5 min
The OCaml toolchain (opam, dune, a compiler) has no business shipping inside the image that actually runs in production. This is the path from "it builds on my machine" to a small image with just the binary in it.
A naive image, and why it's wrong
The obvious first attempt: build and run in the same, single-stage image.
$ FROM ocaml/opam:debian-12-ocaml-5.2$ WORKDIR /home/opam/app$ COPY --chown=opam:opam . .$ RUN opam install -y --deps-only .$ RUN opam exec -- dune build --profile release ./bin/main.exe$ ENTRYPOINT ["_build/default/bin/main.exe"]
This works, but the shipped image now carries opam, the OCaml compiler, every build dependency, and the source tree - typically well over a gigabyte for something that might compile down to a few megabytes.
Multi-stage: keep the builder, discard everything but the binary
Build in one stage, copy only the resulting binary into a clean base for the final image.
$ FROM ocaml/opam:debian-12-ocaml-5.2 AS builder$ WORKDIR /home/opam/app$ COPY --chown=opam:opam . .$ RUN opam install -y --deps-only .$ RUN opam exec -- dune build --profile release ./bin/main.exe$$ FROM debian:12-slim$ COPY --from=builder \$ /home/opam/app/_build/default/bin/main.exe /usr/local/bin/hello$ ENTRYPOINT ["/usr/local/bin/hello"]
Build and run it.
$ docker build -t hello .$ docker run --rm hello
Keep the build context small, so COPY . . doesn't drag in build artifacts from the host.
.dockerignore$ _build/$ .git/$ *.install$ _opam/
Going smaller: Alpine and musl
debian:12-slim is already small, but it still carries glibc, a shell, and package manager metadata. Alpine's musl-based images get you further, and OCaml's official images ship an Alpine variant for exactly this.
Same shape, Alpine base for both stages.
$ FROM ocaml/opam:alpine-3.19-ocaml-5.2 AS builder$ WORKDIR /home/opam/app$ COPY --chown=opam:opam . .$ RUN opam install -y --deps-only .$ RUN opam exec -- dune build --profile release ./bin/main.exe$$ FROM alpine:3.19$ COPY --from=builder \$ /home/opam/app/_build/default/bin/main.exe /usr/local/bin/hello$ ENTRYPOINT ["/usr/local/bin/hello"]
Static linking, and scratch
musl links statically far more easily than glibc does. If the binary has no runtime dependency left (no dynamic C libraries it needs at startup), the final stage can be scratch - an empty image with nothing in it but what you COPY in.
Ask the linker for a fully static binary.
$ (executable$ (name main)$ (ocamlopt_flags (:standard -ccopt -static)))
Check it actually came out static before trusting scratch with it.
$ ldd _build/default/bin/main.exenot a dynamic executable
With that confirmed, the final stage needs nothing but the binary itself.
$ FROM scratch$ COPY --from=builder \$ /home/opam/app/_build/default/bin/main.exe /hello$ ENTRYPOINT ["/hello"]
Checking the result
Compare what each approach actually shipped.
$ docker images helloREPOSITORY TAG SIZEhello debian 118MBhello alpine 24MBhello scratch 6.2MB
Everything after the first stage is optional polish - a multi-stage build with a slim base is already most of the win. Reach for Alpine and static linking when the image size or attack surface actually matters, not by default.