Every day, we build a lot of docker images. You know everything; still, I listed some best practices for optimizing docker build that I follow in day-to-day development. By the way, many best practices are already mentioned on the Docker website. I will share what I do for Golang docker image building. It’s applicable almost all kind of builds.
Always do Multi-Stage docker build. It has a lot of power. It minimizes the size and improves run time performance.
FROM golang:1.23.1-alpine AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy go mod and sum files
COPY go.mod ./
# Download all dependencies
RUN go mod download
# Copy the source code into the container
COPY . .
RUN GOOS=linux go build -o /hello main.go
FROM scratch
# Set the working directory
WORKDIR /
# Copy the binary from the build stage
COPY --from=builder /hello /hello
# Expose the port the app runs on
EXPOSE 8080
# Run the binary
CMD ["/hello"]
I always use the alpine of Golang official image with the proper version. A fixed version will prevent breaking changes in future. However, it’s not the end of the world; if a patch exists, docker will automatically resolve it. As of writing this blog post, I used golang:1.23.1-alpine3.20. alpine is the minimum and stable image.
Every command in a Dockerfile (like RUN, COPY, etc.) generates a separate layer in the final image. Group similar type of commands together into one step reduces the total number of layers. E.g.
COPY go.mod ./
COPY go.sum ./
You should do:
COPY go.mod go.sum ./
Another example if you need to install any dev dependencies,
RUN apk update
RUN apk add --no-cache git
RUN rm -rf /var/cache/apk/* *
You should do:
RUN apk update && apk add --no-cache git && rm -rf /var/cache/apk/*
There is a tool called dive, which can analyze the size of every layer of docker builds.
Another point I wanna bring to your attention. I splitted the copy command into two. First, I copied go.mod and go.sum, so that I can download all the GO dependencies in a layer. Then, I copied the rest of the source code COPY . .. It avoids rebuilding those layers when there are changes in the source code.
Instead, the COPY command can be split in two. First, copy over the package management files (in this case, package.json and yarn.lock). Then, install the dependencies. Finally, copy over the project source code, which is subject to frequent change.
By default, docker transfers all the files from the project directory. Use a .dockerignore file to exclude all unnecessary files for image building. For example, my .dockerignore looks like
# The .dockerignore file excludes files from the container build process.
# Exclude locally vendored dependencies.
vendor/
# Exclude "build-time" ignore files.
.dockerignore
'# Exclude others.
.gitignore
.idea
Use COPY always instead of ADD. There is only one exception when you add a local tar file with auto-extraction capability. E.g.
ADD any-local-file.tar.xz /app
Finally, I wanna share an optimization technique and one more tool for scanning vulnerabilities. You can speed up the build by using a persistent cache.
ENV GOCACHE=/root/.cache/go-build
RUN --mount=type=cache,target=/go/pkg/mod/ \
--mount=type=cache,target="/root/.cache/go-build" \
--mount=type=bind,target=. \
go build -o /hello main.go
trivy is a static scanning tool that can scan docker Images for vulnerabilities. You can integrate it into the CI/CD pipeline.
Feel free to add more best practices or optimizations by adding comments. Happy Hacking 🙂