Building images: Dockerfiles, the layer cache and multi-stage builds
Package your own app as an image: write a Dockerfile, order it so rebuilds take seconds, and use a multi-stage build to ship a 14.6MB non-root image instead of a 508MB one with a compiler in it.
By the end of this lesson you'll be able to
0 of 5 completeThe app
Until now you ran images other people built. Now you'll package your own: a small Go API with one endpoint.
// main.go package main import ( "fmt" "log" "net/http" "os" ) func main() { version := os.Getenv("APP_VERSION") http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "{\"status\":\"ok\",\"version\":%q}\n", version) }) log.Println("price-api listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
Next to it, go.mod (module price-api and go 1.27). The same ideas apply to a Node, Python or Java app;
only the build commands change.
A first Dockerfile
A Dockerfile is the recipe. Each instruction is one step, and most steps produce a layer:
FROM golang:1.27-alpine # start from an image with Go installed WORKDIR /src # cd into /src (created if missing) COPY . . # copy the build context into /src RUN go build -o /price-api . # run a command at build time CMD ["/price-api"] # the default command when a container starts
$ docker build -t price-api:single -f Dockerfile.single . $ docker run -d --name api -p 8090:8080 -e APP_VERSION=1.0.0 price-api:single $ curl -s localhost:8090/health {"status":"ok","version":"1.0.0"}
docker build -t price-api:single -f Dockerfile.single .dockerbuild and run containersbuildbuild an image from a Dockerfile-t price-api:singlename and tag the image-fwhich Dockerfile.the build context: the files COPY can see
docker run -d --name api -p 8090:8080 -e APP_VERSION=1.0.0 price-api:singlerunstart a new container-drun in the background--name apiname the container-p 8090:8080publish a port, host:container-e APP_VERSION=1.0.0set an environment variable insideprice-api:singlethe image to start
curl -s localhost:8090/healthcurlmake an HTTP request-ssilent: no progress bar
The . at the end is the build context: the folder Docker sends to the builder, and the only files
COPY can see. The instructions you'll use most:
| Instruction | Does |
|---|---|
FROM image:tag | the base image; every Dockerfile starts here |
WORKDIR /path | set the working directory for the steps after it |
COPY src dst | copy files from the build context (or another stage) into the image |
RUN command | run a command at build time, saving the result as a layer |
ENV KEY=value | an environment variable, at build time and in containers |
EXPOSE 8080 | document the port the app listens on (it doesn't publish it: -p does) |
USER name | the user later steps and the container run as |
ENTRYPOINT / CMD | what a container runs; CMD gives default arguments you can override |
Write ENTRYPOINT and CMD in the JSON exec form, ["/price-api"]. The shell form (CMD /price-api)
wraps your app in /bin/sh -c, so the shell becomes PID 1 and may not pass docker stop's SIGTERM on
(the Linux course's Signals lesson explains why that matters).
.dockerignore
COPY . . copies everything in the context. Without a .dockerignore, that includes .git, .env files
with secrets, node_modules and editor junk, and any change to them breaks the cache:
# .dockerignore .git .env *.md Dockerfile*
The layer cache
Every step is cached by its instruction and its inputs. On the next build Docker reuses a step only if it, and every step above it, is unchanged. The moment one step changes, it and everything below it run again:
So the order matters. Copy the dependency list first and download, then copy your code:
COPY go.mod ./ # changes rarely (go.sum too, once you have dependencies) RUN go mod download # cached until go.mod changes COPY . . # changes on every commit RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/price-api .
For Node that's COPY package.json package-lock.json ./ then RUN npm ci; for Python,
COPY requirements.txt . then RUN pip install -r requirements.txt. Here's a real rebuild after changing
one line of main.go:
The compile still takes over two minutes on this machine, because Go's own build cache is thrown away with each layer. A cache mount keeps it between builds, outside the image:
RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/price-api .
Package managers have the same trick (/root/.npm, /root/.cache/pip). The mount never becomes part of
the image.
Multi-stage builds
The first image works, but look at it:
$ docker images price-api IMAGE ID DISK USAGE CONTENT SIZE price-api:multi 034074b20fb9 14.6MB 3.24MB price-api:single 367778fc7a08 508MB 102MB $ docker run --rm price-api:single du -sh /usr/local/go 282.1M /usr/local/go $ docker run --rm price-api:single id uid=0(root) gid=0(root) groups=0(root),…
docker images price-apidockerbuild and run containersimageslist images
docker run --rm price-api:single du -sh /usr/local/gorunstart a new container--rmdelete the container when it exitsprice-api:singlethe image to startdu -sh /usr/local/gothe command run inside, instead of the image’s default
docker run --rm price-api:single ididthe command run inside, instead of the image’s default
508MB, of which 282MB is the Go compiler, plus your source code, a shell and package manager, all running as root. Production needs one file: the binary. A multi-stage build compiles in one stage and copies only the result into a clean one:
# syntax=docker/dockerfile:1 # Stage 1: build with the full Go toolchain FROM golang:1.27-alpine AS build WORKDIR /src COPY go.mod ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/price-api . # Stage 2: ship only the binary FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/price-api /price-api USER nonroot EXPOSE 8080 ENTRYPOINT ["/price-api"]
Only the last stage becomes the image. COPY --from=build reaches back into the build stage for the
binary; the compiler, source and module cache stay behind. CGO_ENABLED=0 makes a static binary that needs
no C library, and -ldflags="-s -w" strips debug symbols. The result is the 14.6MB image above.
docker build --target build . stops after a named stage: handy for running tests in the build stage (lesson 13).
Smaller, safer images
| Choice | Why |
|---|---|
a minimal final base: distroless, alpine, -slim, or scratch for static binaries | fewer packages, fewer vulnerabilities, faster pulls |
USER a non-root user | a compromised app isn't root in the container |
pinned base tags, or digests (@sha256:…) | rebuilds are repeatable; lesson 8 explains digests |
RUN apt-get update && apt-get install -y --no-install-recommends … && rm -rf /var/lib/apt/lists/* in one step | a file deleted in a later layer still ships in the earlier one |
no secrets in COPY, ENV or ARG | they stay readable in the image's layers and history; lesson 11 covers build secrets |
The distroless image has no shell at all, which also means an attacker who gets in has no shell to use:
$ docker image inspect price-api:multi --format '{{.Config.User}}' nonroot $ docker run --rm --entrypoint sh price-api:multi docker: Error response from daemon: … exec: "sh": executable file not found in $PATH
docker image inspect price-api:multi --format '{{.Config.User}}'dockerbuild and run containersimagemanage imagesinspectshow details as JSON--format '{{.Config.User}}'print only these fields (Go template)
docker run --rm --entrypoint sh price-api:multirunstart a new container--rmdelete the container when it exits--entrypoint shreplace the image’s ENTRYPOINTprice-api:multithe image to start
docker history price-api:multi lists each layer and its size: the only big one is your 5.86MB binary.
Image metadata and repeatable builds
ENTRYPOINT is the executable; CMD supplies default arguments. ARG is build-time input, ENV is a runtime
default, and neither is a secret store. HEALTHCHECK reports process health. Keep .dockerignore beside the
Dockerfile so credentials and build output never enter the context.
| Need | Use |
|---|---|
| Fixed executable | ENTRYPOINT |
| Default arguments | CMD |
| Build-only value | ARG |
| Runtime default | ENV |
LABEL org.opencontainers.image.source="https://example.invalid/academy" ARG BUILD_VERSION=dev ENV APP_VERSION=$BUILD_VERSION HEALTHCHECK --interval=30s CMD wget -qO- http://127.0.0.1:8080/health || exit 1 ENTRYPOINT ["/app/server"] CMD ["--listen=:8080"]
BuildKit cache mounts speed installs, secret mounts keep a secret out of layers, and buildx can publish multiple architectures:
$ printf '.git\n.env\nnode_modules\n' > .dockerignore $ docker buildx build --platform linux/amd64,linux/arm64 --cache-to type=local,dest=.buildx-cache \ --cache-from type=local,src=.buildx-cache -t example/app:dev .
printf '.git\n.env\nnode_modules\n' > .dockerignore>write output to a file, replacing it
docker buildx build --platform linux/amd64,linux/arm64 --cache-to type=local,dest=.buildx-cache \dockerbuild and run containersbuildxthe BuildKit builderbuildbuild the services’ images--platform linux/amd64,linux/arm64which CPU architecture
Shrink an image from 508MB to under 20MB
Containerise the Go API twice: first with a single-stage Dockerfile, then with a multi-stage one on a distroless non-root base. Prove both answer /health, compare their sizes and users, and show that a code change rebuilds only the last steps.
Two images, 508MB and 14.6MB, the small one running as nonroot, and a rebuild with CACHED dependency stepsReveal one safe solution
$ docker build -t price-api:single -f Dockerfile.single . $ docker build -t price-api:multi . $ docker images price-api --format '{{.Tag}} {{.Size}}' multi 14.6MB single 508MB $ docker run -d --name api -p 8090:8080 -e APP_VERSION=1.0.0 price-api:multi $ curl -s localhost:8090/health {"status":"ok","version":"1.0.0"} $ docker image inspect price-api:multi --format '{{.Config.User}}' nonroot $ sed -i 's/listening/now listening/' main.go && docker build -t price-api:multi . 2>&1 | grep CACHED $ docker rm -f api
docker build -t price-api:single -f Dockerfile.single .dockerbuild and run containersbuildbuild an image from a Dockerfile-t price-api:singlename and tag the image-fwhich Dockerfile.the build context: the files COPY can see
docker build -t price-api:multi .-t price-api:multiname and tag the image
docker images price-api --format '{{.Tag}} {{.Size}}'imageslist images--format '{{.Tag}} {{.Size}}'print only these fields (Go template)
docker run -d --name api -p 8090:8080 -e APP_VERSION=1.0.0 price-api:multirunstart a new container-drun in the background--name apiname the container-p 8090:8080publish a port, host:container-e APP_VERSION=1.0.0set an environment variable insideprice-api:multithe image to start
curl -s localhost:8090/healthcurlmake an HTTP request-ssilent: no progress bar
docker image inspect price-api:multi --format '{{.Config.User}}'imagemanage imagesinspectshow details as JSON--format '{{.Config.User}}'print only these fields (Go template)
sed -i 's/listening/now listening/' main.go && docker build -t price-api:multi . 2>&1 | grep CACHED&&run the next command only if this one worked2>&1send errors to the same place as output|pipe: send output to the next commandgrepfind lines that match a pattern
docker rm -f apirmdelete a container-fstop it first if runningapithe container
Sizes depend on the Go version and base image you pull, but the gap stays the same: the compiler never
reaches production. The grep CACHED lines are your dependency steps, reused.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
You change one line of main.go. Which Dockerfile steps does Docker run again?
Official reading for this lesson
Or get every quiz answer right and it completes itself.