Docker – Images, Layers and Tags

June 21, 20264 min readUpdated 8/21/2026

Almost everything that is surprising about Docker — why the second build is instant, why your image is 460 MB, why deleting a file does not shrink it, why ten containers cost one image's disk — follows from one structural fact. An image is a stack of read-only layers.

The stack

Each layer is a set of filesystem changes: files added, modified or deleted relative to the layer below. They are stacked and presented to the container as one filesystem by a union filesystem (overlay2, on this machine and most others).

Here is the API image from this track, real output, read bottom-up:

$ docker history pizza-api:dev
SIZE      CREATED BY
0B        ENTRYPOINT ["java" "-jar" "app.jar"]
0B        EXPOSE map[8085/tcp:{}]
0B        USER pizza
0B        RUN mkdir -p /app/uploads/products && chown -R pizza:pizza /app/uploads
336kB     COPY /build/extracted/application/ ./
0B        COPY /build/extracted/snapshot-dependencies/ ./
0B        COPY /build/extracted/spring-boot-loader/ ./
120MB     COPY /build/extracted/dependencies/ ./
0B        WORKDIR /app
4.72kB    RUN groupadd --system pizza && useradd --system --gid pizza ... pizza
0B        ENTRYPOINT ["/__cacert_entrypoint.sh"]
5.31kB    COPY --chmod=755 entrypoint.sh /__cacert_entrypoint.sh
165MB     RUN set -eux; ARCH="$(dpkg --print-architecture)"; ...   ← the JRE
54.9MB    RUN apt-get update; apt-get install -y --no-install-recommends ...
0B        ENV LANG=en_US.UTF-8 ...

Read that and the image stops being a mystery. 165 MB of it is a Java runtime. 55 MB is the base OS packages. 120 MB is the application's dependencies. The application itself is 336 kB — 0.07% of a 460 MB image.

Two things fall out immediately. Optimising your own code's size is pointless; the base image is the decision that matters (lesson 8). And the 120 MB dependency layer is separate from the 336 kB application layer on purpose — lesson 6 is about why.

Some instructions cost nothing

Notice how many layers are 0B. ENV, EXPOSE, USER, WORKDIR, ENTRYPOINT and CMD change metadata, not files. They are free.

RUN, COPY and ADD change the filesystem, so they cost whatever they wrote.

Deleting a file does not make the image smaller

This is the consequence people trip over, and it is worth understanding rather than memorising.

COPY secrets.env /tmp/secrets.env
RUN ./configure-with secrets.env
RUN rm /tmp/secrets.env

The final image does not show /tmp/secrets.env. It is still there. The COPY layer holds the file; the rm layer holds a marker saying "this file is deleted", and the union filesystem hides it. Both layers ship.

So the image is no smaller, and — the part that matters — anyone who pulls the image can recover the file by reading that layer. This is the single most common way a credential gets published. Lesson 9 covers what to do instead.

The same logic explains the classic apt-get incantation:

# Bad — two layers, the cache ships in the first one
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good — one layer, the cache never exists at the end of it
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

The && chain is not stylistic. It makes the download and its cleanup the same layer, so the cache is never committed at all.

Layers are shared

Layers are content-addressed — identified by a hash of what is in them — so identical layers are stored once and reused everywhere.

Both frontend images in this track are built FROM nginxinc/nginx-unprivileged:1.27-alpine:

$ docker image ls
REPOSITORY          TAG   SIZE
pizza-web           dev   50.8MB
pizza-web-angular   dev   50.5MB

That is not 101 MB on disk. The 49 MB nginx base is one copy, shared; the two images differ by about 1 MB of built JavaScript each. docker system df shows the real total.

The same sharing is what makes a pull fast: a registry only sends layers you do not already have.

The container's writable layer

When you start a container, Docker adds one thin writable layer on top of the image's read-only stack. Every write the container makes lands there — copy-on-write, so modifying a large file from a lower layer first copies it up.

That layer belongs to the container, not the image. Remove the container and it is gone. This is why a database in a container needs a volume, which is lesson 11.

Tags, and why a tag is not an identity

nginxinc/nginx-unprivileged:1.27-alpine
└──────── repository ─────────┘ └─ tag ─┘

A tag is a mutable pointer. Nothing stops the owner of a repository moving 1.27-alpine to different bytes tomorrow, and for :latest that is guaranteed to happen. Pull the same tag on two machines a week apart and you can get two different images.

What does not move is the digest:

$ docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.21
alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d

$ docker pull alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d

The digest is a hash of the image's content, so it identifies exactly one image, permanently. Tags are for humans. Digests are for anything that has to be reproducible — which is why lesson 18 argues against deploying :latest, and lesson 9 pins base images by digest.

Next: writing the file that produces one.