# ═══════════════════════════════════════════════════════════════ # Sublarr — Multi-Stage Docker Build # Stage 1: Build React Frontend (native platform for speed) # Stage 2: Python Backend + Frontend Bundle # ═══════════════════════════════════════════════════════════════ # Validate backend sources before they can enter any runtime image layer. # This runs on the build host's architecture, including multi-arch builds. FROM --platform=$BUILDPLATFORM python:3.12-slim AS backend-source WORKDIR /source COPY backend/ . COPY docker/verify-source.py /verify-source.py RUN python /verify-source.py /source # Stage 1: Build React Frontend FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend WORKDIR /build COPY frontend/package*.json ./ RUN npm install --legacy-peer-deps COPY frontend/ . # Pre-compress the bundle (brotli+gzip) after the vite build so the backend can # serve .br/.gz with Content-Encoding — big first-load win on low-power hosts. # (Kept as vite build + compress rather than `npm run build` to skip the tsc # type-check in the image; typecheck runs in CI/pre-deploy instead.) RUN npx vite build && node scripts/compress-dist.mjs # Stage 2: Python Backend + Frontend Bundle FROM python:3.12-slim # Pass from build: docker build --build-arg VERSION=$(cat backend/VERSION) ... # Or use scripts/docker-build.sh to get version suggestions and build. ARG VERSION= LABEL org.opencontainers.image.title="Sublarr" LABEL org.opencontainers.image.description="Standalone Subtitle Manager & Translator for Anime/Media" LABEL org.opencontainers.image.source="https://github.com/Abrechen2/sublarr" LABEL org.opencontainers.image.licenses="GPL-3.0" LABEL org.opencontainers.image.version="${VERSION}" # Also expose it to the running app (backend/version.py) so an RC-tagged # build (e.g. "1.6.5-rc.2") reports its actual tag instead of the plain # backend/VERSION content, which never carries the -rc.N suffix. ENV SUBLARR_VERSION="${VERSION}" # The repo default is the relative "log/sublarr.log", which is right for a # source checkout and wrong for a container: it resolves under WORKDIR, so the # log lives inside the container layer and is gone on the next recreate — and # both the Logs page and the support export then read a file that resets on # every update. /config is the persistent volume, same as the database default. ENV SUBLARR_LOG_FILE="/config/sublarr.log" # Install system dependencies # PostgreSQL clients 15, 16 and 17 from the PostgreSQL project's own archive # (PGDG): database_backup_postgres picks pg_dump/pg_restore matching the # server's major version. Debian's single default client was 17, and a backup # it wrote of a PostgreSQL 16 server could not be restored into that server # (SET transaction_timeout) — owner decision 2026-09-16 after the rc.4 VM test. # tesseract-ocr for OCR functionality, hunspell for spell checking RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates postgresql-common && \ /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y && \ apt-get install -y --no-install-recommends \ ffmpeg \ mkvtoolnix \ curl \ gosu \ unrar-free \ postgresql-client-15 \ postgresql-client-16 \ postgresql-client-17 \ tesseract-ocr \ tesseract-ocr-deu \ tesseract-ocr-eng \ hunspell \ hunspell-de-de \ hunspell-en-us \ libenchant-2-2 \ fonts-liberation && \ rm -rf /var/lib/apt/lists/* WORKDIR /app # Install Python dependencies # Includes psycopg2-binary (PostgreSQL) and redis/rq (Redis) for optional backends # ffsubsync pulls webrtcvad which has no prebuilt wheels → gcc + python3-dev # required during install. Build tools are purged afterwards to keep the # runtime image slim. COPY backend/requirements.txt . RUN apt-get update && \ apt-get install -y --no-install-recommends gcc g++ python3-dev && \ pip install --no-cache-dir -r requirements.txt && \ apt-get purge -y --auto-remove gcc g++ python3-dev && \ rm -rf /var/lib/apt/lists/* # Copy backend COPY --from=backend-source /source/ . # Copy built frontend COPY --from=frontend /build/dist ./static # Provide fallback font for libass-wasm subtitle renderer. # Liberation Sans (metric-compatible Arial replacement, SIL OFL licence) is # installed via fonts-liberation above. The worker loads /default.woff2 at # startup; a 404 here crashes the worker before any subtitle renders. RUN cp /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf ./static/default.woff2 # Create non-root user with configurable UID/GID ARG PUID=1000 ARG PGID=1000 RUN groupadd -g ${PGID} sublarr && \ useradd -u ${PUID} -g ${PGID} -m -s /bin/bash sublarr # Create config and backups directory RUN mkdir -p /config/backups && \ chown -R sublarr:sublarr /app /config # Runtime entrypoint: remaps the sublarr user to PUID/PGID, fixes /config # ownership on the bind-mounted volume, then drops privileges via gosu. # Runs as root so it can chown /config and remap the user; gunicorn itself # runs unprivileged as the requested PUID/PGID. COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \ chmod +x /usr/local/bin/docker-entrypoint.sh # NOTE: container starts as root so the entrypoint can chown /config and # remap the user. The application process is dropped to PUID:PGID via gosu. USER root EXPOSE 5765 VOLUME ["/config", "/media"] HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:5765/api/v1/health || exit 1 STOPSIGNAL SIGTERM ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] # --timeout is gunicorn's worker LIVENESS ceiling, not a per-request limit. The # APScheduler ticks run inside this worker, and the longest of them (`cleanup`, # JobSpec.timeout_s=7200) walks the whole media library — on a ~750-season # library that is routinely 18-51 min. At the old 300s the arbiter SIGKILLed the # worker mid-tick (prod, 2026-08-04 03:56, 11 min into the cleanup run): the tick # died before _tick_wrapper could write its scheduler_job_runs row, so the run # left no history entry, no timeout row and no traceback -- it simply vanished. # Keep this >= the largest JobSpec.timeout_s so the app's own timeout fires # first and gets recorded properly. # # --access-logfile - writes one line per request to stdout (hence into # `docker logs`). Without it gunicorn logs nothing per request, so a clean 4xx # left no trace anywhere and a whole probe campaign was invisible from the # inside (found 2026-09-03). The paired in-app metric (HTTP_REQUEST_TOTAL, wired # in app.py) gives the aggregate; this gives the individual line with its status. # # --access-logformat replaces %(r)s (the full request line) with %(m)s %(U)s: # method and URL PATH ONLY. The default format logs the query string, and the # API key may travel as ?apikey= (auth.py) — so the default would write the # key in clear text to every log line and every support bundle. %(U)s drops # the query; the path alone is what a request trace needs. # Each Socket.IO long poll holds a gthread for up to 25 seconds. Four threads # allow four browser connections to starve every API request, even /health. # Use the threaded Flask-SocketIO deployment recommendation (100 threads). # Keep one process: the scheduler and Socket.IO session state are process-local. CMD ["gunicorn", "--bind", "0.0.0.0:5765", "--worker-class", "gthread", "--workers", "1", "--threads", "100", "--timeout", "7200", "--graceful-timeout", "15", "--access-logfile", "-", "--access-logformat", "%(h)s %(t)s \"%(m)s %(U)s\" %(s)s %(b)s %(D)sus", "app:create_app()"]