Repository navigation
Description
The api and gateway runtime images are built from their build stage, base. That stage is the full node:lts-krypton image plus corepack and a global turbo install, and none of it is needed to run node ./dist/…. Both apps are already bundled into dist/, and the runner stage copies only dist/ and a few generated files out of the installer. The runtime still carries the full Debian toolchain from the base image: gcc, g++, make, python3, git, svn, ssh, curl and wget. It also carries pnpm (through corepack) and turbo@2.11.5 under /pnpm. As a result, every deployment and every upgrade downloads about three times more than the app needs, and anyone who gains code execution in either container has a compiler, git and network tools at hand. The gateway is the internet-facing one.
Where
apps/api/Dockerfile:1-8 and :25-26 (the gateway Dockerfile has the same structure, apps/gateway/Dockerfile:1-9 and :27):
FROM node:lts-krypton AS base
# ...
RUN corepack enable
RUN pnpm install -g turbo@2.11.5
# ...
# RUN SERVER
FROM base AS runner
COPY --from=installer /app/apps/api/dist/ /app/dist/Reproduce
- Run
docker manifest inspect --verbose ghcr.io/douglasneuroinformatics/open-data-capture-api:latestand sum thelinux/amd64layer sizes. Do the same fornode:lts-kryptonandnode:24-slim. - Run
docker run --rm --entrypoint sh ghcr.io/douglasneuroinformatics/open-data-capture-api:latest -c 'for b in gcc git make python3 turbo pnpm; do command -v $b; done'.
Actual: the api image is 488.1 MB compressed (gateway: 487.7 MB), and 411.9 MB of that is the node:lts-krypton base. node:24-slim, the same Node 24 on the same Debian 12, is 80.8 MB, so a slim runtime would come to roughly 160 MB. Step 2 prints /usr/bin/gcc, /usr/bin/git, /usr/bin/make, /usr/bin/python3, /pnpm/bin/turbo and /usr/local/bin/pnpm.
Expected: the runtime stage contains Node, the bundled app and the shared libraries it loads, and no build tooling.
Tests
The e2e suite does not use the images, so no unit or Playwright test applies. Verify the fix in the built images. docker compose up -d should bring up a working stack, including login, a recorded instrument and a completed remote assignment, which exercises Prisma in both containers. The step 2 loop should print nothing, and the compressed size should drop accordingly.
Suggested fix
Give each runner its own FROM node:24-slim (or the matching krypton slim tag) instead of FROM base, and move ENV NODE_OPTIONS and the corepack and turbo setup so they apply to the build stages only. node:24-slim ships without libssl, which Prisma's query engine loads, so add RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/* to the runner. Keep USER node in the api runner. The gateway running as root is tracked separately in #1792.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Both runners are stillFROM base(apps/api/Dockerfile:25,apps/gateway/Dockerfile:27), and no commit since this was filed touches either file.Corrections to the suggested fix
ENV NODE_OPTIONS="--max-old-space-size=8192"in the api runner. If it moves to the build stages only, the API falls back to V8's default heap limit. That default is about 2 GiB on a host with less than 15 GiB of memory and about 4 GiB above that (Node 24.15 reportsheap_size_limitas 4288 MiB on an 18 GiB host). Performance: /instrument-records/export buffers the whole dataset in memory (12.8s, +2.9 GiB RSS, 122 MiB body at 200k records) #1407 measured 3.5 GiB of heap for one 200k-record export at 30 measures per record, so that export would run out of heap on any host under 15 GiB. Keep the flag in the gateway runner too, so this PR changes only the packaging. Whether 8 GiB is the right limit belongs in Performance: /instrument-records/export buffers the whole dataset in memory (12.8s, +2.9 GiB RSS, 122 MiB body at 200k records) #1407.baseprovides today:WORKDIR /app. The slim image sets none, so theRUN echo … > package.jsonlines and the relativeCMDpaths would resolve from/.ENV GATEWAY_DATABASE_URL=file:/app/sqlite/gateway.db(apps/gateway/Dockerfile:4). Compose does not pass it (docker-compose.yaml:82-84).run.shexpands it underset -eu(apps/gateway/scripts/run.sh:3,5), so without it the container exits at start. Prisma reads it too (apps/gateway/prisma/schema.prisma:12). Keep it inbaseas well, because the installer'sdb:push(apps/gateway/Dockerfile:24) creates the template database at that path.node:24-slimandnode:krypton-slimare the same bookworm-slim image, the same Debian release aslts-krypton. There is nolts-krypton-slim. Avoidlts-slim: it moves to Node 26 when that line enters LTS on 2026-10-28 (tags, schedule).getBinaryTargetForCurrentPlatform, in libnest'ssrc/meta/plugins/prisma.tsfor the api and in@douglasneuroinformatics/esbuild-plugin-prismafor the gateway. Thelibstatsaddon in the api'sdist/is thelibc: [glibc]variant, the only one pnpm installs there.Verification to add
ldd /app/dist/*.nodein the api image andldd /.prisma/client/*.nodein the gateway image, on both architectures. Anot foundline is a missing shared library, and the check takes seconds without the full stack.esbuildbinary or the export worker. Also create a series instrument (POST /v1/instruments/seriescallsbundle()atapps/api/src/instruments/instruments.service.ts:198), and export from the datahub, which startsexport-worker.jsand resolves#runtimethrough the runner's generatedpackage.json.main,should_releaseis always'true'(.github/scripts/release.cjs:25), so every push builds both images forlinux/amd64andlinux/arm64(release.yaml:89) and pushes them aslatest(:79). The merge is the first CI build, and it goes straight to every deployment that pullslatest. Build and check both architectures locally before merging. Once ci: release from release branches and keep latest on tagged releases #1822 lands, a push tomainpublishesedgeinstead, which gives a target to smoke-test beforelatestmoves.AGENTS.md:49-51exempts onlyscripts/from the unit and e2e rule. State this exception in the PR body, as ci: release from release branches and keep latest on tagged releases #1822 does for its workflow change.Landing order
setprivapproach still works on slim:/usr/bin/setprivis inutil-linux, which is Essential in bookworm.FROM node:lts-kryptonline per Dockerfile: the "Base images" section of its newrelease-channels.md, and step 2 of "From a release branch" incut-a-release.md. After this change the api and gateway Dockerfiles each have a secondFROMfor the runner, and that is the line that ships Node to production. Whichever of the two PRs lands second should extend those docs so a release branch pins the runner too, for example tonode:24.22.1-bookworm-slim.Triage note written with Claude Code. Code references point at
main@d86740e.