diff --git a/Dockerfile b/Dockerfile index 5775471..9a7f1c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,27 @@ +# 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:20-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 -WORKDIR /fe -COPY frontend/package.json ./ -RUN npm install -COPY frontend/ ./ -COPY VERSION ./.version # 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" \ @@ -23,17 +30,21 @@ RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \ # Stage 2: backend runtime, serving the built SPA FROM python:3.12-slim -ARG GIT_SHA -ARG BUILD_DATE ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - GIT_SHA=$GIT_SHA \ - BUILD_DATE=$BUILD_DATE + 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. 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. @@ -47,21 +58,20 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY backend/requirements.txt . -RUN pip install --upgrade pip && pip install -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --upgrade pip && pip install -r requirements.txt -COPY backend/ . +COPY --chown=appuser backend/ . # Read at runtime by config.py as the app_version (a committed single source of truth). -COPY VERSION ./VERSION -COPY --from=frontend /fe/dist ./app/static_spa +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 -# Create the download-center mount point owned by the app user, so a fresh named volume mounted -# at /downloads inherits appuser ownership (Docker copies the image dir's ownership into a new -# empty volume) — the worker/API can then write there 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 -R appuser /app /downloads USER appuser - EXPOSE 8000 CMD ["sh", "entrypoint.sh"]