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.envThe 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.5MBThat 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:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07dThe 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.