Docker Images

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

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

TaskCommand
List imagesdocker images
Pulldocker pull <image>:<tag>
Builddocker build -t <name>:<tag> .
Tagdocker tag <source> <target>
Pushdocker push <registry>/<name>:<tag>
Layer sizesdocker history <image>
Full metadatadocker inspect <image>
Remove onedocker rmi <image>
Remove danglingdocker image prune
Remove all unuseddocker image prune -a
Disk usagedocker 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 prune clears them.
  • docker system prune does not touch container logs. Cap them with max-size in daemon.json.
  • Docker Hub rate-limits anonymous pulls. Authenticate or mirror in CI.

Check your understanding

Exercise

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?

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources