Building Images with Dockerfiles
Write a five-instruction Dockerfile, build it, run it, then build it again and watch the cache turn a 0.6 second build into 0.1. Includes the real BuildKit output and how to read the layer sizes it produces.
Dockerfiles and Builds Guide 14 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Shellbash
- Architectureamd64
- TimeAbout 16 min
- Reviewed20 August 2026
Tested on the versions above. Build step numbers, digests, layer sizes and timings vary per run and per host. Match the shape of the output, not the exact strings.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- Docker Engine running, and comfort with
docker runanddocker images- guides 3 to 5 in this path. - A writable working directory. This guide uses /tmp/cg-build.
- Outbound HTTPS so the base image and packages can be fetched.
-
Create a build context
The build context is the directory you hand to
docker build. Everything in it is sent to the builder, which is why you build from a small dedicated directory rather than your home folder. Two files here: a script to run, and the Dockerfile describing how to package it.bash mkdir -p /tmp/cg-build && cd /tmp/cg-buildprintf '#!/bin/sh\necho "Hello from a Dockerfile-built image"\ncurl --version | head -1\n' > hello.sh# two files: hello.sh and the Dockerfile written in the next stepExpected resultNo output. The directory now holds the script.
Success condition
lsshows hello.sh. Keep this directory minimal - its entire contents are uploaded to the builder on every build. -
Write the Dockerfile
Five instructions, each doing one thing. FROM picks the base image. RUN executes a command at build time and commits the result as a layer. WORKDIR sets the directory for what follows. COPY brings a file in from the build context. CMD records the default command - it runs when the container starts, not during the build.
bash cat /tmp/cg-build/DockerfileFROM alpine:3.22RUN apk add --no-cache curlWORKDIR /appCOPY hello.sh .RUN chmod +x hello.shCMD ["./hello.sh"]Expected resultThe six lines exactly as written.
Success conditionThe file exists and is named
Dockerfilewith no extension - that is the namedocker buildlooks for by default. -
Build the image
BuildKit prints one numbered block per step. Read them as a dependency graph rather than a script: it loads the definition, resolves the base image, transfers the context, then executes each instruction. The
#6block is the package install, with per-line timings showing where the time actually went. Output is abridged - the real build also prints the export stage.bash Example session cd /tmp/cg-build && docker build -t cg-hello:1.0 .#1 [internal] load build definition from Dockerfile#1 transferring dockerfile: 152B done#2 [internal] load metadata for docker.io/library/alpine:3.22#5 [internal] load build context#5 transferring context: 113B done#6 [2/5] RUN apk add --no-cache curl#6 1.278 (9/9) Installing curl (8.14.1-r3)#6 1.357 OK: 12 MiB in 25 packages#6 DONE 1.4s#10 exporting to image#10 naming to docker.io/library/cg-hello:1.0 done#10 DONE 0.6sExpected resultNumbered blocks ending with the image being named and DONE.
Success conditionThe final block names your image. A failed build stops at the block that failed and prints its error - the step number tells you which instruction.
-
Confirm the image exists and run it
The image is now in the local store like any pulled image. Running it executes the CMD you recorded.
bash Example session docker images cg-hello --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}"REPOSITORY:TAG SIZE CREATEDcg-hello:1.0 20.5MB 52 seconds agodocker run --rm cg-hello:1.0Hello from a Dockerfile-built imagecurl 8.14.1 (x86_64-alpine-linux-musl) libcurl/8.14.1 OpenSSL/3.5.7 zlib/1.3.2Expected resultThe image listed, then both lines your script prints.
Success conditionThe script output appears. The curl line proves the RUN instruction genuinely installed the package into the image.
-
Build again and watch the cache
Nothing changed, so BuildKit reuses every layer. The export block drops from 0.6s to 0.1s and no package installation happens at all. This is why instruction order matters: put the things that rarely change - the base image, dependency installation - before the things that change constantly, such as your source code. Change an early instruction and every layer after it is rebuilt.
bash Example session cd /tmp/cg-build && docker build -t cg-hello:1.0 .#10 exporting config sha256:47d02c8e6b277fc8ed4552cf3f198b33fb356b32da7a11d5ca2dac06060ee227 done#10 naming to docker.io/library/cg-hello:1.0 done#10 DONE 0.1sExpected resultA much shorter build with no apk output.
Success conditionThe second build is dramatically faster and the install step does not reappear. If it does rerun, something in an earlier instruction changed.
-
See what each instruction cost
docker historyshows the layers in reverse order with the instruction that created each. This is the fastest way to find why an image is large - here the base rootfs and the curl install account for almost all of the 20.5MB, while the application layers are kilobytes.bash Example session docker history cg-hello:1.0 --format "table {{.CreatedBy}}\t{{.Size}}"CREATED BY SIZECMD ["./hello.sh"] 0BRUN /bin/sh -c chmod +x hello.sh # buildkit 12.3kBCOPY hello.sh . # buildkit 12.3kBRUN /bin/sh -c apk add --no-cache curl # bui… 5.27MBADD alpine-minirootfs-3.22.5-x86_64.tar.gz /… 8.96MBExpected resultOne row per layer, newest first, with sizes.
Success conditionYou can attribute the image size to specific instructions. Note
chmodcreated a whole 12.3kB layer just to change one permission bit - a reason to set permissions in COPY where possible. -
Clean up
Remove the image and the build directory. The base image stays cached, so a rebuild later is fast.
bash # removes the image you just built; the alpine base image is left in placedocker rmi cg-hello:1.0rm -rf /tmp/cg-build# doneExpected resultUntagged and deleted lines from rmi.
Success condition
docker images cg-helloreturns nothing.
Troubleshooting
COPY failed: file not found in build context
Why: The path in COPY is relative to the build context, not to the Dockerfile or your shell. A file outside the context directory cannot be copied in at all.
Fix:Move the file into the context, or build from a directory that contains it. Check what is actually being sent - the
transferring contextline reports the size.bash ls -l /tmp/cg-build# the file named in COPY must appear hereThe build reruns every step even though nothing changed
Why: An early instruction produced different output - often a COPY of a file whose timestamp or contents changed, which invalidates that layer and everything after it.
Fix:Move volatile COPY instructions as late as possible, and copy dependency manifests separately from source so dependency installs stay cached.
bash docker build --progress=plain -t cg-hello:1.0 . 2>&1 | grep -E "CACHED|RUN"# CACHED marks the layers that were reusedThe image is far larger than expected
Why: Package manager caches or build tools left inside a layer. Deleting them in a later instruction does not shrink the image - the earlier layer still contains them.
Fix:Clean up within the same RUN that created the files, as
apk add --no-cachedoes here. Usedocker historyto find the offending layer.bash docker history cg-hello:1.0 --format "{{.Size}}\t{{.CreatedBy}}" | sort -h -r | head -3