CertGrid CertGrid
Hands-on Lab·Docker

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

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.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. 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 step

    Expected resultNo output. The directory now holds the script.

    Success conditionls shows hello.sh. Keep this directory minimal - its entire contents are uploaded to the builder on every build.

  2. 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 Dockerfile with no extension - that is the name docker build looks for by default.

  3. 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 #6 block 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.6s

    Expected 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.

  4. 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.2

    Expected 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.

  5. 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.1s

    Expected 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.

  6. See what each instruction cost

    docker history shows 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.96MB

    Expected resultOne row per layer, newest first, with sizes.

    Success conditionYou can attribute the image size to specific instructions. Note chmod created a whole 12.3kB layer just to change one permission bit - a reason to set permissions in COPY where possible.

  7. 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# done

    Expected resultUntagged and deleted lines from rmi.

    Success conditiondocker images cg-hello returns nothing.

Troubleshooting

Official sources