Docker Images
How Docker images work. Layers and build cache, why deleting files does not shrink an image, tags vs digests, reducing image size, and clearing dangling images.
An image is a read-only template containing a filesystem and the metadata needed to run it. Containers are created from images; images are built from Dockerfiles or pulled from registries.
Nearly everything worth knowing about images comes down to layers.
Layers
Each instruction in a Dockerfile produces a layer. A set of filesystem changes stacked on the one before. The final image is those layers combined and presented as one filesystem.
docker history nginx:1.27
IMAGE CREATED BY SIZE
a1b2c3d4e5f6 CMD ["nginx" "-g" "daemon off;"] 0B
<missing> EXPOSE map[80/tcp:{}] 0B
<missing> COPY docker-entrypoint.sh / # buildkit 1.62kB
<missing> RUN /bin/sh -c apt-get update && apt-get ins… 61.2MB
<missing> ADD file:4f4fb700ef54461cf… in / 80.5MB
docker history is the first command to reach for on any size problem. It shows exactly which instruction added the weight, which is rarely the one people expect.
Three properties follow, and between them they explain most image behaviour.
Layers are shared
Ten images built on the same base store that base once. This is why docker images can list ten images at 200 MB each while docker system df reports far less total disk:
docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 14 5 4.2GB 2.8GB (66%)
Layers are cached
A rebuild reuses every layer up to the first instruction whose inputs changed, then rebuilds everything after it. Instruction order therefore determines build speed:
# Dependencies change rarely. Install them first
COPY package*.json ./
RUN npm ci --omit=dev
# Source changes constantly. Copy it last
COPY . .
Reversing those two blocks means every source edit reinstalls every dependency. It is the most common Dockerfile performance mistake and costs minutes on each build.
Layers are additive: deleting does not shrink
A file created in one layer and removed in a later one is still in the image. The later layer adds a whiteout marker hiding it; the bytes remain in the earlier layer and still ship.
# Wrong. The archive is in the image forever
RUN curl -O https://example.com/big.tar.gz
RUN tar -xzf big.tar.gz && rm big.tar.gz
# Right. One layer, nothing left behind
RUN curl -O https://example.com/big.tar.gz \
&& tar -xzf big.tar.gz \
&& rm big.tar.gz
This catches nearly everyone once, usually with apt-get caches:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Tags and digests
A tag is a mutable pointer. nginx:1.27 today and nginx:1.27 next month can be different images, because the maintainer can repoint it.
docker pull nginx:1.27 # mutable
docker pull nginx@sha256:a1b2c3... # immutable content hash
For reproducible builds, and anything security-sensitive, pin the digest.
At minimum, never deploy latest. It is not special, just the default tag name, and it means your deployment depends on whatever was most recently pushed. Two deploys of "latest" can run different code, and a rollback has nothing specific to return to.
Tagging adds a name to an existing image rather than copying it:
docker tag myapp:1.4.2 registry.example.com/myapp:1.4.2
docker tag myapp:1.4.2 registry.example.com/myapp:stable
Both names now point at one image ID.
Reducing image size
Size affects pull time, registry storage, and cold start on every platform that schedules containers. Three techniques, in order of impact.
Choose a smaller base. node:22 is roughly 1.1 GB; node:22-alpine around 150 MB; distroless smaller still. Alpine uses musl rather than glibc, which occasionally breaks native modules. Test rather than assume.
Use a multi-stage build, so build tooling never reaches the final image:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
The compiler, dev dependencies and source are all absent from what ships. For compiled languages the effect is dramatic. A Go binary in a scratch image is a few megabytes.
Add a .dockerignore. Without one, COPY . . sends node_modules, .git and every local artifact into the build context and often into the image:
node_modules
.git
.env
dist
*.log
This speeds builds too, since the context is uploaded to the daemon before the build starts.
Dangling images and reclaiming disk
docker images
REPOSITORY TAG IMAGE ID SIZE
myapp 1.4.2 a1b2c3d4e5f6 142MB
<none> <none> f6e5d4c3b2a1 140MB
That <none> entry is a dangling image. The previous build of myapp, orphaned when the tag moved to the newer image. It still occupies disk.
Rebuilding the same tag repeatedly produces one every time, which is why disk usage climbs steadily during development and why Docker eventually reports no space left on device.
docker image prune # dangling only. Safe to run routinely
docker image prune -a # every image not used by a container
docker system df # see what is reclaimable first
One thing prune does not touch: container logs. A long-running container using the default json-file driver with no rotation can write tens of gigabytes to /var/lib/docker/containers/<id>/<id>-json.log. If docker system prune frees less than expected, that is usually where the space went:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
Put that in /etc/docker/daemon.json and restart Docker to cap it.
Registries
docker login registry.example.com
docker push registry.example.com/myapp:1.4.2
docker pull registry.example.com/myapp:1.4.2
Docker Hub is the default, so nginx means docker.io/library/nginx. Any other registry must be named explicitly.
Two practical notes. Docker Hub rate-limits anonymous pulls, which a busy CI pipeline hits quickly. Authenticate, or mirror base images into your own registry. And a push only transfers layers the registry does not already hold, so pushing a rebuilt image is usually fast.
Quick reference
| Task | Command |
|---|---|
| List images | docker images |
| Pull | docker pull <image>:<tag> |
| Build | docker build -t <name>:<tag> . |
| Tag | docker tag <source> <target> |
| Push | docker push <registry>/<name>:<tag> |
| Layer sizes | docker history <image> |
| Full metadata | docker inspect <image> |
| Remove one | docker rmi <image> |
| Remove dangling | docker image prune |
| Remove all unused | docker image prune -a |
| Disk usage | docker system df |
Key takeaways
- Each Dockerfile instruction is a layer, and layers are shared, cached and additive.
- Copy dependency manifests before source code so the install layer stays cached.
- Deleting a file in a later layer does not shrink the image: clean up inside the same
RUN. - Tags are mutable; digests are not. Never deploy
latest. - Multi-stage builds keep compilers and dev dependencies out of the shipped image.
<none>images are dangling: previous builds of a retagged image.docker image pruneclears them.docker system prunedoes not touch container logs. Cap them withmax-sizeindaemon.json.- Docker Hub rate-limits anonymous pulls. Authenticate or mirror in CI.
Check your understanding
An image that will not shrink
A Dockerfile downloads a 500 MB archive in one RUN and deletes it in the next. The image is still 500 MB larger than expected. Why?
Pruning that freed almost nothing
A server reports "no space left on device". docker system prune -a removes several images but frees little space. What is the likely cause?
Frequently Asked Questions
What is the difference between a Docker image and a container?
An image is a read-only template made of stacked filesystem layers; a container is a running instance created from it with its own thin writable layer. One image can produce any number of containers, all sharing the same underlying layers, which is why running ten containers from one image consumes very little extra disk. Images are built and pulled; containers are run, stopped and removed.
How do I reduce my Docker image size?
Three things, in order of impact. Switch to a smaller base, node:22-alpine is around 150 MB against roughly 1.1 GB for node:22. Use a multi-stage build so compilers and development dependencies stay in the build stage and never ship. Add a .dockerignore so node_modules, .git and local artifacts are not copied in. Then run docker history to see which instruction is actually adding the weight, because it is often not the one you would guess.
Why doesn't deleting files reduce my Docker image size?
Because layers are additive and all of them ship together. A file created in one layer and removed in a later one is hidden by a whiteout marker, but its bytes remain in the earlier layer and are still part of the image. Anything downloaded and then deleted must be handled within a single RUN instruction chained with &&, so only the net result is committed. The same applies to package manager caches. Clean them in the same RUN that installed the packages.
What are dangling images and is it safe to delete them?
They are the <none> entries in docker images. Previous builds whose tag was reassigned to a newer image, leaving them unreferenced. They are entirely safe to remove, and docker image prune does exactly that. They accumulate quickly during development because every rebuild of the same tag creates one, which is the usual reason disk usage climbs steadily and eventually produces "no space left on device".
Why did docker system prune not free much disk space?
Almost certainly container logs, which prune never touches. With the default json-file log driver and no rotation, a long-running container can write tens of gigabytes to /var/lib/docker/containers/<id>/<id>-json.log. Check that directory's size directly. Fix it by setting "log-opts": {"max-size": "10m", "max-file": "3"} in /etc/docker/daemon.json and restarting Docker. Named volumes are the other common culprit, since they are only removed with --volumes.
Should I use the latest tag?
No. latest is not special. It is simply the default tag name, pointing at whatever the maintainer most recently pushed, which can change without warning. Deploying it means two deploys of the same nominal version can run different code, and a rollback has no specific artifact to return to. Pin an explicit version tag, and for anything security-sensitive pin the digest with image@sha256:..., which is a content hash and genuinely immutable.