Understanding Image Layers
Docker images are built from stacked, read-only layers. Each layer records a filesystem change, such as adding files, installing packages, or setting image metadata. Understanding layers matters because they explain why images share storage, why containers are cheap to start, and why a small Dockerfile change can make a build fast or painfully slow.
Overview: How Image Layers Work
An image is not one giant archive. It is a chain of layers plus configuration metadata. A base image such as alpine:3.20 already has one or more layers. When your Dockerfile runs instructions like RUN, COPY, and ADD, Docker records the resulting filesystem changes as additional layers. The final image points to the complete ordered stack.
Docker uses a union filesystem to present those layers as one normal-looking directory tree. On Linux, the common storage driver is overlay2. Docker Desktop on macOS and Windows runs Linux containers inside a Linux VM, but the concept is the same: multiple read-only image layers are mounted together, and the container sees one merged filesystem.
A container adds one more piece: a thin writable layer. When you run an image, Docker mounts the image layers read-only, creates a writable container layer on top, applies runtime settings, and starts the configured process. If the process writes /tmp/report.txt, that file goes into the container’s writable layer, not into the image. If the process modifies a file that came from the image, Docker uses copy-on-write: the file is copied into the writable layer and changed there. Removing the container removes that writable layer unless the data was stored in a volume or bind mount.
Layers are content-addressed. A registry stores compressed layer blobs by digest, plus manifests that describe which layers and image configuration make up an image. If two images share the same base layers, Docker downloads and stores those layers once. That is why building ten services from node:20-alpine does not require ten independent copies of the Node runtime layers.
Layers also drive the build cache. Docker processes a Dockerfile from top to bottom. For each instruction, BuildKit checks whether an equivalent previous result can be reused. If an instruction changes, or its input files change, that instruction is rebuilt and every instruction after it is rebuilt too. This is the key performance rule: put slow-changing setup before fast-changing application source.
Syntax
docker build -t IMAGE:TAG PATH
docker history IMAGE:TAG
docker image inspect IMAGE:TAG
docker image ls IMAGE
docker run --rm IMAGE:TAG
| Command | What it shows or does |
|---|---|
docker build -t IMAGE:TAG PATH |
Builds an image from a Dockerfile and build context. Each filesystem-changing instruction can become a layer. |
docker history IMAGE:TAG |
Shows the image’s layer history, including instruction text and approximate layer sizes. |
docker image inspect IMAGE:TAG |
Prints detailed JSON metadata, including root filesystem layer digests for pulled images. |
docker image ls IMAGE |
Lists local image references and their virtual sizes. |
docker run --rm IMAGE:TAG |
Creates a container from image layers, starts its process, then removes the container layer when it exits. |
Dockerfile syntax is just as important for layers:
FROM base-image:tag
WORKDIR /path
COPY source destination
RUN command
CMD ["executable", "argument"]
FROM chooses the parent image layer chain. WORKDIR, ENV, CMD, and EXPOSE mostly change image metadata, though some may still appear in history. RUN, COPY, and ADD are the instructions most associated with filesystem layers.
Examples
Example 1: Build a Tiny Image and View Its Layers
FROM alpine:3.20
RUN echo "hello from a layer" > /message.txt
CMD ["cat", "/message.txt"]
docker build -t layer-demo:1.0 .
docker history layer-demo:1.0
docker run --rm layer-demo:1.0
Output:
[+] Building 1.4s (6/6) FINISHED
IMAGE CREATED CREATED BY SIZE
layer-demo 10 seconds ago CMD ["cat" "/message.txt"] 0B
<missing> 10 seconds ago RUN /bin/sh -c echo "hello from a layer" ... 19B
<missing> 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:... in / 7.8MB
hello from a layer
The base Alpine image contributes its own layers. Your RUN instruction adds a tiny layer containing /message.txt. CMD changes the default runtime command but does not add file content, so its size is shown as 0B. The exact history formatting varies by Docker version, but the idea is stable: image history reveals the ordered build steps.
Example 2: Rebuild and Watch the Cache
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
USER node
CMD ["node", "server.js"]
docker build -t cached-api:1.0 .
Output:
[+] Building 0.9s (9/9) FINISHED
=> CACHED [2/5] WORKDIR /app
=> CACHED [3/5] COPY package*.json ./
=> CACHED [4/5] RUN npm ci --omit=dev
=> [5/5] COPY server.js ./
=> exporting to image
This layout preserves the dependency layer. If only server.js changes, Docker can reuse WORKDIR, the package-file copy, and npm ci. It rebuilds only the later application-source layer and anything after it. The pinned base tag node:20-alpine is deliberate: node or latest can move and make builds less reproducible.
Example 3: See the Container Writable Layer
docker run --name writable-demo alpine:3.20 sh -c "echo container-only > /created.txt"
docker diff writable-demo
docker rm writable-demo
Output:
C /root
A /created.txt
writable-demo
The image alpine:3.20 was not changed. The file was created in the stopped container’s writable layer, and docker diff reports filesystem changes relative to the original image. After docker rm, that writable layer is gone. Persistent application data should use a named volume or bind mount instead of relying on the container layer.
How It Works Step by Step
- The Docker client sends the Dockerfile and build context to the builder. Files ignored by
.dockerignoreare excluded before cache checks and copies. - The builder resolves the
FROMimage and makes sure its parent layers exist locally, pulling missing layers from a registry if needed. - For each Dockerfile instruction, BuildKit calculates whether the previous image state, instruction text, build arguments, and relevant input files match an existing cache record.
- If the cache matches, Docker reuses the existing layer or metadata result and moves to the next instruction.
- If the cache misses, Docker executes the instruction. For
RUN, it starts a temporary build container, runs the command, captures filesystem changes, and commits those changes as a new read-only layer. - When one instruction is rebuilt, every later instruction must be considered again because its parent filesystem has changed, even if the later instruction text is identical.
- When you run the final image, Docker mounts the image layers read-only, adds a writable container layer, connects configured networking and mounts, and starts the image’s
CMDorENTRYPOINT. - When you push an image, Docker uploads missing layer blobs and a manifest describing the layer order. Registries store layers by digest, so shared layers do not need to be uploaded twice.
Common Mistakes
Copying Everything Before Installing Dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
This works, but it is cache-hostile. Any source edit, README change, or stray local file can invalidate the COPY . . layer and force npm ci to run again. Fix it by copying dependency manifests first, installing dependencies, then copying application source:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
CMD ["node", "server.js"]
Deleting Files in a Later Layer to Shrink an Earlier Layer
FROM alpine:3.20
RUN dd if=/dev/zero of=/tmp/big-file bs=1M count=50
RUN rm /tmp/big-file
CMD ["sh"]
The final filesystem no longer shows /tmp/big-file, but the earlier layer can still contain it, so the image may remain large. Create and remove temporary build files in the same RUN instruction, or use a multi-stage build so build artifacts never enter the final image.
Baking Secrets into a Layer
FROM alpine:3.20
RUN echo "<YOUR_API_KEY>" > /tmp/api-key.txt
RUN rm /tmp/api-key.txt
CMD ["sh"]
This is unsafe even though the file is deleted later. Image layers and build history can preserve data from earlier steps. Secrets should come from runtime configuration, mounted secret files, Docker secrets, or your orchestrator’s secret store, not ordinary Dockerfile instructions.
Best Practices
- Order Dockerfile instructions from least-changing to most-changing to maximize cache reuse.
- Copy dependency manifests before source files, then run dependency installation, then copy the rest of the app.
- Use a focused
.dockerignoreso local caches, logs,node_modules, Git metadata, and secrets do not enter the build context. - Use pinned base image tags such as
alpine:3.20andnode:20-alpine; avoidlatestfor repeatable builds. - Combine package-manager install and cleanup in one
RUNinstruction when cleanup affects image size. - Use multi-stage builds to keep compilers, package caches, and source-only build artifacts out of production images.
- Do not store application data in the container writable layer. Use named volumes for persistent data and bind mounts for local development source code.
- Use
docker historyanddocker image inspectwhen an image is unexpectedly large or the cache is behaving differently than expected. - Remember that
EXPOSEis metadata only. Publish ports withdocker run -por Composeports:.
Practice Exercises
- Create a Dockerfile based on
alpine:3.20that writes two files using two separateRUNinstructions. Build it and inspectdocker history. Expected end state: you can identify which instruction created each file layer. - Take a small Node.js project and build it once with
COPY . .beforenpm ci, then again withpackage*.jsoncopied first. Edit onlyserver.jsand compare the cached build output. - Run an Alpine container that creates a file, inspect it with
docker diff, then remove the container. Hint: the image should still be unchanged after the container is gone.
Summary
- Docker images are ordered stacks of read-only filesystem layers plus image metadata.
- A container adds a thin writable layer on top of the image and starts a process.
- Shared layers save disk space, registry bandwidth, and build time.
- Build cache invalidation flows downward: changing one instruction rebuilds that instruction and later ones.
- Good layer order copies slow-changing dependency files before fast-changing application source.
- Deleting files in a later layer does not necessarily remove their bytes from earlier layers.
- Use volumes for persistent data, pinned tags for reproducible bases, and multi-stage builds for small production images.
