The Problem #
I use (what I thought were decent) Docker Multi-Stage builds to keep image sizes down, but I still had a massive problem: my (buildx cross compilation) builds took 9 minutes! What I didn't realize is that I wasn't entirely knowledgeable on 2 core Docker optimizations:
- Layer Caching: I was invalidating my cache way too often, making unnecessary rebuilds.
- Parallelization: Docker automatically runs independent stages in parallel, but I wasn't structuring my builds to take advantage of it.
Once I optimized both of these, my (buildx cross compilation) build time dropped from 540 seconds to just 45, a 91% speedup!
For context, this is what my Dockerfile looked like in the beginning:
1FROM golang:1.24 AS build-env
2
3ARG BUILD_VERSION
4ARG BUILD_DATE
5ARG BUILD_COMMIT
6ARG BUILD_TAGS
7ARG GOARCH
8ARG GOOS
9
10WORKDIR /app
11
12COPY . ./
13
14RUN apt-get update && apt-get install -y tzdata nodejs npm
15
16# Install go dependencies
17RUN go mod download
18
19RUN npm install -g typescript
20
21# Navigate to admin directory, install dependencies, and build the app
22WORKDIR /app/internal/frontend/admin
23RUN npm ci
24RUN npm run build
25
26# Navigate to user directory, install dependencies, and build the app
27WORKDIR /app/internal/frontend/user
28RUN npm ci
29RUN npm run build
30
31# Return to the main app directory
32WORKDIR /app
33
34RUN CGO_ENABLED=0 GOARCH=${GOARCH} GOOS=${GOOS} go build \
35 -o kitchensink \
36 ./cmd/kitchensink/main.go
37
38# Final Stage (make the image smaller w/ multistages)
39FROM scratch
40
41WORKDIR /
42
43COPY --from=build-env /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
44COPY --from=build-env /usr/share/zoneinfo /usr/share/zoneinfo
45COPY --from=build-env /app/kitchensink /kitchensink
46
47ENV TZ=UTC
48
49ENTRYPOINT ["./kitchensink"]
Step 1. Understand Caching #
Docker heavily utilizes layer caches to make builds faster. The system creates a new layer for every instruction (e.g. RUN, COPY, etc), and can intelligently cache, meaning if a step hasn't changed, Docker reuses the cached result instead of re-running it.
However, improper layer ordering can break caching and force unnecessary rebuilds. A mistake I've turned into a habit is placing COPY . ./ too early in the Dockerfile. Since this copies every single file, any file change invalidates the cache for all subsequent layers, even those that don't depend on the changed file.
This was a big issue, considering that whenever any of my code changed, I was invalidating every layer; not just what changed.
A basic fix to this is modifying the COPY instructions to be more direct, such as:
1FROM golang:1.24 AS build-env
2
3# args
4# REMOVE COPY from here
5# everything remains the same
6
7WORKDIR /app/internal/frontend/admin
8
9COPY ./internal/frontend/admin/package.json ./
10COPY ./internal/frontend/admin/package-lock.json ./
11
12RUN npm ci
13
14WORKDIR /app/internal/frontend/user
15
16COPY ./internal/frontend/user/package.json ./
17COPY ./internal/frontend/user/package-lock.json ./
18RUN npm ci
Just from this one change, layer caching has improved in such a way that npm ci will only do a full install when the package.json / lockfile changes. Otherwise, it can continue on to build, saving valuable time and bandwidth. This idea of using COPY just on the files you're working with repeats in later steps. It is a major discovery to me (facepalm).
While this is an immediate WIN in caching, what happens if both packages need updating? Currently, they will run sequentially one after the next. This can be... slow.
Step 2. The Power of Multi-Stage Builds #
What I never realized, somehow, is that Docker Multi-Stage builds will run in parallel!
The best part? You don't even need to really think about the "parallel"-ness; Docker will take care of keeping track of which parts need to wait on what.
The next optimize I did was to create two stages for the installation process so that they can run at the same time:
1# Install Admin Node Dependencies
2FROM --platform=$BUILDPLATFORM node:lts-alpine AS admin-deps-install
3
4WORKDIR /admin-deps
5COPY /internal/frontend/admin/package.json ./
6COPY /internal/frontend/admin/package-lock.json ./
7
8RUN npm ci
9
10# Install User Node Dependencies
11FROM --platform=$BUILDPLATFORM node:lts-alpine AS user-deps-install
12
13WORKDIR /user-deps
14
15COPY /internal/frontend/user/package.json ./
16COPY /internal/frontend/user/package-lock.json ./
17
18RUN npm ci
Curious about that --platform piece? I spent about 2 hours debugging that..
Step 3. Inherit Stages #
In my Dockerfile, I've now solved the issue of running both installs only when necessary; however, the same thing happens for the build step.
Referencing the original Dockerfile, you can see I installed typescript globally before doing any of this work:
1RUN npm install -g typescript
2
3# Navigate to admin directory, install dependencies, and build the app
4WORKDIR /app/internal/frontend/admin
5RUN npm ci
6RUN npm run build
7
8# Navigate to user directory, install dependencies, and build the app
9WORKDIR /app/internal/frontend/user
10RUN npm ci
11RUN npm run build
Since this, and other pre-build dependencies, needs to be installed prior to building, I decided to create a base builder image:
1FROM --platform=$BUILDPLATFORM node:lts-alpine AS node-builder
2
3RUN npm install -g typescript
4RUN npm install -g rimraf
Now, instead of rerunning the same code twice for each UI, I can simply inherit this base image:
1# Build Admin UI
2FROM node-builder AS admin-builder
3
4WORKDIR /app
5
6COPY ./internal/frontend/admin/ .
7COPY --from=admin-deps-install /admin-deps/node_modules ./node_modules
8
9RUN npm run build
10
11# Build User UI
12FROM node-builder AS user-builder
13
14WORKDIR /app
15
16COPY ./internal/frontend/user/ .
17COPY --from=user-deps-install /user-deps/node_modules ./node_modules
18
19RUN npm run build
Note here how I'm only ever copying the frontend code and dependencies: nothing else. This loops back again to the idea of layer caching.
With this setup, both builds will run in parallel, saving a "massive" amount of time (roughly 1-3 minutes). The sweetness really comes from the layer caching, though: these stages will only build what is necessary (changed since last time) in the moment. No more waiting to do the same thing every build!
Step 4. Pulling it all Together #
Since my backend, and the piece that serves the frontend, is in Go, I need to move the built files into a golang env builder. This is really simple using the COPY --from=<stage> command.
You will also see how I reiterated the learning of "only pull in necessary files" with the go mod download command.
1FROM --platform=$BUILDPLATFORM golang:1.24 AS build-env
2
3ARG BUILD_VERSION
4ARG BUILD_DATE
5ARG BUILD_COMMIT
6ARG BUILD_TAGS
7ARG GOARCH
8ARG GOOS
9
10WORKDIR /app
11
12COPY go.mod go.sum ./
13
14RUN go mod download
15
16COPY . ./
17
18COPY --from=admin-builder /app/dist /app/internal/frontend/admin/dist
19COPY --from=user-builder /app/dist /app/internal/frontend/user/dist
20
21# finally build, rest of file identical.
Conclusion #
By reordering COPY instructions, splitting dependencies into separate stages, leveraging parallel multi-stage builds, and minimizing unnecessary rebuilds, I reduced post-first buildx build times from 540 seconds to just 45 seconds: a 91% improvement.
Screenshot showing final build time of 47.5 seconds.
These optimizations not only speed up development but also reduce resource usage and network overhead, making iteration cycles significantly smoother. More importantly, they create a scalable and maintainable build process that efficiently handles changes without redundant work.
If you're struggling with long Docker build times, try applying these techniques. As I've learned, small changes in Dockerfile design can lead to massive improvements in performance.
Challenges and Learnings #
docker buildxhas some weird behaviors. A real challenge was finding out I needed to specify the--platformtag inFROMinstructions. The error I was getting was quite odd, and nearly un-Google-able. I'm honestly not quite sure why the first Dockerfile worked.- Keep
COPY . ./minimal and exact: never do this until you absolutely need to. - Layer Caching Waterfalls: when one layers cache invalidates, all downstream layers are invalidated.
- Reduce redundant installs by inheriting base stages.
- Use parallel multi-stage builds for dependencies.
give me some feedback on bluesky: @kv.codes