DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    DockerLesson 7 of 14
    Your progress · 0%
    On this page
    1. The app
    2. A first Dockerfile
    3. The layer cache
    4. Multi-stage builds
    5. Smaller, safer images
    6. Image metadata and repeatable builds
    ↖ Course roadmap
    Lesson 7 · Building images

    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.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 5 complete

    The 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 containers
    • buildbuild 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:single
    • runstart 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 inside
    • price-api:singlethe image to start
    curl -s localhost:8090/health
    • curlmake 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:

    InstructionDoes
    FROM image:tagthe base image; every Dockerfile starts here
    WORKDIR /pathset the working directory for the steps after it
    COPY src dstcopy files from the build context (or another stage) into the image
    RUN commandrun a command at build time, saving the result as a layer
    ENV KEY=valuean environment variable, at build time and in containers
    EXPOSE 8080document the port the app listens on (it doesn't publish it: -p does)
    USER namethe user later steps and the container run as
    ENTRYPOINT / CMDwhat 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:

    Same Dockerfile, three builds. What changed decides where the rebuild starts.© Animation: DevOps Academy

    Same Dockerfile, three builds. What changed decides where the rebuild starts.© Animation: DevOps Academy

    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:

    Steps 2 to 4 are CACHED. Only the copy of the code and the compile run again.© Screenshot: DevOps Academy

    Steps 2 to 4 are CACHED. Only the copy of the code and the compile run again.© Screenshot: DevOps Academy

    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-api
    • dockerbuild and run containers
    • imageslist images
    docker run --rm price-api:single du -sh /usr/local/go
    • runstart a new container
    • --rmdelete the container when it exits
    • price-api:singlethe image to start
    • du -sh /usr/local/gothe command run inside, instead of the image’s default
    docker run --rm price-api:single id
    • idthe 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

    ChoiceWhy
    a minimal final base: distroless, alpine, -slim, or scratch for static binariesfewer packages, fewer vulnerabilities, faster pulls
    USER a non-root usera 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 stepa file deleted in a later layer still ships in the earlier one
    no secrets in COPY, ENV or ARGthey 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 containers
    • imagemanage images
    • inspectshow details as JSON
    • --format '{{.Config.User}}'print only these fields (Go template)
    docker run --rm --entrypoint sh price-api:multi
    • runstart a new container
    • --rmdelete the container when it exits
    • --entrypoint shreplace the image’s ENTRYPOINT
    • price-api:multithe image to start
    Debugging an image with no shell

    Use the :debug distroless tag while you investigate, or attach a throwaway container with tools to the running one's namespaces: docker run --rm -it --pid container:api --network container:api alpine:3.22 sh. Lesson 13 covers debugging.

    docker history price-api:multi lists each layer and its size: the only big one is your 5.86MB binary.

    In production

    Build images in CI from a clean checkout, never on a laptop, and tag them with the commit SHA (lesson 8). Rebuild regularly, even without code changes, so the base image picks up security fixes, and scan the result (lesson 11).

    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.

    NeedUse
    Fixed executableENTRYPOINT
    Default argumentsCMD
    Build-only valueARG
    Runtime defaultENV
    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 containers
    • buildxthe BuildKit builder
    • buildbuild the services’ images
    • --platform linux/amd64,linux/arm64which CPU architecture
    ⌘
    Mini mission

    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.

    Input: Docker, main.go and go.mod from this lessonOutput: Two images, 508MB and 14.6MB, the small one running as nonroot, and a rebuild with CACHED dependency steps
    Reveal 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 containers
    • buildbuild 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:multi
    • runstart 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 inside
    • price-api:multithe image to start
    curl -s localhost:8090/health
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker image inspect price-api:multi --format '{{.Config.User}}'
    • imagemanage images
    • inspectshow 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 worked
    • 2>&1send errors to the same place as output
    • |pipe: send output to the next command
    • grepfind lines that match a pattern
    docker rm -f api
    • rmdelete a container
    • -fstop it first if running
    • apithe 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    You change one line of main.go. Which Dockerfile steps does Docker run again?

    Go deeper

    Official reading for this lesson

    • DocsDocker Docs: Dockerfile referenceDocker Docs ↗
    • DocsDocker Docs: build cacheDocker Docs ↗
    • DocsDocker Docs: multi-stage buildsDocker Docs ↗
    • DocsDocker Docs: building best practicesDocker Docs ↗
    • ToolDistroless container imagesGoogleContainerTools ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousKeeping data: volumes, bind mounts and backupsNext lesson →Registries: Docker Hub, GHCR, ECR and tags you can trust