Multi-stage build

The Dockerfile has two stages: a builder stage with the full compiler toolchain and dev headers, and a runtime stage that only carries the final binary and shared libraries it needs. Final runtime image content size is ~455 MB (~135 MB without the baked-in models — see below).

Baking in models

Models are baked into the image (COPY models /app/models in the runtime stage) rather than mounted at deploy time — the goal is a single self-contained artifact you can push to any cloud host and start with no setup step beyond docker run. This trades image size (+~179 MB, the size of models/) for zero-friction deployment: no volume to wire up, no separate “copy models onto the host first” step in your deploy pipeline, no risk of a host running the image against stale or mismatched model files. Verified this actually works standalone: built the image, ran it with no volume mount at all, and confirmed GET /v1/ready200 and POST /v1/embed returns a real 512-d embedding — the container is genuinely self-contained.
Prefer a smaller, model-agnostic image — e.g. you want to swap models without a rebuild, or ship a minimal base image and layer models on separately per-deployment? Delete the COPY models /app/models line from the Dockerfile’s runtime stage and mount them at docker run time instead:
DETECTOR_MODEL_PATH/EMBEDDER_MODEL_PATH both point at /app/models/... either way (see Configuration) — nothing else about the image changes.

Build args

default:"1.19.2"
ONNX Runtime release version to download (prebuilt, not compiled).
default:"v1.9.7"
Git tag to build Drogon from.
default:"4.12.0"
Git tag to build OpenCV from. See below for why this can’t just be an apt-get install. Must stay ≥ 4.12.0 — 4.10.0/4.11.0 have CVE-2025-53644 (CVSS 9.8), confirmed reachable through this service’s upload endpoints. Do not downgrade this.

Why OpenCV is built from source

This is the single most important thing to understand about this Dockerfile, and it was not obvious until the image was actually built and run — the local macOS build (Homebrew’s OpenCV 5.0.0) gave no warning of this. Ubuntu 24.04’s apt-packaged OpenCV is version 4.6.0. The YuNet detector model this service uses (face_detection_yunet_2023mar.onnx) requires OpenCV ≥ 4.8.0 — this is a documented upstream incompatibility (opencv_zoo#172), not a bug in this codebase. Critically, the failure mode is a runtime crash during model warmup, not a build error:
A container built against libopencv-dev looks completely fine — it builds, it produces a binary, docker build exits 0 — and then crash-loops the instant it tries to actually run detection. The fix: the builder stage clones and compiles OpenCV 4.12.0 from source (-DBUILD_LIST=core,imgproc, imgcodecs,objdetect,dnn,calib3d, trimmed to just the modules this project uses to keep build time down), and the runtime stage copies the resulting .so files instead of apt-installing them.
Version pinned to 4.12.0, not 4.10.0 — see Security Report → Phase 5 for why 4.10.0/4.11.0 are unsafe for this service specifically (a confirmed-reachable CVSS 9.8 arbitrary-write vulnerability, triggerable through the public image-upload endpoints).
This adds real time to docker build — a full (if trimmed) OpenCV compile, even parallelized with -j$(nproc). That’s expected. Pinned to a stable 4.x release deliberately, not 5.x — OpenCV 5 reorganized headers in ways the local macOS build already needed a version-gated workaround for (see Architecture → FaceAligner); staying on 4.x in Docker avoids needing a second workaround path.

Other bugs the local build couldn’t have caught

Building and actually running the Docker image (not just compiling it) surfaced one more real bug: Silently-broken apt-get install. The runtime stage’s package list originally included libjsoncpp26 and libssl3 — neither exists on Ubuntu 24.04 (the real names are libjsoncpp25 and libssl3t64, part of the same 64-bit time_t transition that gives the OpenCV packages their 406t64 suffix). Because apt-get install fails atomically when any package in the list is missing, and the line originally had a || echo "NOTE: ..." fallback, the entire install silently failed — including the OpenCV packages, whose names were actually fine. The container built “successfully” with zero OpenCV libraries present, and crashed on start with error while loading shared libraries: libopencv_imgcodecs.so.406: cannot open shared object file. Fixed by correcting the package names and — more importantly — removing the || fallback entirely. A silently-broken runtime image (missing .so files that only surface as a crash on container start) is worse than a build that fails loudly at docker build time.
If a future Ubuntu point release renames these packages again, you’ll get a loud docker build failure instead of a silent runtime crash — that’s the intended failure mode. To find current names: apt list 2>/dev/null | grep -E 'libjsoncpp|libssl3|libuuid1' in a fresh ubuntu:24.04 container.

Validation performed

The image was built for linux/amd64 and run end-to-end (not just compiled) to catch exactly the class of bug described above:
  • docker build --platform linux/amd64 -t face-engine . — full build, both stages.
  • Container started with models mounted; GET /v1/ready200.
  • Ran the same same-person/different-person /v1/compare test cases used to validate the model (see Models) against the containerized service over real HTTP. Results matched the native macOS build almost exactly (e.g. me/me2: 0.6842 in Docker vs. 0.6834 native — trivial floating-point noise from platform/compiler differences, not a correctness issue).
  • Measured /v1/embed latency inside the container: p50 ≈ 1026 ms. This is not representative of real deployment performance — this particular test ran linux/amd64 under QEMU emulation on an Apple Silicon host. The native macOS p50 (~300 ms, see Models → Performance) is a better proxy for how a genuine x86_64 host will perform.

.dockerignore

Without this, docker build’s context upload was pulling in the local CMake build directory, the locally-built Drogon/OpenCV source trees (used for local macOS development — see Quickstart → Local build), the model weights themselves (174 MB+, mounted at runtime instead), and personal test photos — none of which the Dockerfile actually needs.

Not done yet

Intentionally, per the current plan:
  • Micro-batching for the embedder — biggest win on GPU, marginal on CPU. Skip until actually running on GPU.
  • Prometheus metrics endpointThreadPool::pendingJobs() is already exposed in code as a natural queue-depth metric to wire up when this happens.
  • Rate limiting — belongs at the load balancer/gateway, not baked into this service.
  • CUDA/TensorRT variant — same source, different base image plus EXECUTION_PROVIDER=cuda (see Configuration), once there’s a GPU to benchmark against.