Gendi Notes

Docker Images and Registries

An image is a stack of read-only layers. Understanding that one fact explains build caching, image size, and why deleted secrets are still in there.

Level 2 guide dockerimagesregistrylayers

What is it?#

An image is the packaged, immutable result of a build: your application, its dependencies, and a small filesystem, ready to run anywhere.

It is not one file. It is a stack of layers, each recording the filesystem changes made by one build instruction.

Dockerfile resulting layers ────────── ──────────────── FROM node:22-alpine → [ base OS + node ] ← shared with other images WORKDIR /app → [ (metadata only) ] COPY package*.json ./ → [ + package.json ] RUN npm ci → [ + node_modules ] ← the big, slow one COPY . . → [ + your source ] CMD [...] → [ (metadata only) ] a container adds one thin WRITABLE layer on top

Why layers exist#

Two big wins, and one trap.

Caching. If an instruction and its inputs have not changed, Docker reuses the existing layer instead of re-running it. This is why build order matters enormously.

Sharing. Ten images built FROM node:22-alpine store that base once. Pulling the tenth downloads only its unique layers.

The trap: layers are append-only. A file added in one layer and deleted in a later one is still present in the earlier layer, and anyone with the image can extract it.

How to use it#

Write a Dockerfile that caches well#

Order instructions from least likely to change to most likely:

Ordered for caching
FROM node:22-alpine

WORKDIR /app

# Changes rarely → stays cached across every source edit
COPY package*.json ./
RUN npm ci --omit=dev

# Changes constantly → put it last
COPY . .

CMD ["node", "server.js"]

Multi-stage builds — the fix for huge images#

Compile with a full toolchain, then copy only the result into a clean image:

Multi-stage: compilers never reach production
# ---- stage 1: build (has the full toolchain) ----
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- stage 2: runtime (only what is needed to run) ----
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Nothing from stage 1 exists in the final image — no compilers, no build caches, no dev dependencies. Typically a 5–10× size reduction, and a much smaller attack surface.

Always add .dockerignore#

Without it, COPY . . uploads your entire directory into the build:

.dockerignore
node_modules
.git
.env
*.log
dist
Dockerfile

This makes builds faster and stops local secrets and .git history from ending up in the image.

Choosing a base image#

BaseSizeTrade-off
node:22~1.1 GBEverything included. Fine for a build stage
node:22-slim~250 MBDebian, trimmed. Good default
node:22-alpine~150 MBSmallest with a shell. Uses musl, not glibc
gcr.io/distroless/nodejs22~120 MBNo shell at all. Most secure, hardest to debug

Tags, digests, and why latest is dangerous#

docker build -t myapp:1.4.2 -t myapp:latest .
docker push myapp:1.4.2
ReferenceImmutable?
myapp:latestNo — just a label someone can move
myapp:1.4.2Only by convention. It can be overwritten
myapp@sha256:abc123...Yes — a content hash
# Pin exactly, by digest — the same bytes forever
docker pull myapp@sha256:abc123...

# Find the digest of what you have
docker inspect --format='{{index .RepoDigests 0}}' myapp:1.4.2

Registries#

docker login registry.example.com
docker tag myapp:1.4.2 registry.example.com/team/myapp:1.4.2
docker push registry.example.com/team/myapp:1.4.2
docker pull registry.example.com/team/myapp:1.4.2

An image name is really registry/namespace/name:tag. When the registry is omitted, Docker assumes Docker Hub — which is why nginx works and why hitting Docker Hub's anonymous rate limit in CI is so common. Authenticate, or use a mirror.

Inspecting and shrinking#

docker images                              # sizes
docker history myapp:1.4.2                 # SIZE PER LAYER — find the fat one
docker image inspect myapp:1.4.2

# What is actually inside? A shell in a throwaway container.
docker run --rm -it --entrypoint sh myapp:1.4.2

docker system df                           # what is using disk
docker builder prune                        # clear the build cache
docker system prune -a                      # remove unused images too

docker history is the tool for size problems. It shows exactly which instruction added the 400 MB.

Security basics worth doing#

# Run as a non-root user. Many base images provide one.
USER node

# Or create one
RUN adduser -D -u 10001 appuser
USER appuser
docker scout cves myapp:1.4.2    # scan for known vulnerabilities
# or: trivy image myapp:1.4.2

Containers run as root by default. If an attacker escapes the process, root inside the container is a much better starting position than an unprivileged user. Adding two lines removes that.

Common problems#

SymptomCauseFix
Every build is slowCOPY . . before dependency installReorder
Image is 1.5 GBBuild tools in the final imageMulti-stage + slim base
exec format errorWrong CPU architecture (arm64 vs amd64)docker build --platform linux/amd64
no such file or directory on a binary that existsmusl vs glibc on AlpineUse -slim
Code change not taking effectRan the old image, or cached layerCheck the tag; --no-cache to confirm
toomanyrequests in CIDocker Hub anonymous rate limitAuthenticate, or mirror
Different pods, different codeDeployed latestDeploy immutable tags

Interview corner · الأسئلة اللي بتتسأل#

Explain Docker layer caching, and how you would order a Dockerfile for a monorepo.

Each instruction produces a layer keyed on the instruction plus its inputs. If the key is unchanged the layer is reused — but invalidating one layer invalidates every layer after it, whether or not those would have changed.

So the rule is: order least-likely-to-change first.

For a monorepo the naive COPY . . is fatal, because a change to any package invalidates the install for every package:

# Copy only the manifests first — one COPY per package, or a glob that
# matches manifests only. A change in service B's source no longer
# invalidates service A's dependency install.
COPY package.json pnpm-lock.yaml ./
COPY packages/a/package.json packages/a/
COPY packages/b/package.json packages/b/
RUN pnpm install --frozen-lockfile

# Only now bring in source
COPY . .
RUN pnpm --filter a build
Alpine or Debian slim for production? · نستخدم alpine ولا slim؟

The expected answer is a trade-off, not a winner.

alpine-slim (Debian)
Size~150 MB~250 MB
libcmuslglibc
Native modulesSometimes breakWork
DNS resolutionmusl historically weaker (no search fallback for some cases)Standard glibc behaviour
Package availabilityapk, smaller ecosystemapt, everything

When alpine is right: Go or Rust static binaries (where libc barely matters), simple interpreted apps, and anywhere image size genuinely dominates — huge fleets, tight bandwidth.

When to use slim: anything with native modules (Python wheels with C extensions, node-gyp builds), anything latency-sensitive to DNS, and any time you find yourself debugging musl for more than half an hour.

Key takeaways#

  • An image is a stack of append-only layers. Nothing is ever really removed.
  • Order the Dockerfile least-changing first. Dependencies before source.
  • Multi-stage builds keep compilers out of production — usually 5–10× smaller.
  • .dockerignore speeds builds and keeps .git and .env out.
  • Never deploy latest. Use a version or the Git SHA.
  • A secret in a layer is in the image. Use BuildKit secret mounts; if it leaked, rotate it.
  • docker history finds the layer that made your image huge.
  • Add USER — do not run as root.

Last updated 2026-09-08