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.

§ 01

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.

§ 02

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/
§ 03

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"]
§ 04

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.exe
not 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"]
§ 05

Checking the result

Compare what each approach actually shipped.

$ docker images hello
REPOSITORY TAG SIZE
hello debian 118MB
hello alpine 24MB
hello 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.