# syntax=docker/dockerfile:1 # Build/version info. APP_VERSION comes from the committed VERSION file (always correct, # even for a plain `docker compose build`); GIT_SHA/BUILD_DATE are best-effort build-args # the deploy scripts pass. An explicit APP_VERSION build-arg still overrides the file. # # GIT_SHA/BUILD_DATE differ on EVERY build: declare them as late as possible in each stage — # anything below them can never cache. ARG APP_VERSION= ARG GIT_SHA=unknown ARG BUILD_DATE= # Stage 1: build the frontend SPA FROM node:22-alpine AS frontend WORKDIR /fe # Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules. COPY frontend/package.json frontend/package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY frontend/ ./ COPY VERSION ./.version # Version args land here — immediately before the only step that reads them — so they cannot # invalidate the dependency layers above. ARG APP_VERSION ARG GIT_SHA ARG BUILD_DATE # Vite inlines VITE_*-prefixed env at build time; version falls back to the VERSION file. RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \ VITE_GIT_SHA="$GIT_SHA" \ VITE_BUILD_DATE="$BUILD_DATE" \ npm run build # Stage 2: backend runtime, serving the built SPA FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app # Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing # `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned # so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's # ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind # mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md). RUN adduser --disabled-password --gecos "" appuser \ && mkdir -p /downloads \ && chown appuser /app /downloads # ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1). # intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but # no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264. # Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp # uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO # token, else only storyboards are offered). yt-dlp auto-detects deno on PATH. ARG DENO_VERSION=v2.9.1 RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \ && curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \ && unzip -o /tmp/deno.zip -d /usr/local/bin \ && chmod +x /usr/local/bin/deno \ && rm /tmp/deno.zip \ && rm -rf /var/lib/apt/lists/* # Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which # tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so # OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from # Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there # still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt # source is added only for this one install and removed after, so it can't drift the rest of the image. # Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused). RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \ && apt-get update \ && apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \ && rm /etc/apt/sources.list.d/bookworm.sources \ && rm -rf /var/lib/apt/lists/* # Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is # byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats # up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header). COPY backend/requirements.lock . RUN --mount=type=cache,target=/root/.cache/pip \ pip install --upgrade pip && pip install -r requirements.lock COPY --chown=appuser backend/ . # Read at runtime by config.py as the app_version (a committed single source of truth). COPY --chown=appuser VERSION ./VERSION COPY --chown=appuser --from=frontend /fe/dist ./app/static_spa # Version stamps LAST — keep them below everything expensive (see the header note). ARG GIT_SHA ARG BUILD_DATE ENV GIT_SHA=$GIT_SHA \ BUILD_DATE=$BUILD_DATE USER appuser EXPOSE 8000 CMD ["sh", "entrypoint.sh"]