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.

§ 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.