Skip to content
Go back

How I Shrunk My Docker Image size by 48x (and Cut Build Time in Half)

Updated:  at  10:55 PM

tl;dr:

Reduced a Go backend Docker image size from 1.29 GB to 27.1MB using .dockerignore, Alpine base images, Multi stage builds. That’s a 48x total reduction in image size, also achieved a 46% reduction in build time (from 43.8s to 23.6s).


While working on my real time leaderboard I was making multiple changes every day, testing out how each would improve performance, which led me to scale up to 28232 concurrent SSE connections on a single machine (the default Linux max for outbound ports).

Linux has 28232 ports for outbound connections by default, can be increased, see Baeldung.

Each change required a rebuild of the backend Docker image. With rapid iteration and long build times, I found myself spending more time waiting than building. I wanted to solve this, and set out to optimize builds for time and speed.

Table of contents

Open Table of contents

Initial metrics

Initially my image (for the Go backend server) would be one of the major bottlenecks for build time. I had been using a very basic Go 1.24 base image, with a generic Dockerfile to build the image and run the server during runtime (CMD).

FROM golang:1.24

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY src/ ./src/

RUN go build -o main ./src/main.go

CMD ["./main"]

This had terrible performance, with respect to both time and image size:

baseline dockerfile performance

To optimize it, I started learning about what kind of base images were available for Go and reading blogs on best practices to improve the build performance.

To understand why these worked, I dived into Docker’s documentation to figure out how building an image really works.


Image deep dive

Docker images are essentially packaged bundles of configuration files, binaries, and all the things needed to make your application work. It’s not an active component though, for that you need to change it into a container(You run containers from images — and while containers can exist in multiple states -e.g., created, running, exited; the image itself is static).

Every line that you add to a Dockerfile adds a layer to the final image being built. A layer in an image is also an image, and layers are add only, which means even if you wrote a command to remove certain files in your Dockerfile, it’ll only increase the size instead of reducing it. This means you’ll have to be careful and not install any packages you don’t need, remove things in the same command as you are using to install, or use other techniques such as the ones discussed later to reduce size.

Docker history

Use docker history to get details of each layer inside the final image, provides details of sizes of each layer as well.

docker history output

Benchmarking setup

The method I used to measure effects of each change is pretty simple. A bash script is run after each change to measure improvements (or degradation), which executes the build step, taking care not to use docker cache. This step is repeated for a number of times, all of the trial outputs are logged to a file for review (also has terminal output).

After working on and improving the benchmarking script(adding better logging, repeated trials and averaging over trials), I ended up with this:

#!/bin/bash

IMAGE_NAME=${1:-"leaderboard"}
DOCKERFILE=${2:-"Dockerfile"}
TRIALS=${3:-5}

echo "Starting Docker build benchmarking for $IMAGE_NAME : (${TRIALS} trials)"
echo "Testing dockerfile changes - single stage non alpine build, no cache flag"
echo "$(date): Starting docker build benchmarking for $IMAGE_NAME (${TRIALS} trials)" >> logs/docker_benchmark.log
echo "Testing dockerfile changes - single stage non alpine build, no cache flag" >> logs/docker_benchmark.log

total_time_seconds=0
total_size_mb=0
successful_trials=0

for ((i=1; i<=TRIALS; i++)); do
    echo "Running trial $i/$TRIALS..."
    
    start_time=$(date +%s)
    
    build_output=$(docker build --no-cache -t $IMAGE_NAME -f $DOCKERFILE . 2>&1)
    exit_code=$?
    
    end_time=$(date +%s)
    build_time=$((end_time - start_time))
    
    if [[ $exit_code -eq 0 ]]; then
        image_size=$(docker images $IMAGE_NAME --format "{{.Size}}")
        
        echo "Trial $i: Success - ${build_time}s, $image_size"
        echo "Trial $i: build time ${build_time}s image size $image_size time start $start_time time end $end_time" >> logs/docker_benchmark.log
        
        total_time=$((total_time + build_time))
        size_mb_total=$(awk "BEGIN {print $size_mb_total + $image_size}")
        successful_trials=$((successful_trials + 1))
    else
        echo "Trial $i: Failed (exit code: $exit_code)"
        echo "Trial $i: BUILD FAILED (exit code: $exit_code) time start $start_time time end $end_time" >> logs/docker_benchmark.log
        echo "$(date): $build_output">>logs/error.log
        echo "$(date): $build_output \n --------------------"
    fi
done

echo "================================="
if [[ $successful_trials -gt 0 ]]; then
    avg_time=$(awk "BEGIN {print $total_time / $successful_trials}")
    avg_size_mb=$(awk "BEGIN {print $size_mb_total / $successful_trials}")
    
    echo "Successful trials: $successful_trials/$TRIALS"
    echo "Average time: ${avg_time}s"
    echo "Average size: $avg_size_mb mb"
    
    echo "$(date): Average results - time ${avg_time}s size - ${avg_size_mb}mb (${successful_trials}/${TRIALS} successful)" >> logs/docker_benchmark.log
