Dockerfile
Rama main · réplica de lectura del repositorio original.
# --- Build frontend
FROM node:26-alpine AS frontend-builder
WORKDIR /app
# corepack is no longer bundled with the Node image as of node:26 (it built
# and ran fine on node:24) — installing it explicitly via npm works
# regardless of whether the base image happens to ship it.
RUN npm install -g corepack && corepack enable && corepack prepare pnpm@11.5.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# This stage only ever runs `pnpm run build:web` below (a Vite build) — it
# never launches Electron itself, so the ~100MB Electron binary the
# `electron` package's own postinstall otherwise downloads from GitHub
# Releases is pure waste here: slower installs, and a real point of
# failure in build environments with restricted/unreliable egress to
# GitHub's CDN (seen as a "socket hang up" mid-download). Skipping it is
# safe precisely because this stage has no other use for it.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN pnpm install
# Only what `pnpm run build:web` actually touches — an unrelated repo change
# (e.g. in connect/) shouldn't bust this layer and trigger a needless rebuild.
COPY src ./src
COPY CHANGELOG.md web.vite.config.ts party.vite.config.ts vite.vuetify-deps.ts tsconfig.json tsconfig.app.json tsconfig.node.json ./
# The docs the in-app help bundles (src/renderer/src/services/help/docs.ts).
COPY docs/help ./docs/help
RUN pnpm run build:web
# The party guest page, which connect serves itself under /party/ (see
# routes/party.py) - built into connect/static/party/ and copied over below.
RUN pnpm run build:party
# --- Build minimal ffmpeg (audio-only, statically linked)
#
# A distribution ffmpeg brings ~130MB of codecs and libraries nothing here
# ever touches (AV1/H.264/H.265 encoders, Vulkan shader compilation,
# X11/Wayland/SDL, Blu-ray, webcam capture). Building only what connect
# actually reaches gets that to ~10MB with no runtime dependencies at all.
# Which formats that is, and why each one is on the list, is documented in
# build/ffmpeg/configure-flags — read that before changing a format
# anywhere in the app.
FROM alpine:3.24 AS ffmpeg-builder
RUN apk add --no-cache \
build-base \
coreutils \
curl \
lame-dev \
nasm \
openssl-dev \
openssl-libs-static \
tar \
xz \
zlib-dev \
zlib-static
WORKDIR /build
# libopus from source, because Alpine has no static build of it: `opus-dev`
# ships only libopus.so, there is no opus-static package, and this ffmpeg is
# linked fully static so a shared library is of no use. Five lines here beat
# the alternatives — a Debian builder would bring the static library as a
# package but produce a glibc binary whose getaddrinfo() needs dlopen'd NSS
# modules that a static link leaves out, i.e. an ffmpeg that cannot resolve
# a hostname, and ffmpeg's input here is http(s) URLs. Prebuilt images
# (mwader/static-ffmpeg, jrottenberg/ffmpeg) solve it too, at ~118MB
# compressed against the ~10MB this produces, and with every video codec
# back in.
#
# --prefix=/usr/local is enough for ffmpeg's configure to find it: Alpine's
# pkg-config already searches /usr/local/lib/pkgconfig first.
#
# Versions and configure flags come from build/ffmpeg/, shared with the
# desktop app's own build of the same thing (scripts/build-ffmpeg.sh) so the
# two can't drift apart — see build/ffmpeg/README.md.
COPY build/ffmpeg/versions build/ffmpeg/configure-flags /build/ffmpeg/
RUN . /build/ffmpeg/versions \
&& curl -fsSL -4 --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 10 \
-o opus.tar.gz "https://downloads.xiph.org/releases/opus/opus-$OPUS_VERSION.tar.gz" \
&& tar xf opus.tar.gz \
&& cd "opus-$OPUS_VERSION" \
&& ./configure --disable-shared --enable-static --disable-doc --disable-extra-programs \
--prefix=/usr/local \
&& make -j$(nproc) \
&& make install
# ffmpeg.org's own server is occasionally flaky under CI load — retry with
# backoff, and force IPv4 to sidestep the SSL handshake failures observed
# over broken IPv6 paths on some CI/hosting networks.
RUN . /build/ffmpeg/versions \
&& curl -fsSL -4 --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 10 \
-o ffmpeg.tar.xz "https://ffmpeg.org/releases/ffmpeg-$FFMPEG_VERSION.tar.xz" \
&& tar xf ffmpeg.tar.xz \
&& mv "ffmpeg-$FFMPEG_VERSION" ffmpeg-src
WORKDIR /build/ffmpeg-src
# Before the ten minutes of compiling: every name in the recipe has to be
# one this ffmpeg version knows, or configure drops it without a word (see
# scripts/check-ffmpeg-recipe.sh — this is the check that would have caught
# `--enable-parser=mp3`).
COPY scripts/check-ffmpeg-recipe.sh /build/
RUN sh /build/check-ffmpeg-recipe.sh /build/ffmpeg-src /build/ffmpeg/configure-flags
# What goes into this build and why is in build/ffmpeg/configure-flags,
# which the desktop app's own build reads too. Only the two flags that
# depend on *this* build being an Alpine/musl one are here: linking fully
# static (which is what makes the result a single file with no runtime
# dependencies to copy into the final stage) and telling pkg-config to
# report static link lines to match.
RUN ./configure \
$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' /build/ffmpeg/configure-flags) \
--extra-ldflags="-static" \
--pkg-config-flags="--static" \
&& make -j$(nproc) \
&& make install
# A build that succeeded is not yet a build that contains what connect asks
# for: configure warns about a name it doesn't know only when *nothing* else
# in the same comma list matched, so one wrong entry among a dozen right ones
# passes silently and surfaces as a track that won't play. Every such miss so
# far has been found in production. Copied after the build rather than
# alongside the flags so that editing the contract re-runs the check without
# rebuilding ffmpeg itself.
COPY build/ffmpeg/required-components /build/ffmpeg/
COPY scripts/verify-ffmpeg.sh /build/
RUN sh /build/verify-ffmpeg.sh /usr/local/bin/ffmpeg /build/ffmpeg/required-components
# --- Build Python venv
#
# `miniaudio` (a pyatv/AirPlay dependency) has no musllinux wheel for arm64 —
# only for x86_64, on every released version (checked directly against
# PyPI's file listing) — so `uv sync` must compile it from source on arm64,
# which needs a C++ compiler. Isolating that into its own stage means the
# compiler toolchain doesn't have to live in the final image either — only
# the resulting .venv is copied over.
#
# `pylibrb` (Rubber Band, the beatmix's time stretcher) has no musllinux
# wheel at all, so it is compiled here on amd64 too, against Alpine's own
# Rubber Band and fmt - which is why the final stage installs their runtime
# libraries.
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS python-builder
WORKDIR /app
RUN apk add --no-cache build-base cmake pkgconf rubberband-dev fmt-dev
COPY connect/pyproject.toml connect/uv.lock ./
# --no-dev: pyinstaller/pytest/ruff (see pyproject.toml's dev group) are
# build/test-only tooling with no purpose in a running container — skipping
# them keeps the venv copied into the final stage smaller.
RUN uv sync --locked --no-dev
# --- Final image
FROM ghcr.io/astral-sh/uv:python3.14-alpine
WORKDIR /app
# rubberband-libs and fmt: what the venv's pylibrb links against (see above).
RUN apk add --no-cache nginx gettext rubberband-libs fmt
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --chown=nginx:nginx --from=frontend-builder /app/out/web /usr/share/nginx/html
COPY --chown=nginx:nginx ./settings.js.template /etc/nginx/templates/settings.js.template
COPY --chown=nginx:nginx ng.conf.template /etc/nginx/templates/default.conf.template
COPY connect/pyproject.toml ./
COPY connect/uv.lock ./
COPY --from=python-builder /app/.venv /app/.venv
COPY connect/. .
# After the line above, so a build left over in a local checkout can't win.
COPY --from=frontend-builder /app/connect/static/party ./static/party
COPY start.sh /start.sh
RUN chmod +x /start.sh
ENV SERVER_INTERNAL_URL="" CONNECT_TOKEN="" CONNECT_URL=/api
ENV WEB_PORT=7070 PORT=7071
EXPOSE 7070
# Goes through nginx to /api/health, so it fails if either nginx or the
# Python backend is down/unresponsive. Uses $WEB_PORT (not the 7070 default
# baked into EXPOSE above, which is only documentation) so the healthcheck
# still hits the right port when it's overridden — see start.sh.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${WEB_PORT}/api/health" || exit 1
CMD ["/start.sh"]