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.
What is a Docker image?
Section titled “What is a Docker image?”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
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
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-c1A /only-in-c1$ docker diff c2 # nothing$ docker ps -s --format 'table {{.Names}}\t{{.Size}}'NAMES SIZEc2 0B (virtual 8.66MB)c1 4B (virtual 8.66MB)What is a Dockerfile?
Section titled “What is a Dockerfile?”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:1FROM alpine:3.24CMD ["echo", "Hello, Docker!"]Build and run it:
docker build -t hello .docker run --rm helloExpected output:
Hello, Docker!The build itself (plain progress, image already cached locally):
#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 doneNote #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.
Prove the layers
Section titled “Prove the layers”Look at the image you just built from three angles.
$ docker history helloIMAGE CREATED CREATED BY SIZE COMMENT216be2c70742 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=8660054docker 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 tblobs/sha256/1b349a3334531b575b01d409540dd8aca14ef29abddacd50f36643d7c949db4b # the layerblobs/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.
What this module covers
Section titled “What this module covers”| Lesson | What you will learn |
|---|---|
| This page | Images vs containers, layers, first build |
| Dockerfile basics | Core instructions: FROM, WORKDIR, COPY, RUN, ENV, EXPOSE |
| Build & tag | Build context, -t name:tag, docker images, .dockerignore |
| Layers & cache | How build cache works, instruction ordering for speed |
| CMD vs ENTRYPOINT | Exec form, shell form, default arguments, overrides |
Hands-on: your first build
Section titled “Hands-on: your first build”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 helloReading AI-generated code
Section titled “Reading AI-generated code”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=50RUN rm /tmp/big.binCMD ["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 lsdocker-b1-bloat 61.1MBdocker-b1-lean 8.66MB$ docker history bloatRUN /bin/sh -c rm /tmp/big.bin # buildkit 0BRUN /bin/sh -c dd if=/dev/zero of=/tmp/big.b… 52.4MB # still thereCMD ["/bin/sh"] 0B$ docker image inspect bloat --format 'layers={{len .RootFS.Layers}}'layers=361.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:
# @buildFROM alpine:3.24RUN dd if=/dev/zero of=/tmp/big.bin bs=1M count=50 2>/dev/null && rm /tmp/big.binCMD ["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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Many small layers | Fine-grained cache reuse and sharing | Deleted content still ships; more history noise |
Few big layers (chained &&) | Cleanup really shrinks the image | Any change rebuilds the whole layer |
# syntax=docker/dockerfile:1 | Latest Dockerfile features, same behaviour everywhere | An extra network fetch of the frontend on a cold cache |
Common Misconceptions
Section titled “Common Misconceptions”- “Each instruction creates a layer.” Only filesystem-changing instructions do. The hello image has 3 history entries and 1 layer.
- “
rmin a laterRUNmakes the image smaller.” It hides the file; the image stayed 61.1 MB. - “Stopping a container deletes its changes.”
docker rmdoes; 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.