Skip to content

Images & the Dockerfile — Module Overview

Measured on Docker Desktop 4.81.0 / Engine 29.6.1, Buildx v0.35.0-desktop.2, BuildKit v0.31.1 (macOS, linux/arm64), 2026-09-30.

A Docker image is a read-only package that contains everything needed to run a program — the OS filesystem, runtime, libraries, application code, and default configuration. Think of it as a recipe: the recipe itself never changes, but you can bake as many cakes (containers) from it as you like.

Images are built from stacked, read-only layers. Each layer represents a change — a file added, a package installed, a configuration written. When you stack layers on top of each other you get a complete filesystem, but Docker only stores (and transfers) the layers that have changed, keeping images lean.

flowchart TB
  img["Image (read-only)"]
  l3["Layer 3: COPY app/ /app/ (your code)"]
  l2["Layer 2: RUN npm install (dependencies)"]
  l1["Layer 1: FROM node:24-alpine (base OS + runtime)"]
  img --> l3 --> l2 --> l1
An image is a stack of read-only layers

A running container is an image with one extra writable layer on top. The image is untouched — the container writes to its own scratch layer. Remove the container (docker rm) and that layer disappears (unless you save it); merely stopping the container keeps it (proved in the lifecycle lesson).

flowchart TB
  con["Container (running)"]
  w["Writable layer (container-specific changes, deleted with docker rm)"]
  l3["Layer 3 (read-only)"]
  l2["Layer 2 (read-only)"]
  l1["Layer 1 (read-only)"]
  con --> w --> l3 --> l2 --> l1
A container adds a writable layer on top of the image

Two containers of the same image, one of which wrote a 4-byte file: docker diff shows the change only on that container, and docker ps -s shows the writable layer as 4B on top of the shared virtual 8.66MB image:

$ docker diff c1 # wrote /only-in-c1
A /only-in-c1
$ docker diff c2 # nothing
$ docker ps -s --format 'table {{.Names}}\t{{.Size}}'
NAMES SIZE
c2 0B (virtual 8.66MB)
c1 4B (virtual 8.66MB)

A Dockerfile is a plain-text file of instructions that tells Docker how to build an image. Some instructions create a layer (RUN, COPY, ADD) and some only record metadata (CMD, ENV, EXPOSE, WORKDIR when the directory exists). You run docker build to turn a Dockerfile into an image, and docker run to start a container from that image.

Here is the simplest possible Dockerfile:

# Dockerfile
# @build
# syntax=docker/dockerfile:1
FROM alpine:3.24
CMD ["echo", "Hello, Docker!"]

Build and run it:

Terminal window
docker build -t hello .
docker run --rm hello

Expected output:

Hello, Docker!

The build itself (plain progress, image already cached locally):

3.24
#1 [internal] load build definition from Dockerfile
#2 resolve image config for docker-image://docker.io/docker/dockerfile:1 # from the "# syntax" line
#7 [1/1] FROM docker.io/library/alpine:3.24
#7 CACHED
#8 exporting to image
#8 naming to docker.io/library/hello done

Note #2: the # syntax=docker/dockerfile:1 line makes BuildKit fetch the Dockerfile frontend image from Docker Hub (a network call the first time; then cached). Drop the line for an offline build with the frontend bundled in BuildKit; keep it when you want the newest Dockerfile features.

Look at the image you just built from three angles.

$ docker history hello
IMAGE CREATED CREATED BY SIZE COMMENT
216be2c70742 12 days ago CMD ["echo" "Hello, Docker!"] 0B buildkit.dockerfile.v0
<missing> 12 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 12 days ago ADD alpine-minirootfs-3.24.2-aarch64.tar.gz … 8.66MB buildkit.dockerfile.v0
$ docker image inspect hello --format 'layers={{len .RootFS.Layers}} size={{.Size}}'
layers=1 size=8660054

docker history lists three history entries, but the image has exactly one filesystem layer (RootFS.Layers): the two CMD entries are metadata with size 0B. History entries and layers are not the same thing. <missing> just means the entry belongs to a parent image you did not build locally.

docker save shows the layers as files: the image archive contains one blob per layer (the 1b349a33… tar is the 8.66 MB root filesystem, identical to the one in alpine:3.24) plus the config and manifest JSON:

