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. The build stage itself is just dune build --profile release - see Introduction to Dune for what that flag actually changes.