Dockerfile
Rama main · réplica de lectura del repositorio original.
# Combined Engine + Web UI image for self-hosted single-user deployment
# Recommended: docker compose up (includes SearXNG for web search)
# Standalone: docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... ghcr.io/lynox-ai/lynox:latest
# Build acceleration notes
# - `--mount=type=cache` keeps the pnpm store + apt lists between builds when
# the BuildKit backend has a persistent cache (GitHub Actions `type=gha`,
# Docker Build Cloud, or local docker buildx). Cuts ~1–2 min per build by
# avoiding redundant package downloads when only application code changed.
# - We deliberately drop the `rm -rf /var/lib/apt/lists/*` lines because the
# directories are tmpfs-mounted via `--mount=type=cache`; trying to remove
# them at build time would just re-download next time. The lists are not
# copied into the final image (multi-stage), so image size is unaffected.
# --- Stage 1: Build Engine ---
FROM node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS build-engine
WORKDIR /app
# Disable lefthook in the build: its dev-dep postinstall runs `lefthook install`
# which needs a git repo/binary that isn't in this stage, failing the build on a
# cold install-layer cache (the `|| true` on our own `prepare` doesn't cover the
# package's own postinstall). LEFTHOOK=0 makes lefthook skip — git hooks are a
# dev-machine concern, never needed in an image build.
ENV LEFTHOOK=0
# onnxruntime-node's postinstall fetches only CUDA/TensorRT providers on
# linux/x64 (~300 MB, never loaded; the CPU runtime ships in the package).
ENV ONNXRUNTIME_NODE_INSTALL=skip
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& corepack enable && corepack prepare pnpm@10 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install only engine deps (web-ui has separate install)
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile --filter @lynox-ai/core
COPY src/ src/
COPY tsconfig.json ./
RUN pnpm run build
# --- Stage 2: Build Web UI ---
FROM node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS build-webui
WORKDIR /app
# Bake the build SHA into the web-ui bundle so the running client can detect
# a stale-cache mismatch against the engine's /api/health.build_sha. CI passes
# this via --build-arg BUILD_SHA=$GITHUB_SHA in staging.yml.
ARG BUILD_SHA=
ENV BUILD_SHA=${BUILD_SHA}
# See build-engine stage: skip lefthook's git-dependent postinstall in the build.
ENV LEFTHOOK=0
# The web-ui install also installs the workspace root's dependencies, so the
# onnxruntime-node postinstall runs here too (see build-engine stage).
ENV ONNXRUNTIME_NODE_INSTALL=skip
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY packages/web-ui/package.json packages/web-ui/package.json
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# No `--mount=type=cache` here: on Apple Silicon local docker builds, the
# pnpm content-addressable store inside the cache mount produces symlinks
# in /app/packages/web-ui/node_modules that dangle the moment the cache
# unmounts post-RUN — `vite build` then fails with `Cannot find module
# .../vite/bin/vite.js`. CI builds (GHA Ubuntu, type=gha cache survives)
# work without this guard, but pinning explicitly keeps local + CI builds
# behaviour-identical at the cost of ~1-2min slower install (no shared
# pnpm store across builds).
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
cd packages/web-ui && pnpm install --frozen-lockfile
COPY packages/web-ui/ packages/web-ui/
RUN cd packages/web-ui && pnpm run build
# --- Stage 3: Production deps (Engine) ---
FROM node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS deps
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& corepack enable && corepack prepare pnpm@10 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# onnxruntime-node: skip the postinstall download (CUDA/TensorRT providers only on
# linux/x64, ~300 MB, never loaded — the CPU runtime ships in the npm package),
# and remove the provider files in case a warm store's side-effects cache put them
# back. Then prove the CPU runtime still loads in this image.
ENV ONNXRUNTIME_NODE_INSTALL=skip
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile --prod \
&& node -e "const db = require('better-sqlite3')(':memory:'); db.prepare('SELECT 1').get(); db.close(); console.log('better-sqlite3 OK')" \
&& ORT_DIR="$(ls -d node_modules/.pnpm/onnxruntime-node@*/node_modules/onnxruntime-node)" \
&& rm -f "$ORT_DIR"/bin/napi-v6/linux/*/libonnxruntime_providers_*.so \
&& test -z "$(find "$ORT_DIR/bin" -name 'libonnxruntime_providers_*')" \
&& node -e "require(require('path').resolve(process.argv[1])); console.log('onnxruntime-node OK')" "$ORT_DIR"
# --- Stage 4: Whisper.cpp (audio transcription) ---
FROM node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS whisper-build
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends \
cmake make g++ git curl ca-certificates
# Pin whisper.cpp to the exact commit the v1.8.4 tag points at. The shallow
# --branch clone stays (efficient), but we assert HEAD equals the expected
# commit so a moved/re-pointed tag fails the build (tamper-detection) instead
# of silently compiling swapped-out upstream code.
RUN git clone --depth 1 --branch v1.8.4 https://github.com/ggerganov/whisper.cpp /tmp/whisper \
&& cd /tmp/whisper \
&& test "$(git rev-parse HEAD)" = "9386f239401074690479731c1e41683fbbeac557" \
&& cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF \
&& cmake --build build --config Release -j$(nproc) \
&& cp build/bin/whisper-cli /usr/local/bin/whisper-cli \
&& cp build/src/libwhisper.so.1* /usr/local/lib/ \
&& cp build/ggml/src/libggml*.so.0* /usr/local/lib/ \
&& ldconfig
# Whisper STT model: first from OUR GitHub release mirror, then from Hugging
# Face at a pinned commit. HF intermittently served HTTP errors and flaked the
# build repeatedly (~5× on 2026-06-03/04), which is why the mirror comes first;
# the mirror in turn answered 504 for longer than one source's retry window, so
# HF stays as the second source. Both serve the same bytes, and the sha256 below
# is checked once, whichever source delivered (scripts/fetch-pinned.sh).
# Only the `base` model is shipped — it transcribes all clip lengths; the prior
# `tiny` model was just a short-clip speed optimisation that whisper-cpp.ts
# already treats as optional (falls back to base).
RUN --mount=type=bind,source=scripts/fetch-pinned.sh,target=/tmp/fetch-pinned.sh \
mkdir -p /usr/share/whisper \
&& sh /tmp/fetch-pinned.sh /usr/share/whisper/ggml-base.bin \
60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe \
https://github.com/lynox-ai/lynox/releases/download/whisper-models/ggml-base.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.bin
# --- Stage 5: Production image ---
FROM node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS production
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && apt-get install -y --no-install-recommends \
wget ffmpeg libstdc++6 tini
# Whisper.cpp binaries + model
COPY --from=whisper-build /usr/local/bin/whisper-cli /usr/local/bin/whisper-cli
COPY --from=whisper-build /usr/local/lib/libwhisper* /usr/local/lib/
COPY --from=whisper-build /usr/local/lib/libggml* /usr/local/lib/
COPY --from=whisper-build /usr/share/whisper/ggml-base.bin /usr/share/whisper/ggml-base.bin
RUN ldconfig
# Harden: remove package managers, shells, SUID binaries.
# npm, corepack and yarn are the base image's bundled Node package managers; the
# runtime runs `node /app/dist/index.js` with pre-installed /app/node_modules and
# never invokes them, so they are dead weight — and their bundled node-tar carries
# fixed-but-unreachable CVEs (e.g. CVE-2026-59873) that fail the image scan.
#
# The dpkg STATUS stays (/var/lib/dpkg): an image scanner reads the installed OS
# packages from it. Without it a scan reports zero OS packages, which reads like a
# clean image and is a blind one. The tools go; the record of what is installed stays.
# Perl is removed through dpkg first, so the database no longer lists it. (It still
# lists apt, dpkg and bash, whose binaries the chain below deletes: a stale entry can
# only add findings, so it errs toward failing a release, never toward passing one.)
# Its own RUN, because the chain below ends in `|| true`.
RUN dpkg --purge --force-remove-essential --force-depends perl-base
RUN \
rm -rf /usr/bin/apt-get /usr/bin/apt /usr/bin/dpkg /usr/bin/dpkg-* \
/usr/lib/apt /var/lib/apt /etc/apt \
&& rm -rf /usr/bin/perl* /usr/share/perl* /usr/lib/*/perl* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/opt/yarn-v* /usr/local/bin/yarn /usr/local/bin/yarnpkg \
&& rm -f /usr/bin/bash /bin/bash /usr/bin/rbash /bin/rbash \
&& find / -perm -4000 -type f -exec chmod u-s {} + 2>/dev/null || true \
&& sed -i 's|root:/bin/bash|root:/usr/sbin/nologin|' /etc/passwd
RUN groupadd -g 1001 lynox && useradd -u 1001 -g lynox -m -s /bin/sh lynox \
&& mkdir -p /home/lynox/.lynox/memory /workspace /home/lynox/.cache/huggingface \
&& chown -R lynox:lynox /home/lynox/.lynox /workspace /home/lynox/.cache
# Shell sidecar (PRD 152 v2 §4.1). The same image runs as the sidecar with
# `/usr/bin/tini -- node /app/dist/shell-sidecar/exec-daemon.js` as uid 0 with only
# CAP_SETUID/CAP_SETGID. Its calls run as pool uids whose only group is lynox-ws
# (1002); the engine user is a member, so both reach the shared workspace, but a
# pool uid is never in 1001 and cannot open the socket. /run/lynox-exec is the
# socket volume's mount point: root 0755, so a fresh named volume starts that way
# and the daemon creates its 0710 root:1001 directory inside. tini because a
# killed call's children otherwise stay as zombies (measured: 33 without, 0 with).
RUN groupadd -g 1002 lynox-ws && usermod -aG lynox-ws lynox \
&& mkdir -p /run/lynox-exec && chown root:root /run/lynox-exec && chmod 0755 /run/lynox-exec
# The workspace anchor in sidecar mode (PRD shell-sidecar-activation D0, D2). The template mounts
# the workspace volume at /run/lynox-anchor/ws; the entrypoint's root stage opens it as fd 3 and
# then drops to 1001, which can no longer reach it by name: the parent is root 0700. The engine
# reaches the tree only through that fd (`shell-isolation.ts`). The tree itself is 1001 0755, the
# owner and mode start check (b) asks for, so a fresh named volume starts that way. Unused when
# the image runs without the sidecar.
RUN mkdir -p /run/lynox-anchor/ws \
&& chown root:root /run/lynox-anchor && chmod 0700 /run/lynox-anchor \
&& chown 1001:1001 /run/lynox-anchor/ws && chmod 0755 /run/lynox-anchor/ws
WORKDIR /app
# The code belongs to root and the runtime user only reads it, so the process cannot
# change what it runs on its next start. The fleet's read-only root filesystem already
# stops that; a container started without it (`docker run`, an own compose file) did
# not. Nothing writes under /app at runtime: caches go to $HOME/.cache.
# Engine
COPY --from=deps /app/node_modules/ /app/node_modules/
COPY --from=build-engine /app/dist/ /app/dist/
COPY --from=build-engine /app/package.json /app/package.json
# Web UI
COPY --from=build-webui /app/packages/web-ui/build/ /app/web-ui/
COPY --from=build-webui /app/packages/web-ui/node_modules/ /app/web-ui/node_modules/
COPY --from=build-webui /app/packages/web-ui/package.json /app/web-ui/package.json
COPY entrypoint-webui.sh /entrypoint-webui.sh
RUN chmod 0755 /entrypoint-webui.sh
# Every program in the image is execute-only for the runtime user, so no
# process in the container is dumpable. The kernel marks a process started from
# a binary its user cannot read as not dumpable, and another process of the
# same user then cannot read its /proc/<pid>/environ or memory (with the
# host's fs.suid_dumpable at 0 or 2, the default). It holds for every process
# started from a program in the image: the engine, what it starts, and what
# `docker exec` starts from here. Not for a binary from outside the image, such
# as the `docker-init` that `init: true` mounts in as pid 1 — so the container
# runs without one. A program is an executable ELF file without a library name; owned by
# root, because its owner could read it whatever the mode. Libraries stay
# readable: the loader maps them. Last, after every COPY, so a binary a
# dependency brings is covered too. scripts/image-exec-only-probe.sh checks
# the built image (smoke).
RUN find / -xdev -type f -perm /111 ! -name '*.so' ! -name '*.so.*' ! -name '*.node' \
-exec sh -c 'for f; do head -c4 "$f" | grep -q ELF && chown root:root "$f" && chmod go-r "$f"; done; true' sh {} +
USER lynox
# Git SHA the engine was built from. Surfaced via /api/health so UpdateManager
# can verify the running container actually swapped to the new image after a
# rollout — package.json `version` (PKG_VERSION) is unchanged between two
# tagged builds against the same release commit (e.g. `staging` floats), so
# version-equality alone can't catch a stalled `docker compose pull`.
# Defaults empty so dev images don't bake in a stale SHA; CI always passes one
# via --build-arg BUILD_SHA=$GITHUB_SHA.
ARG BUILD_SHA=
ENV BUILD_SHA=${BUILD_SHA}
ENV NODE_ENV=production
ENV LYNOX_HTTP_PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:${LYNOX_HTTP_PORT:-3000}/health || exit 1
ENTRYPOINT ["/entrypoint-webui.sh"]