Skip to content

Convert production image to distroless (slim builder → distroless prod → distroless test) #106

Description

@gmoon

Summary

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 npm
FROM 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.

Tasks

  • deps stage → node:22-slim (digest-pinned, Dependabot-maintained).
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    dockerPull requests that update Docker codeenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions