Skip to content

API and gateway runtime images ship the full Node build image with gcc, git and turbo, tripling their size #1794

Open

Description

@joshunrau

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

  1. Run docker manifest inspect --verbose ghcr.io/douglasneuroinformatics/open-data-capture-api:latest and sum the linux/amd64 layer sizes. Do the same for node:lts-krypton and node:24-slim.
  2. 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

  1. added
    Priority: LowNice to have, cosmetic or speculative
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: MediumStraightforward change, about a day or less
    Area: InfrastructureDeployment, Docker, CI, logging, CLIs, build and test tooling
    PerformanceSpeed, memory or scalability
    SecurityAuthentication, authorization, data exposure or a vulnerable dependency
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Both runners are still FROM base (apps/api/Dockerfile:25, apps/gateway/Dockerfile:27), and no commit since this was filed touches either file.

    Corrections to the suggested fix

    • Keep 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 reports heap_size_limit as 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.
    • The new runners lose two things base provides today:
      • WORKDIR /app. The slim image sets none, so the RUN echo … > package.json lines and the relative CMD paths would resolve from /.
      • In the gateway, ENV GATEWAY_DATABASE_URL=file:/app/sqlite/gateway.db (apps/gateway/Dockerfile:4). Compose does not pass it (docker-compose.yaml:82-84). run.sh expands it under set -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 in base as well, because the installer's db:push (apps/gateway/Dockerfile:24) creates the template database at that path.
    • Tag: node:24-slim and node:krypton-slim are the same bookworm-slim image, the same Debian release as lts-krypton. There is no lts-krypton-slim. Avoid lts-slim: it moves to Node 26 when that line enters LTS on 2026-10-28 (tags, schedule).
    • Do not go on to alpine. The build stage is glibc, so the native files the runners load are glibc builds. The Prisma engine is picked at build time by getBinaryTargetForCurrentPlatform, in libnest's src/meta/plugins/prisma.ts for the api and in @douglasneuroinformatics/esbuild-plugin-prisma for the gateway. The libstats addon in the api's dist/ is the libc: [glibc] variant, the only one pnpm installs there.

    Verification to add

    • Run ldd /app/dist/*.node in the api image and ldd /.prisma/client/*.node in the gateway image, on both architectures. A not found line is a missing shared library, and the check takes seconds without the full stack.
    • The issue's checklist does not run the bundled esbuild binary or the export worker. Also create a series instrument (POST /v1/instruments/series calls bundle() at apps/api/src/instruments/instruments.service.ts:198), and export from the datahub, which starts export-worker.js and resolves #runtime through the runner's generated package.json.
    • No CI job builds these images on a PR. On main, should_release is always 'true' (.github/scripts/release.cjs:25), so every push builds both images for linux/amd64 and linux/arm64 (release.yaml:89) and pushes them as latest (:79). The merge is the first CI build, and it goes straight to every deployment that pulls latest. Build and check both architectures locally before merging. Once ci: release from release branches and keep latest on tagged releases #1822 lands, a push to main publishes edge instead, which gives a target to smoke-test before latest moves.
    • Root AGENTS.md:49-51 exempts only scripts/ 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

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: InfrastructureDeployment, Docker, CI, logging, CLIs, build and test toolingDifficulty: MediumStraightforward change, about a day or lessEnhancementType: an improvement to an existing feature, including performancePerformanceSpeed, memory or scalabilityPriority: LowNice to have, cosmetic or speculativeSecurityAuthentication, authorization, data exposure or a vulnerable dependency

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions