Dockerfile for a Caption Validator

The failure this page prevents is “it validated green locally but the CI runner passed a file the gate should have blocked” — a divergence caused by a different pycaption build, a different ffprobe, or a different Python patch level between the workstation and the runner. When a caption QC gate makes a ship-or-block decision under FCC 47 CFR § 79.1, that decision must be byte-reproducible: identical caption bytes in, identical verdict out, on every machine. A container with a pinned base image, hash-pinned wheels, and a fixed ffprobe is how you get there. The Dockerfile below builds that image in two stages so the compiler never reaches the runtime layer, runs the gate as an unprivileged user, and exposes a single stdin ENTRYPOINT that returns the same 0/1/2 exit contract the parent CI/CD gating for caption builds step depends on.

# syntax=docker/dockerfile:1.7
# ---- Stage 1: build a self-contained venv from hash-pinned wheels only ----
FROM python:3.12-slim AS builder

ENV PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /build
COPY requirements.lock .
# --require-hashes refuses any wheel whose sha256 is absent from the lock;
# --only-binary=:all: forbids source builds, so the image is reproducible
RUN python -m venv /opt/venv \
 && /opt/venv/bin/pip install --require-hashes --only-binary=:all: -r requirements.lock

# ---- Stage 2: minimal runtime — venv + ffprobe + non-root, nothing else ----
FROM python:3.12-slim AS runtime

# ffmpeg supplies ffprobe for the drift check; --no-install-recommends stays lean
RUN apt-get update \
 && apt-get install -y --no-install-recommends ffmpeg \
 && rm -rf /var/lib/apt/lists/*

# copy the already-built venv; no compiler or build headers reach the final image
COPY --from=builder /opt/venv /opt/venv
COPY caption_gate.py /app/caption_gate.py

# run as an unprivileged user; a fixed UID stays stable across rebuilds
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin qc
USER qc

ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1
WORKDIR /app

# stdin -> gate -> exit 0 pass / 1 compliance fail / 2 misuse
ENTRYPOINT ["python", "/app/caption_gate.py"]

The entrypoint is a thin stdin adapter over the compliance checks — it reads the artifact, tolerates a BOM, and translates the result into the exit contract:

# caption_gate.py — container entrypoint; reads a caption artifact on stdin
import sys

def main() -> int:
    fmt = sys.argv[1] if len(sys.argv) > 1 else "vtt"
    payload = sys.stdin.buffer.read().decode("utf-8-sig")   # consume a UTF-8 BOM
    if not payload.strip():
        print("empty input: nothing to validate", file=sys.stderr)
        return 2                                # misuse -> route to engineering
    from validator import run_gate              # your FCC/CEA-608 checks
    violations = run_gate(payload, fmt)
    for v in violations:
        print(v, file=sys.stderr)
    return 1 if violations else 0               # non-zero blocks the build

if __name__ == "__main__":
    sys.exit(main())

Build and run it identically everywhere — the same image digest on a laptop and a runner produces the same verdict:

docker buildx build --platform linux/amd64 -t caption-validator:1.0 .
docker run --rm -i caption-validator:1.0 vtt < captions/program_001.vtt

Walkthrough

Multi-stage build. Stage one (builder) creates a virtualenv and installs the locked dependencies; stage two (runtime) copies only /opt/venv. Compilers, headers, and pip’s build machinery live only in the discarded first stage, so the shipped image carries the smallest possible attack and byte surface. This is the single biggest lever on both image size and reproducibility.

Layer caching. requirements.lock is copied and installed before the application code, so Docker reuses the cached dependency layer on every build where the lock is unchanged — only the fast COPY caption_gate.py layer rebuilds. Copying source first would invalidate the expensive install layer on every code edit.

Pinning. --require-hashes makes pip reject any wheel whose SHA-256 is not in requirements.lock, and --only-binary=:all: forbids source compilation, so a supply-chain substitution or a transient sdist cannot change what the gate runs. Pin the base image itself by digest (python:3.12-slim@sha256:…) in production so even the base cannot drift.

Non-root. The gate never needs to write outside /app, so it runs as UID 10001 with a nologin shell. A container that reads untrusted caption files from many vendors should not do so as root — a parser exploit then lands with no privileges to escalate.

--platform. CI runners are amd64 but many workstations are arm64; docker buildx build --platform builds the architecture the runner will use, so a locally built arm64 image cannot mask an amd64-only wheel problem. Build (or at least smoke-test) the platform CI runs.

Why stdin, not a mounted path. The entrypoint reads the artifact from sys.stdin.buffer rather than a file path so the container never needs a bind mount, a copied-in working directory, or write access to the host — the CI job simply pipes bytes in with docker run --rm -i … < artifact and reads the exit code out. That keeps the container stateless and read-only-friendly: nothing about the verdict depends on the filesystem layout, which is what lets the same image digest produce the same result whether it runs from a developer’s shell or a locked-down runner. It also composes cleanly with the streaming, memory-bounded parser the gate uses internally, so a feature-length package flows through without ever being materialized on the container’s disk.

Threshold reference table

Target Value Rationale
Base image python:3.12-slim, digest-pinned Reproducible, minimal footprint
Final image size < 400 MB (< 200 MB with static ffprobe) slim base + apt ffmpeg pulls codec libs
Wheel install --require-hashes --only-binary=:all: SHA-256-pinned, no source builds
Runtime user non-root, UID 10001 Least privilege for untrusted input
ffprobe source ffmpeg apt (or static binary) Reference PTS for drift checks
Exit contract 0 pass · 1 fail · 2 misuse Matches the CI gate contract
Build platform linux/amd64 (match runner) Avoid arch-masked wheel gaps

Edge cases & known gotchas

  • apt ffmpeg vs static ffprobe: the Debian ffmpeg package pulls a large tree of codec libraries the gate never uses. If you only need ffprobe for PTS, copy a single statically linked ffprobe binary into the image instead and drop the apt step — that is what takes the image under 200 MB.
  • Wheel vs source build: --only-binary=:all: fails loudly if a pinned dependency has no wheel for the target platform (common for arm64 C-extension packages). Prefer packages that publish manylinux/aarch64 wheels, or build that one dependency in the builder stage with its toolchain, never in runtime.
  • amd64 vs arm64: microsecond rounding and codec builds can differ subtly across architectures. Pick one architecture for the authoritative gate (the runner’s, usually amd64) and treat other-arch local runs as convenience, not as the compliance verdict.
  • Reproducible builds: apt versions float unless pinned; for byte-identical images pin the exact ffmpeg=<version> and pull from a snapshot.debian.org timestamped mirror, and set SOURCE_DATE_EPOCH so file timestamps do not vary between builds.
  • BOM and encoding on stdin: decoding with plain utf-8 leaves a leading BOM in the first cue; utf-8-sig (as in the entrypoint) consumes it so a signed file does not skew the first timestamp.

Integration hook

This container is the packaged form of the runner-side gate in CI/CD gating for caption builds: the CI job pipes the caption artifact into docker run --rm -i over stdin and lets the container’s exit code gate the merge, exactly as the workflow in integrating caption QC into GitHub Actions CI does with a bare script — but now byte-identical between local and CI because the whole toolchain is pinned in the image. Populate requirements.lock and the gate’s own tests with the reusable pytest fixtures for drift thresholds so the image you ship has been exercised against every framerate boundary before it ever gates a build.

Part of: Automated QC Validation & Reporting — the deterministic caption QC and reporting reference.