← Back to BlogDEAuf Deutsch lesen

A Practical Docker Cache Pattern for Dune Package Management

This is how I personally handle caching the OCaml compiler and Dune Package Management builds in Docker. It is not a recommendation from the Dune team. I would love feedback or to hear how others handle this.

You can check out the example repo at sabine/dune-pkg-cacheable-docker-build.

Goal: avoid rebuilding the OCaml compiler on every source change.

The problem

When you use Dune Package Management (dune pkg + dune.lock), Dune installs the OCaml compiler and all dependencies during the build. In Docker, that work is expensive and easy to invalidate if you copy your entire repo too early. In CI that often means rebuilding the compiler on every code change.

The fix is simple: put the compiler + package build into its own Docker layer that only depends on dune-project and dune.lock.

The cacheable pattern (two-step build)

This is the pattern I use. The key idea is to use dune build @pkg-install, which builds all the dependencies from your lockfile. That lets Docker cache the expensive compiler + dependency layer.

Unoptimized Dockerfile (everything invalidates the compiler layer)

FROM debian:bookworm AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
  build-essential \
  ca-certificates \
  curl \
  git \
  m4 \
  pkg-config \
  unzip \
  xz-utils \
  && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://github.com/ocaml-dune/dune-bin-install/releases/download/v3/install.sh | sh -s -- 3.20.2 --install-root /usr --no-update-shell-config

WORKDIR /app
COPY . .

ENV TMPDIR=/app
RUN dune pkg lock
RUN dune build --profile=release

Optimized Dockerfile (cache compiler + packages)

FROM debian:bookworm AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
  build-essential \
  ca-certificates \
  curl \
  git \
  m4 \
  pkg-config \
  unzip \
  xz-utils \
  && rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://github.com/ocaml-dune/dune-bin-install/releases/download/v3/install.sh | sh -s -- 3.20.2 --install-root /usr --no-update-shell-config

WORKDIR /app

COPY dune-project dune-project
# If your dune-workspace affects dependencies (e.g. override pins), copy it here too
# COPY dune-workspace dune-workspace

ENV TMPDIR=/app
RUN dune pkg lock
RUN dune build @pkg-install

COPY . .
RUN dune build --profile=release

What this buys you: if only your application source changes, Docker can reuse the layer that compiled the OCaml compiler and your dependency set.

Minimal project setup

This approach works whether you check in dune.lock or generate it during the Docker build.

dune pkg lock

Then you can build locally or in CI with:

dune build --profile=release

Benchmark (local)

I put the benchmark repo here: sabine/dune-pkg-cacheable-docker-build

Average warm rebuild (3 runs, after cache warm-up):

  • Unoptimized: 118.593s
  • Optimized: 1.907s

Architecture:

  • OS: Linux pop-os 6.17.9-76061709-generic
  • CPU: AMD Ryzen 7 7840HS (16 threads)
  • Arch: x86_64

These runs are “warm rebuilds” after changing a single source file. The optimized Dockerfile keeps the compiler + packages cached and only rebuilds the app layer.

Update

Originally this post used a custom dummy target to trigger package builds. It turns out Dune has a built-in target for this: dune build @pkg-install. This is much cleaner and is now what I recommend. The example repo has been updated to use this approach.

Notes and caveats

  • If dune.lock changes, the cache invalidates (that is the point).
  • I initially used to check in dune.lock, but dropped it later to align with current Dune maintainer guidance.
  • If the base image or Dune version changes, the cache invalidates.
  • Keep your .dockerignore tight so small files do not churn the build context.

Closing

This is just how I currently handle Docker builds with Dune package management. If you do something better or simpler, I would love to hear it.