Skip to content

Docker multi stage - #346

Merged
kOaDT merged 5 commits into
mainfrom
docker-multi-stage
Oct 7, 2026
Merged

kOaDT merged 5 commits into
mainfrom
docker-multi-stage

Conversation

@kOaDT

@kOaDT kOaDT commented Oct 5, 2026

Copy link
Copy Markdown
Owner

The published image carried its whole build environment: compilers, the npm cache, an 800 MB Cypress binary nothing in the container runs, the Next.js build cache and every devDependency. This PR moves the build into its own stage and ships only what the lab runs. Nothing changes for players: first run, upgrading an existing volume and every challenge behave the same.

Size

Image Disk usage Content size (pushed)
leogra/oss-oopssec-store:latest (2026-09-22) 4.23 GB 1.08 GB
main built locally 4.29 GB 1.10 GB
This branch 1.50 GB 348 MB

Sizes from docker image ls, Node 22.23.2.

Changes

  • Runtime dependencies. tsx and dotenv move to dependencies. The entrypoint seeds through tsx, and both the seed and prisma.config.ts load dotenv, so pruning devDependencies would break the first run and every prisma command. In the lockfile, only "dev": true goes away for tsx, dotenv and their transitive packages; no version changes.
  • Multi-stage Dockerfile. The builder installs with CYPRESS_INSTALL_BINARY=0, cleans the npm cache in the same layer, builds as before, then prunes devDependencies and drops .next/cache. The runtime stage copies /app onto the same node:22-alpine base and installs no apk packages.
  • npx --no in the entrypoint. Without a TTY, npx assumes --yes, so a package missing from the image was quietly fetched from the registry instead of failing. I checked this with an image stripped of tsx: plain npx installed tsx@4.23.15, which is not the locked version, and carried on. With --no it fails.
  • CI smoke test. docker-build.yml built the image but never started it. It now runs the container with --network none, waits for the seed, and queries /api/flags/count. .dockerignore joins the path filter.
  • .dockerignore drops untracked local folders (.claude/, .plan/, .venv/), so an image built locally matches the published one.

Trade-offs

  • Whole tree, no allowlist. At runtime the app reads content/, documents/, flag-xxe.txt, prisma/schema.prisma and next.config.ts. The supply-chain challenge reads packages/react-toastfy/ and lab/quarantine/ through path traversal. With an allowlist, a forgotten path would break a challenge without any error at startup. The sources are about 2.5 MB, so there is nothing to gain.
  • Build toolchain cut to python3 make g++. On Node 22, libxmljs2 and better-sqlite3 install as prebuilt binaries. The toolchain only matters if node-gyp has to compile them, and npm rebuild --build-from-source works with these three packages alone. libxmljs2 compiles its bundled libxml2, so the runtime stage needs no libxml2.
  • typescript stays in the image (23 MB). It is an optional peer dependency of prisma, which --omit=dev keeps.
  • Still root. The runtime user is unchanged, as are output: "standalone" and the single-arch build. Those are separate changes.

Verification

Run against the image from this branch:

  • Offline first run, then restart: seed, then db:upgrade, 36 flags both times.
  • Upgrade from leogra/oss-oopssec-store:latest: offline, flag progress kept.
  • npm run test:api with the container on the host database: 296/297. The XXE test fails the same way against main: it builds its file:// URL from the host working directory, which does not exist in the container. The exploit works with file:///app/flag-xxe.txt.
  • npm run test:e2e: 70/70.
  • /vulnerabilities/react2shell and /_next/image with an Unsplash URL return 200, which means content/ and next.config.ts are loaded.
  • docker compose up --build on a fresh project: first run OK.
  • The CI smoke script, run locally: passes in 8 s on this image and fails on an image without tsx.
  • npm run test:unit, npm run lint (0 errors) and npm run format:check pass.

Left for later

  • Glibc binaries on Alpine. npm ci also installs the glibc builds of @next/swc (137 MB), @napi-rs/canvas (32 MB) and sharp, because the lockfile records os and cpu but not libc. Leaving them out would take about another 170 MB off disk.
  • Margin. Disk usage is just under 1.5 GB (1,499,843,887 bytes), so a few added dependencies will cross that mark.

@kOaDT kOaDT self-assigned this Oct 5, 2026
@github-actions github-actions Bot added the size/M PR size: M label Oct 5, 2026
@kOaDT
kOaDT merged commit 735fa4c into main Oct 7, 2026
7 checks passed
@kOaDT
kOaDT deleted the docker-multi-stage branch October 7, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant