The core idea
Your base image is most of your image. It decides your C library, your package manager, which debugging tools exist inside the container, and how many packages a security scanner will flag. There's no single best choice, but there are four families, and knowing what's inside each makes the choice easy.
The four families
Measured with the notes API on top of each (arm64; amd64 differs slightly):
| Base | What's inside | C library | Notes API image |
|---|---|---|---|
node:22 | Debian plus build tools: git, python3, gcc, curl | glibc | 1.63 GB base alone |
node:22-slim | minimal Debian: apt and bash, no curl or compilers | glibc | 349 MB |
node:22-alpine | Alpine Linux: busybox and apk | musl | 238 MB |
gcr.io/distroless/nodejs22-debian12 | Node and its runtime libraries. No shell, no package manager | glibc | 212 MB |
You can check these yourself:
$ docker run --rm node:22 sh -c 'which curl git python3 gcc'/usr/bin/curl/usr/bin/git/usr/bin/python3/usr/bin/gcc$ docker run --rm node:22-slim sh -c 'which curl apt-get bash'/usr/bin/apt-get/usr/bin/bash$ docker run --rm node:22-slim sh -c 'ldd --version | head -1'ldd (Debian GLIBC 2.36-9+deb12u14) 2.36$ docker run --rm node:22-alpine sh -c 'ldd --version 2>&1 | head -1'musl libc (aarch64)
Notice that node:22-slim has no curl. That matters the first time you write a healthcheck that calls curl.
glibc vs musl
Alpine is small partly because it uses musl instead of glibc as its C library. Pure JavaScript, Python or Go code doesn't care. Native code does:
- npm packages with native addons (for example
bcrypt,sharp) need musl builds or compile from source. - Python wheels on PyPI are mostly built for glibc, so on Alpine
pipoften compiles from source, which is slow and needs build tools. - DNS resolution and some locale and threading behaviour differ in small ways that occasionally surface as bugs.
None of this makes Alpine bad. It means "switch to Alpine" is a change to test, not a free win.
Distroless: nothing to log into
Distroless images contain your language runtime and the libraries it needs, and nothing else. No shell, so this fails:
$ docker run --rm --entrypoint sh notes-api:distroless -c 'echo hi'docker: Error response from daemon: ... exec: "sh": executable file not found in $PATH
Output trimmed. That's the point: an attacker who gets code execution has no shell, no package manager and no curl
to work with, and scanners have far fewer packages to flag. The cost is that you can't docker exec -it ... sh to
debug. You debug from outside instead, or use a :debug variant of the image temporarily.
Distroless also changes how you write the Dockerfile: there's no npm in the final image, so you copy a pruned
node_modules from the build stage, and CMD lists only the script because the entrypoint is already node:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM gcr.io/distroless/nodejs22-debian12:nonroot
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["dist/server.js"]The :nonroot tag runs as user 65532 by default.
You switch a Python service from python:3.12-slim to python:3.12-alpine. The build now takes 6 minutes instead of 40 seconds. What's the likely cause?
A default you can defend
- Start with
-slim. glibc compatibility, a package manager when you need one, a shell for debugging, and a reasonable size. It's the least surprising choice. - Move to distroless for production services once your Dockerfile is stable and you're comfortable debugging without a shell.
- Use Alpine when size really matters and your dependencies are known to work on musl.
- Use the full image for build stages that need compilers, not for what you ship.
Whichever you choose, pin the major version (node:22-slim, not node:slim), and consider pinning by digest
(02.06 Tags, digests and registries).
Run sh -c 'cat /etc/os-release | head -2; which apt-get apk curl' in node:22-slim and node:22-alpine. Then build the distroless variant above and try docker exec -it into it. What's the error, and how would you debug a problem in that container?
STUCK? ▸
pull access denied for distroless: check the full name, gcr.io/distroless/nodejs22-debian12. It's hosted on Google's registry, not Docker Hub.
Recap
Interview questions
How do you choose a base image? Compare slim, alpine and distroless.
What's the security argument for distroless images?