You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Convert the production image from node:22-alpine to distroless (gcr.io/distroless/nodejs22-debian12:nonroot) to shrink the attack surface (no shell, no package manager, no busybox → far cleaner Trivy/CVE scans) at comparable size, while keeping the build/test/deploy toolchain coherent on a single libc.
Deferred from the base-image hardening work (the digest pin landed in #105); filing so it isn't lost. Not urgent — alpine + digest pin is a perfectly fine prod baseline.
Motivation
22-alpine is a reasonable small base, and for this app musl-vs-glibc is a non-issue today (zero native deps — just @aws-sdk/client-s3 + hono). Distroless is the meaningful step up in posture: it ships Node + glibc and nothing else, so there's no shell to exploit and dramatically fewer packages for scanners to flag. It also runs as non-root by default.
Proposed design (all glibc — no alpine/musl anywhere)
Testing on musl and shipping on glibc would be incoherent (and a latent bug the day a native dep is added), so the whole toolchain moves to glibc:
# deps/builder — glibc, has npmFROM node:22-slim@sha256:… AS deps
WORKDIR /src
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# production — distroless (no shell, nonroot uid 65532)FROM gcr.io/distroless/nodejs22-debian12:nonroot AS production
WORKDIR /src
COPY --from=deps /src/node_modules ./node_modules
COPY server.js package.json ./
ENV PORT=8080 NODE_ENV=production AWS_NODEJS_CONNECTION_REUSE_ENABLED=1
EXPOSE 8080
# no shell/wget → node-based healthcheck (node --test / fetch are built in)HEALTHCHECK CMD ["/nodejs/bin/node","-e","fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
ENTRYPOINT ["/nodejs/bin/node","server.js"]
# test — runs the app tests ON the shipped runtime (node --test is built in)FROM production AS test
COPY test/server.test.js ./test/
ENTRYPOINT ["/nodejs/bin/node","--test","test/server.test.js"]
docker run --rm -e BUCKET=… -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY <test-image> then exercises the actual production runtime (same node binary, node_modules, non-root user) — no alpine stand-in.
production stage → gcr.io/distroless/nodejs22-debian12:nonroot (digest-pinned).
Replace the wget healthcheck with the node-based one above; drop tini (rely on docker run --init / the orchestrator for reaping — server.js already handles SIGTERM).
test stage → FROM production + copy test/server.test.js, entrypoint node --test.
Rework test/docker-basic.test.js: distroless has no shell, so the whoami / ls /src checks move to docker inspect assertions (.Config.User, image config) instead of exec'ing coreutils. The missing-BUCKET guard check still works via --entrypoint /nodejs/bin/node.
Keep the dev credentials file — it works unchanged in distroless (pure fs.readFileSync + bind mount). Add a README note that the mounted file must be readable by the non-root uid 65532 (default 0644 from npm run credentials is fine; strict umasks need chmod 644 or docker run --user), and make getCredentials() log loudly on read failure so a perms problem is diagnosable instead of silently falling back to the SDK chain. See the credentials-security discussion (file is preferred over -e for agent/local use — no secret in the command or in docker inspect).
Verify locally: build, test stage passes, container goes healthy, streams an object, returns 404, and a credentials-file mount works as the non-root user.
Trade-offs / notes
No shell in the container → can't docker exec sh to debug. Use the distroless :debug variant or kubectl debug / ephemeral debug containers when needed.
Size is comparable-to-slightly-smaller (~150 MB); the real win is attack surface, not bytes.
Alternative considered: Chainguard (cgr.dev/chainguard/node) — near-zero-CVE, glibc, has a -dev shell variant — but free tier is :latest-only, which conflicts with the digest/version pinning we standardized on. Revisit if a paid plan is in play.
Summary
Convert the production image from
node:22-alpineto distroless (gcr.io/distroless/nodejs22-debian12:nonroot) to shrink the attack surface (no shell, no package manager, no busybox → far cleaner Trivy/CVE scans) at comparable size, while keeping the build/test/deploy toolchain coherent on a single libc.Deferred from the base-image hardening work (the digest pin landed in #105); filing so it isn't lost. Not urgent — alpine + digest pin is a perfectly fine prod baseline.
Motivation
22-alpineis a reasonable small base, and for this app musl-vs-glibc is a non-issue today (zero native deps — just@aws-sdk/client-s3+hono). Distroless is the meaningful step up in posture: it ships Node + glibc and nothing else, so there's no shell to exploit and dramatically fewer packages for scanners to flag. It also runs as non-root by default.Proposed design (all glibc — no alpine/musl anywhere)
Testing on musl and shipping on glibc would be incoherent (and a latent bug the day a native dep is added), so the whole toolchain moves to glibc:
docker run --rm -e BUCKET=… -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY <test-image>then exercises the actual production runtime (same node binary, node_modules, non-root user) — no alpine stand-in.Tasks
depsstage →node:22-slim(digest-pinned, Dependabot-maintained).productionstage →gcr.io/distroless/nodejs22-debian12:nonroot(digest-pinned).wgethealthcheck with the node-based one above; droptini(rely ondocker run --init/ the orchestrator for reaping —server.jsalready handles SIGTERM).teststage →FROM production+ copytest/server.test.js, entrypointnode --test.test/docker-basic.test.js: distroless has no shell, so thewhoami/ls /srcchecks move todocker inspectassertions (.Config.User, image config) instead of exec'ing coreutils. The missing-BUCKETguard check still works via--entrypoint /nodejs/bin/node.fs.readFileSync+ bind mount). Add a README note that the mounted file must be readable by the non-root uid 65532 (default0644fromnpm run credentialsis fine; strict umasks needchmod 644ordocker run --user), and makegetCredentials()log loudly on read failure so a perms problem is diagnosable instead of silently falling back to the SDK chain. See the credentials-security discussion (file is preferred over-efor agent/local use — no secret in the command or indocker inspect).teststage passes, container goes healthy, streams an object, returns 404, and a credentials-file mount works as the non-root user.Trade-offs / notes
docker exec shto debug. Use the distroless:debugvariant orkubectl debug/ ephemeral debug containers when needed.cgr.dev/chainguard/node) — near-zero-CVE, glibc, has a-devshell variant — but free tier is:latest-only, which conflicts with the digest/version pinning we standardized on. Revisit if a paid plan is in play.