# Release image of the Ferrofin server: # - the ferrofin-server binary (built from this workspace), # - ffmpeg/ffprobe (scan probes with ffprobe, transcode needs ffmpeg), # - jellyfin-web (served at /web). # # A plain `docker build .` is fully self-contained: the `web-build` and # `runtime-build` stages below build jellyfin-web and the ffmpeg runtime from # public sources. CI keeps its fast path by overriding WEB_IMAGE/RUNTIME_IMAGE # with prebuilt base images (ci/web.Dockerfile, ci/runtime.Dockerfile — same # contents, baked once); BuildKit then skips the unused local stages entirely. # NOTE: kaniko does NOT skip unused stages by default — the CI service-image # build MUST pass `--skip-unused-stages=true` or it rebuilds jellyfin-web from # source every release (see .gitlab-ci.yml). ARG WEB_IMAGE=web-build ARG RUNTIME_IMAGE=runtime-build # ── jellyfin-web, built from source (skipped when CI passes WEB_IMAGE) ── ARG JELLYFIN_WEB_VERSION=12.4 FROM node:24-trixie AS web-source ARG JELLYFIN_WEB_VERSION # webpack's production build is memory-hungry; give Node headroom. ENV NODE_OPTIONS=--max-old-space-size=4096 WORKDIR /web # jellyfin-web pulls a couple of deps from github.com archive URLs that ECONNRESET; # raise npm's fetch retries and retry `npm ci` so a transient blip doesn't fail the build. # # JELLYFIN_VERSION becomes the bundle's build version (__JF_BUILD_VERSION__), which # is also the buster of the query cache jellyfin-web saves in the browser: unset, it # is "Release" forever and a saved cache outlives every upgrade. Jellyfin's own # image sets it to the web version too, so the cache drops whenever the bundled # jellyfin-web changes (not on a Ferrofin release that keeps the same web version). # Keep this in sync with ci/web.Dockerfile. RUN git clone --depth 1 --branch "v${JELLYFIN_WEB_VERSION}" \ https://github.com/jellyfin/jellyfin-web.git . \ && npm config set fetch-retries 5 fetch-retry-mintimeout 20000 fetch-retry-maxtimeout 120000 \ && n=0; until npm ci --no-audit --no-fund; do \ n=$((n+1)); [ "$n" -ge 5 ] && echo "npm ci failed after $n attempts" && exit 1; \ echo "npm ci failed, retry $n/5 after 15s"; sleep 15; \ done \ && JELLYFIN_VERSION="${JELLYFIN_WEB_VERSION}" npm run build:production FROM scratch AS web-build COPY --from=web-source /web/dist /dist # ── ffmpeg runtime base (skipped when CI passes RUNTIME_IMAGE) ────────── # jellyfin-ffmpeg over Debian's ffmpeg: SIMD single-pass tonemapping and a # current libx264 — the difference on 4K HDR transcode start times. It is also # built --enable-chromaprint, which is what the intro skipper fingerprints # with; libchromaprint-tools would add an unused fpcalc fallback and Debian's # separate FFmpeg libraries. Keep this runtime in sync with ci/runtime.Dockerfile. # curl and gnupg are only needed to import the repository key; purge them and # their unused dependencies in the installation layer. FROM debian:trixie-slim AS runtime-build RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ && curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \ | gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \ && echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian trixie main" \ > /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ && apt-get install -y --no-install-recommends jellyfin-ffmpeg8 \ && ln -s /usr/lib/jellyfin-ffmpeg/ffmpeg /usr/local/bin/ffmpeg \ && ln -s /usr/lib/jellyfin-ffmpeg/ffprobe /usr/local/bin/ffprobe \ && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false curl gnupg \ && rm -rf /var/lib/apt/lists/* # ── prebuilt-or-local indirection ─────────────────────────────────────── FROM ${WEB_IMAGE} AS web # ── server binary ─────────────────────────────────────────────────────── FROM rust:1.99.1-trixie AS build WORKDIR /src COPY . . # .git is excluded from the build context, so bake the release version in from # the same SERVICE_VERSION build-arg CI already passes (empty for a local build, # which ferrofin-health's build.rs treats as unset → crate version fallback). ARG SERVICE_VERSION= ENV FERROFIN_GIT_DESCRIBE=${SERVICE_VERSION} # Strip the container binary while leaving local profiling builds unchanged. RUN CARGO_PROFILE_RELEASE_STRIP=symbols cargo build --locked --release -p ferrofin-server # ── runtime ───────────────────────────────────────────────────────────── FROM ${RUNTIME_IMAGE} COPY --from=build /src/target/release/ferrofin-server /usr/local/bin/ferrofin-server COPY --from=web /dist /usr/share/ferrofin/web # The release version, passed from CI (--build-arg SERVICE_VERSION=). The # binary reports it (falling back to the crate version when unset for local builds). ARG SERVICE_VERSION= ENV SERVICE_VERSION=$SERVICE_VERSION # FERROFIN_WEB_DIR lives OUTSIDE /data on purpose: /data is a mounted volume that # would shadow anything baked under it. ENV FERROFIN_DATA_DIR=/data FERROFIN_BIND_ADDR=0.0.0.0 FERROFIN_PORT=8096 \ FERROFIN_WEB_DIR=/usr/share/ferrofin/web # Run as a fixed non-root UID (1000, the conventional first-user id media # containers standardize on). /data is chowned BEFORE the VOLUME declaration so # anonymous volumes inherit the ownership; bind mounts are the operator's job — # chown them to 1000:1000 or override with `docker run --user`. RUN useradd --uid 1000 --user-group --home-dir /data --no-create-home \ --shell /usr/sbin/nologin ferrofin \ && mkdir -p /data && chown ferrofin:ferrofin /data USER ferrofin # The commit the image was built from, for gates/run to check before it measures anything: # docker build --build-arg SOURCE_COMMIT="$(git rev-parse HEAD)" -t . ARG SOURCE_COMMIT= LABEL org.opencontainers.image.revision=$SOURCE_COMMIT VOLUME /data EXPOSE 8096 8920 7359/udp ENTRYPOINT ["ferrofin-server"]