Skip to content

Small Base Images & .dockerignore

When you run docker build ., Docker packages everything in the current directory into a build context and sends it to the build daemon. Large contexts slow down every build, even if most files are never used by a COPY instruction.

Sending build context to Docker daemon 823.4MB ← node_modules included!

A .dockerignore file tells Docker which paths to exclude from the build context — exactly like .gitignore does for Git.

Create .dockerignore in the same directory as your Dockerfile:

# Dependencies — reinstalled inside the image
node_modules/
vendor/
# Version control
.git/
.gitignore
# Editor and OS noise
.DS_Store
*.log
.env
.env.*
# Test and CI files not needed at runtime
__tests__/
coverage/
.github/
*.test.ts
*.spec.ts

After adding this file the build context drops from hundreds of MB to a few KB for a typical Node.js project. As an added bonus, excluding .env files prevents secrets from accidentally leaking into the image.

The base image is often the largest single contributor to image size. Here are the common options, from largest to smallest:

BaseApproximate sizeNotes
ubuntu:24.04~78 MBFull apt ecosystem; large
debian:bookworm~117 MBDebian full; common
node:22~1.1 GBIncludes full Debian + devtools
node:22-slim~235 MBDebian slim; no devtools
node:22-alpine~55 MBAlpine Linux; smallest with shell
cgr.dev/chainguard/node~50 MBDistroless; minimal, no shell
scratch0 MBTruly empty; for static binaries

Alpine is based on musl libc and BusyBox. It is the most popular small base for containers because:

  • The base image is under 5 MB.
  • apk package manager covers most common tools.
  • Official images for Node.js, Python, Go, Ruby, and others all publish -alpine variants.
FROM node:22-alpine

One caveat: Alpine uses musl instead of glibc. Some packages compiled against glibc (e.g., certain native Node addons) need extra work. For pure-JS applications it is nearly always drop-in.

Google’s distroless images contain only the language runtime — no shell, no package manager, no ls or cat. They are smaller and present a much smaller attack surface because there is no tooling for an attacker to abuse.

FROM gcr.io/distroless/nodejs22-debian12

Because there is no shell, CMD and ENTRYPOINT must use exec form (JSON array):

CMD ["dist/server.js"]

When you must install packages with apt-get, always clean up the package lists in the same RUN layer:

RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

--no-install-recommends skips optional recommended packages. rm -rf /var/lib/apt/lists/* removes the downloaded package index from the layer — if you put it in a separate RUN instruction the size is already committed and the cleanup has no effect.

Hands-on: compare build contexts and base images

Section titled “Hands-on: compare build contexts and base images”

The snippet below demonstrates the .dockerignore pattern and an Alpine base image.

# syntax=docker/dockerfile:1

# .dockerignore (create alongside this Dockerfile):
# node_modules/
# .git/
# .env
# coverage/

FROM node:22-alpine
WORKDIR /app

# Only manifests first for cache efficiency
COPY package.json ./
RUN echo '{}' > package-lock.json

# Application source (node_modules excluded by .dockerignore)
COPY src/ ./src/ 2>/dev/null || true
RUN echo "console.log('Running on Alpine, image is tiny!');" > src/index.js 2>/dev/null || true

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node","src/index.js"]

# --- Build and check the size ---
# docker build -t myapp:alpine .
# docker images myapp:alpine
What is the primary purpose of a .dockerignore file?
Why must `rm -rf /var/lib/apt/lists/*` be in the SAME RUN instruction as `apt-get install`?
Which of the following base images is the smallest and most commonly used for Node.js production images?
What is a key limitation of distroless images?