$ docker save hello | tar t
blobs/sha256/1b349a3334531b575b01d409540dd8aca14ef29abddacd50f36643d7c949db4b # the layer
blobs/sha256/216be2c707428d3874e3ad6a2b12828c9aef44f61fa5f4730f80b1c5cbcf3ac5 # image config
...
manifest.json
$ docker save hello | tar xO manifest.json # "Layers": ["blobs/sha256/1b349a33…"]

Layers are content-addressed: docker image inspect alpine:3.24 --format '{{index .RootFS.Layers 0}}' printed the same sha256:1b349a33…. Every image built FROM alpine:3.24 reuses that layer on disk instead of copying it.

LessonWhat you will learn
This pageImages vs containers, layers, first build
Dockerfile basicsCore instructions: FROM, WORKDIR, COPY, RUN, ENV, EXPOSE
Build & tagBuild context, -t name:tag, docker images, .dockerignore
Layers & cacheHow build cache works, instruction ordering for speed
CMD vs ENTRYPOINTExec form, shell form, default arguments, overrides

Paste the snippet below into a Play with Docker session, run the two commands, and see Docker build a tiny image from scratch.

# syntax=docker/dockerfile:1
FROM alpine:3.24
CMD ["echo", "Hello, Docker!"]
# --- build & run ---
# docker build -t hello .
# docker run --rm hello

An AI-written Dockerfile that “cleans up after itself to keep the image small”:

🐛 Spot the bug

FROM alpine:3.24
# AI: "generate the data, then delete it so the final image stays small"
RUN dd if=/dev/zero of=/tmp/big.bin bs=1M count=50
RUN rm /tmp/big.bin
CMD ["ls", "-la", "/tmp"]
Show the answer

The file is gone from the running container, but the image did not shrink: layers are additive. The rm only adds a “whiteout” layer that hides the file; the layer that created it still ships. Built and measured:

$ docker image ls
docker-b1-bloat 61.1MB
docker-b1-lean 8.66MB
$ docker history bloat
RUN /bin/sh -c rm /tmp/big.bin # buildkit 0B
RUN /bin/sh -c dd if=/dev/zero of=/tmp/big.b… 52.4MB # still there
CMD ["/bin/sh"] 0B
$ docker image inspect bloat --format 'layers={{len .RootFS.Layers}}'
layers=3

61.1 MB versus 8.66 MB for the same visible result. Every downstream docker pull pays for the 52.4 MB. Create and delete in the same RUN, so the layer is captured after the cleanup:

Dockerfile.lean
# @build
FROM alpine:3.24
RUN dd if=/dev/zero of=/tmp/big.bin bs=1M count=50 2>/dev/null && rm /tmp/big.bin
CMD ["ls", "-la", "/tmp"]

The same rule is why package-manager cleanup (apt-get install … && rm -rf /var/lib/apt/lists/*) must be in the instruction that installs. For secrets it is worse: a secret written in one layer and deleted in the next is still recoverable from the earlier layer.

An image is a stack of filesystem diffs plus a config (default command, environment, ports). It only ever grows upward: a later layer can hide a file but never remove bytes from a lower one. A container is that stack, made writable through a copy-on-write layer on top, plus a running process. Layers are shared by content hash, which is why ten images on the same base cost one base.

ChoiceBenefitCost
Many small layersFine-grained cache reuse and sharingDeleted content still ships; more history noise
Few big layers (chained &&)Cleanup really shrinks the imageAny change rebuilds the whole layer
# syntax=docker/dockerfile:1Latest Dockerfile features, same behaviour everywhereAn extra network fetch of the frontend on a cold cache
  • “Each instruction creates a layer.” Only filesystem-changing instructions do. The hello image has 3 history entries and 1 layer.
  • “rm in a later RUN makes the image smaller.” It hides the file; the image stayed 61.1 MB.
  • “Stopping a container deletes its changes.” docker rm does; stopping keeps the writable layer.
  • “An image is a VM disk.” It has no kernel or init; it is a filesystem plus metadata that a container runtime unpacks and runs on the host kernel.
What is a Docker image?
What happens to a container's writable layer when the container is removed?
Which command turns a Dockerfile into an image?
docker history for a 2-line Dockerfile (FROM alpine:3.24, CMD [...]) lists three entries but RootFS.Layers has 1 element. Why?
A Dockerfile has RUN dd ... /tmp/big.bin (50 MB) then a separate RUN rm /tmp/big.bin. What is the image size compared with doing both in one RUN?