# syntax=docker/dockerfile:1 # The headless-Linux deployment: one image holding the server, the built web UI and both Python # environments, CPU-only. Models are not baked in — they land in the /models volume the same lazy # way `pnpm run setup` leaves them out, so the image stays rebuildable without a 15 GB tax. # ---- Web bundle ------------------------------------------------------------- FROM node:22-bookworm AS web-build WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # patchedDependencies in package.json: pnpm hashes these before it installs anything. COPY patches patches/ COPY packages/server/package.json packages/server/ COPY packages/web/package.json packages/web/ COPY packages/desktop/package.json packages/desktop/ COPY e2e/package.json e2e/ RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile COPY tsconfig.base.json ./ # The web build type-checks against the server's router, imported straight from its source. COPY packages/server packages/server COPY packages/web packages/web RUN pnpm --filter @libratory/web build # ---- Runtime ---------------------------------------------------------------- FROM node:22-bookworm AS runtime # ffmpeg carries the built-in aac encoder lib/ffmpeg.ts falls back to; espeak-ng is the G2P floor # under Kokoro; poppler-utils is pdftotext and pdftoppm; tesseract-ocr reads scanned pages (osd # names the script); zip/unzip pack and unpack the read-along EPUBs. # chromium renders the PDF/EPUB document exports — lib/vivliostyle.ts prefers a system browser, # and the alternative is a 345 MB in-volume Chrome that would still be missing its shared libraries. RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg espeak-ng poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-osd zip unzip chromium \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # The same pinned, checksummed uv that scripts/setup.sh installs — read from the same pins.json, # so a version bump stays one edit. ARG TARGETARCH RUN --mount=type=bind,source=scripts/pins.json,target=/tmp/pins.json <<'SH' set -e case "$TARGETARCH" in amd64) KEY=linux-x64 ;; arm64) KEY=linux-arm64 ;; *) echo "No pinned uv build for $TARGETARCH" >&2; exit 1 ;; esac UV_VERSION="$(node -p 'require("/tmp/pins.json").uv.version')" UV_TARGET="$(node -p "require('/tmp/pins.json').uv['$KEY'].target")" UV_SHA="$(node -p "require('/tmp/pins.json').uv['$KEY'].sha256")" curl -fsSL --retry 3 -o /tmp/uv.tar.gz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_TARGET}.tar.gz" echo "$UV_SHA /tmp/uv.tar.gz" | sha256sum -c - >/dev/null tar -xzf /tmp/uv.tar.gz --strip-components=1 -C /usr/local/bin rm /tmp/uv.tar.gz SH # The Python environments live outside /app so a development bind-mount of the source cannot # shadow them. env.ts is told where via CONDA_ENV_PATH/POCKET_ENV_PATH/PIPER_ENV_PATH below. ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \ UV_PROJECT_ENVIRONMENT=/opt/venv COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv uv python install 3.12 && uv sync --frozen COPY scripts/requirements-pocket.txt scripts/requirements-pocket.txt # Pocket is CPU-only by design; --torch-backend keeps Linux from pulling the CUDA torch (setup.sh # does the same for the native path). RUN --mount=type=cache,target=/root/.cache/uv uv venv --python 3.12 /opt/venv-pocket \ && uv pip install --python /opt/venv-pocket/bin/python --torch-backend=cpu -r scripts/requirements-pocket.txt # Piper (Bulgarian, word timings): onnxruntime and numpy 2 kept out of the main env. BgTTS is not # built here — the picker says so — because its codec pulls ~1.5 GB for three voices. COPY scripts/requirements-piper.txt scripts/requirements-piper.txt RUN --mount=type=cache,target=/root/.cache/uv uv venv --python 3.12 /opt/venv-piper \ && uv pip install --python /opt/venv-piper/bin/python -r scripts/requirements-piper.txt RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches patches/ COPY packages/server/package.json packages/server/ COPY packages/web/package.json packages/web/ COPY packages/desktop/package.json packages/desktop/ COPY e2e/package.json e2e/ RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile COPY . . COPY --from=web-build /app/packages/web/dist packages/web/dist # /data is the library, /models holds every lazily-downloaded weight: the HF hub cache and the # platformdirs (surya) cache both point inside it, so "backup the volumes" is the whole story. # FONT_PATH moves marker's on-demand font out of site-packages, which is root-owned here and # rightly so — the server runs as node and the image stays immutable. It must be the full path: # marker precomputes FONT_PATH from FONT_DIR at import, so overriding the directory alone is inert. # LIBRATORY_ENV_FILE lives in /data because the Settings panel writes API keys to it — written # into the container's own filesystem they would vanish with the next `up --build`. ENV NODE_ENV=production \ CONDA_ENV_PATH=/opt/venv/bin \ POCKET_ENV_PATH=/opt/venv-pocket/bin \ PIPER_ENV_PATH=/opt/venv-piper/bin \ LIBRATORY_RUNTIME=docker \ DATA_DIR=/data \ LIBRATORY_ENV_FILE=/data/.env \ HOST=0.0.0.0 \ PORT=3034 \ HF_HOME=/models/hf \ XDG_CACHE_HOME=/models/cache \ FONT_PATH=/models/cache/marker-fonts/GoNotoCurrent-Regular.ttf RUN mkdir -p /data /models && chown -R node:node /data /models USER node VOLUME ["/data", "/models"] EXPOSE 3034 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3034)+'/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" # The entrypoint caches the essential Kokoro voice into /models on first boot — the one model # the lazy-download story cannot cover, because synthesis itself runs HF_HUB_OFFLINE=1. ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] # tsx from the server's own node_modules — the same way `pnpm dev` runs it, minus the watcher. CMD ["packages/server/node_modules/.bin/tsx", "packages/server/src/main.ts"]