else
    echo "All trials failed!"
    echo "$(date): All trials failed!" >> logs/docker_benchmark.log
fi
echo "================================="

echo "Cleaning up Docker resources"
docker builder prune -af
docker image rm -f $IMAGE_NAME 2>/dev/null
docker container prune -f
docker volume prune -f
docker network prune -f

At the start of the file I’ve defined some variables, which can be passed as args while running the bash script. There’s a few echo commands after that, which are used to signal the start of a test run, logging the change done, other details of the current run.


Optimizations

1. .dockerignore

Like the .gitignore file prevents unnecessary files from being tracked in Git, the .dockerignore file prevents the files and directories mentioned inside it from being included in the image, when the parent directory is copied to build an image.

.git
.gitattributes
.gitignore

.env

docker-compose.yml
Dockerfile

logs/
grafana/
prometheus/

.vscode/

Why These Specific Files Are Excluded

Let’s break down each entry in my .dockerignore and why it matters:

Get templates of .dockerignore here.

Performance improvements:

dockerignore benchmark

Since I’m already importing only the required files (src, go.mod), this step does not have much of a difference. Differences created due to this would be of the order of a few megabytes (in my case), which is overshadowed by the size of the final image (1.29 GB). There is however a build time improvement (cannot rely on it too much, fluctuates between runs, but average still decreased).

2. Smaller base images

I’ve used alpine and slim based base images for python before, but did not know they existed until I wandered onto the Dockerhub repository for Go.

These contain the bare essentials to run applications, so they’re much smaller. A downside to this is that they don’t have many of the tools required to compile some packages (C compilers among many other packages).

Dockerfile:

FROM golang:1.24-alpine

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY src/ ./src/

RUN go build -o main ./src/main.go

CMD ["./main"]

This provides a significant improvement: alpine build benchmark

That’s almost a 2x improvement in build size.

3. Multi stage builds

Multi stage builds use multiple FROM statements in a single Dockerfile, which allows you to just take what you need from the previous images (binaries, configs), without having the overhead of packages and tools required to build those binaries. This is extremely convenient because the builder images can be bulky and have lots of packages in order to build the app, but the final binaries produced will be copied in a much smaller base image, and can be used to dramatically cut down on resource usage.

FROM golang:1.24-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY config.yaml .
COPY src/ ./src/

RUN go build -o main ./src/main.go

FROM alpine:latest

WORKDIR /app

COPY --from=builder /app/main .
COPY --from=builder /app/config.yaml .

CMD ["./main"]

multi stage build benchmark

That’s an improvement of ~26x.

And an overall improvement of ~48x in build size (1.29GB to 27.1MB).

I know that this configuration can still be improved, such as the copy command in the multi stage build alpine image can just copy both the code and configs in a single line.


Results Summary

After applying all the optimizations step by step, here’s how the image size and average build time changed.

Build StageSizeAvg Build Time
Baseline1.29 GB43.8s
+ .dockerignore1.29 GB28.4s
+ Alpine + .dockerignore699 MB27s
+ Multi-stage + Alpine + .dockerignore27.1MB23.6s

The real gains in image size came from switching to Alpine and combining it with multi stage builds. .dockerignore did not improve image size, but it had a considerable effect on build time.


Production considerations

Although this optimization journey shows a bunch of ways to improve builds, it does not use a great feature of Docker, the caching layer.

Every time you build an image, Docker caches the layers so that it does not need to be rebuilt each time, saving a lot of resources.

This however, does not work when a change occurs, let’s say we’re copying config files, and a value in the config needed to be changed. The layers (inclusive of the copy config line) after the copy line will need to be rebuilt, as a dependency has been changed.

This is why ordering least frequently changed operations (go mod copy, install, config copy) should be done in the layers before the frequently changing operations (source code copy, binary build).

Additional reduction in size can be achieved by opting for scratch or distroless base images, but those have some drawbacks (missing packages - might now be able to do a lot of stuff out of the box).

Conclusion

This was a fun side quest that taught me how Docker really builds images under the hood. The impact of optimizing Docker builds is huge - faster iteration, lower CI/CD costs, and leaner deploys; all especially valuable for small teams or fast-moving solo devs.

By understanding Docker image concepts, using .dockerignore, choosing the a leaner base image, and combining it with multi-stage builds, I reduced my Go backend image from 1.29GB to 27.1MB - a 48x reduction in image size and a 46% reduction in build time.

If you’re working on performance-critical systems or just want tighter feedback loops, I strongly recommend auditing your Docker setup. Small improvements in how you build today can pay off massively as your project scales.

P.S.: I learnt of an even better way to improve docker images, this one’s automatic - try slim.

Where to go from here?

I’d highly recommend reading the following:



Previous Post
Tech I find interesting
Next Post
Building an AI LinkedIn Sourcing Agent (Full version)