# Stage 1: Build frontend # # Pinned to the BUILD host's arch, like the backend stage below. The output is # `dist/` — JavaScript, CSS and HTML, identical for every target — so there is # nothing to gain from running this stage under QEMU, and one thing to lose: # esbuild's postinstall validates its native binary by executing it, and that # binary SIGSEGVs under emulation. It killed the v0.42.0 release at # `npm ci` (`vite-node/node_modules/esbuild`, signal SIGSEGV) on a build whose # dependencies had not changed since the release before it, so the emulated # path was always a coin flip. Building natively also halves the multi-arch # build: one npm ci + vite build instead of one per target platform. FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend WORKDIR /app/frontend COPY src/frontend/package*.json ./ # patches/ MUST land before `npm ci`: the postinstall hook runs patch-package, # which silently no-ops when the directory is absent. It was copied only with # the rest of src/ below, so every image ever built shipped UNPATCHED # pdfjs-dist and pixi.js — the PDF watermark filter and the GPU-leak fix in # BatchableGraphics.reset() never actually ran in production (verified by # grepping deployed assets: `watermarkFilter` 10x locally, 0x deployed). COPY src/frontend/patches ./patches RUN npm ci COPY src/frontend/ ./ RUN npm run build # Stage 2: Build backend # Run the build stage on the BUILD host's native arch and cross-compile to the # target arch (GOARCH=$TARGETARCH). CGO is disabled, so cross-compilation is # trivial and skips slow QEMU emulation of the target toolchain during buildx. # # Go 1.25 is required by go-pdfium (its go.mod declares `go 1.25.0`). The PDF # text index pulled that dependency in, which bumped the toolchain 1.22 -> 1.25. # modernc.org/sqlite MUST be kept in lock-step with the toolchain: v1.34.5 # (transpiled libc generated for Go 1.21) crashed at runtime under Go 1.25 with # "unable to open database file: out of memory (14)" the instant the databank # DB was opened (shipped broken in v0.31.0). v1.50.1 ships libc regenerated for # Go 1.25 and boots cleanly. If the golang base image moves again, bump # modernc.org/sqlite to a release whose go.mod `go` directive matches. FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend ARG TARGETARCH WORKDIR /app/backend COPY src/backend/go.* ./ RUN go mod download COPY src/backend/ ./ ARG APP_VERSION=dev ARG PUBKEY="" # Extra trusted signing keys (comma-separated) for a rotation overlap window. ARG PUBKEYS="" ARG SOURCES="https://www.ripperdoc.de/boardripper,https://github.com/AlexeyInwerp/BoardRipper/releases/latest/download" RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build \ -ldflags="-s -w \ -X boardripper/updater.Version=${APP_VERSION} \ -X boardripper/updater.PubKey=${PUBKEY} \ -X boardripper/updater.PubKeys=${PUBKEYS} \ -X boardripper/updater.SourceList=${SOURCES}" \ -o server . # Pre-create /data and /library owned by the runtime UID. scratch has no # shell + no mkdir, so we stage the dirs in alpine first and COPY them # into the final image. Without this, running the image with no host # bind-mount fails at startup with `mkdir /data: permission denied` # (the server's MkdirAll can't create top-level dirs as non-root). RUN mkdir -p /opt/empty/data /opt/empty/library && \ chown -R 65532:65532 /opt/empty # Stage 3: Final minimal image. Runs as non-root (UID 65532, the # `nonroot` user from the distroless convention) so a hypothetical RCE in # the binary doesn't own the bind-mounted /data and /library volumes. # scratch has no /etc/passwd, so we synthesize one with just the entry # that USER references — Go's HTTP stack doesn't need a real user lookup. FROM scratch COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=backend /app/backend/server /server COPY --from=frontend /app/frontend/dist /static # Bundled board reference database. Backend prefers DATA_DIR/boards.db when # the user has staged a curated copy on their volume; falls back here when # /data is empty (default fresh install). Override with BOARDDB_PATH. COPY ["Board Database/boards.db", "/boards.db"] COPY etc-passwd /etc/passwd # --chown is required: cross-stage COPY otherwise resets ownership to # root:root and the runtime UID 65532 can't write into /data (SQLite # fails with `unable to open database file (14)`). COPY --chown=65532:65532 --from=backend /opt/empty/data /data COPY --chown=65532:65532 --from=backend /opt/empty/library /library USER 65532:65532 EXPOSE 8080 ENV STATIC_DIR=/static ENV DATA_DIR=/data ENV LIBRARY_DIR=/library ENV PORT=8080 # SQLite needs a writable temp directory for statement journals, sorters, and # VACUUM. The scratch image has no /tmp and the runtime CWD (/) isn't writable # by UID 65532, so SQLite's unixTempFileDir() finds nowhere to put a temp file # and returns SQLITE_IOERR_GETTEMPPATH (extended code 6410) the moment an # operation needs one. This is invisible on a fresh DB but fatal on a populated # one: the pdf-index v0→v1 migration drops the large legacy pdf_text/pdf_pages # tables, and dropping a multi-hundred-MB table opens a statement journal. # Point all temp-file creation at the always-writable, disk-backed data volume. # (SQLITE_TMPDIR is the first dir SQLite consults; TMPDIR also covers Go's # os.TempDir() for any non-SQLite temp needs.) Shipped broken in v0.31.0/v0.31.1. ENV SQLITE_TMPDIR=/data ENV TMPDIR=/data # Return freed heap pages to the OS promptly. Go's default Linux scavenger uses # MADV_FREE, which leaves freed pages counted in RSS until the kernel is under # memory pressure — the "consumes many gigs and slowly releases back" profile. # MADV_DONTNEED drops them immediately so RSS tracks live memory. The runtime # also derives a soft GOMEMLIMIT from the cgroup memory limit at startup (see # configureMemoryLimit in main.go) so the GC works harder before RSS climbs. ENV GODEBUG=madvdontneed=1 ENTRYPOINT ["/server"]