darcsweb - Dockerfile

[root] / Dockerfile
# syntax=docker/dockerfile:1.7
# -- Build stage --
#
# The haskell and debian base images are multi-arch (amd64 + arm64), so this
# Dockerfile works natively on x86-64 as well as ARM64 hosts like the
# Raspberry Pi 5.  Building on the Pi itself is slow; see README.md for
# cross-build instructions.
#
# BuildKit cache mounts are used below so that apt packages, the opam
# switch (OCaml + Rocq), the stack resolver, and .stack-work survive
# between builds.  BuildKit is the default builder since Docker 23; older
# hosts need `DOCKER_BUILDKIT=1 docker build ...`.

FROM haskell:9.6-slim AS build

# TARGETARCH is populated by BuildKit from the target platform and is used
# below to namespace cache mounts so arm64 and amd64 builds do not share
# arch-specific binaries (notably the opam switch, which is native code).
ARG TARGETARCH

# Build parallelism.  Defaults to 2 because the Raspberry Pi 5 hangs under
# -j4 (GHC linking and the opam Rocq build exhaust RAM on a 4 GB Pi).
# Raise it on bigger hosts via `--build-arg JOBS=$(nproc)`.
ARG JOBS=2

# Install Rocq build dependencies.  /var/cache/apt and /var/lib/apt are
# cache-mounted so repeated builds do not redownload .deb files; the
# default docker-clean hook is removed so apt keeps its cache.
RUN --mount=type=cache,id=darcsweb-apt-cache-${TARGETARCH},target=/var/cache/apt,sharing=locked \
    --mount=type=cache,id=darcsweb-apt-lib-${TARGETARCH},target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && \
    apt-get install -y --no-install-recommends \
      opam make sed ca-certificates \
      libgmp-dev linux-libc-dev pkg-config

# Pin OCaml + Rocq.  /root/.opam is cache-mounted, so the multi-minute
# OCaml and Rocq compilation runs once, not on every rebuild.  The mount
# must be re-declared on every subsequent RUN that invokes `rocq` (the
# stack build triggers extraction through Setup.hs).  A stale cache can
# leave the switch half-built; verify the compiler runs with the pinned
# version and rebuild from scratch on mismatch.
RUN --mount=type=cache,id=darcsweb-opam-${TARGETARCH},target=/root/.opam,sharing=locked \
    if [ ! -f /root/.opam/config ]; then \
      opam init --disable-sandboxing --bare -y; \
    fi && \
    if ! opam exec --switch=default -- ocaml -version 2>/dev/null | grep -q '4\.14\.2'; then \
      opam switch remove default -y 2>/dev/null || true; \
      opam switch create default ocaml-base-compiler.4.14.2 -j"${JOBS}"; \
    fi && \
    eval "$(opam env --switch=default)" && \
    opam pin add rocq-core 9.0.0 -n && \
    opam pin add rocq-stdlib 9.0.0 -n && \
    opam install rocq-core.9.0.0 rocq-stdlib.9.0.0 -y -j"${JOBS}"

# Make rocq available to later RUN steps that mount /root/.opam.
ENV PATH=/root/.opam/default/bin:$PATH

WORKDIR /src

# Copy dependency manifests first so the snapshot/GHC layer is cached
# independent of source changes.
COPY stack.yaml stack.yaml.lock darcsweb.cabal Setup.hs LICENSE ./

# Stack's resolver, GHC tarball, and global package DB live in ~/.stack;
# cache-mount it so `stack setup` is a no-op on the second build.
RUN --mount=type=cache,id=darcsweb-stack-${TARGETARCH},target=/root/.stack,sharing=locked \
    stack setup --install-ghc --no-terminal

# Copy sources and build.  .stack-work is cache-mounted so Haskell
# modules are only recompiled when their sources actually change.
# gen/ is intentionally not copied: Setup.hs' preBuild hook runs
# `make -C verified extract`, which regenerates gen/*.hs from the
# Rocq sources in verified/ using the pinned Rocq 9.0.0 installed
# above.  Copying a host-side gen/ would either fail on fresh hosts
# (the directory is not tracked in darcs) or ship stale extractions.
COPY src/ src/
COPY app/ app/
COPY test/ test/
COPY verified/ verified/

RUN --mount=type=cache,id=darcsweb-opam-${TARGETARCH},target=/root/.opam,sharing=locked \
    --mount=type=cache,id=darcsweb-stack-${TARGETARCH},target=/root/.stack,sharing=locked \
    --mount=type=cache,id=darcsweb-stackwork-${TARGETARCH},target=/src/.stack-work,sharing=locked \
    stack build --no-terminal --copy-bins \
      --local-bin-path /usr/local/bin \
      -j"${JOBS}"

# -- Runtime stage --
FROM debian:bookworm-slim

ARG TARGETARCH

RUN --mount=type=cache,id=darcsweb-apt-cache-runtime-${TARGETARCH},target=/var/cache/apt,sharing=locked \
    --mount=type=cache,id=darcsweb-apt-lib-runtime-${TARGETARCH},target=/var/lib/apt,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && \
    apt-get install -y --no-install-recommends libgmp10 && \
    groupadd -r darcsweb && \
    useradd -r -g darcsweb -s /sbin/nologin darcsweb

COPY --from=build /usr/local/bin/darcsweb /usr/local/bin/darcsweb
COPY static/ /usr/share/darcsweb/static/

RUN printf '%s\n' \
      'bind = 0.0.0.0' \
      'port = 3000' \
      'repos = /srv/darcs' \
      'title = DarcsWeb' \
      'static = /usr/share/darcsweb/static' \
      > /etc/darcsweb.conf

USER darcsweb
EXPOSE 3000
ENTRYPOINT ["darcsweb", "-c", "/etc/darcsweb.conf"]