Skip to content
Latchkey LogoLatchkey home

Reduce Docker image size CI pushes and pulls

To reduce Docker image size CI has to move on every run, change the base image first and everything else second: on the sizes the Docker Hub registry API reported on 21 September 2026, moving from node:22 to node:22-slim takes the compressed image from 389.6 MB to 76.2 MB before you have touched a single line of your own Dockerfile. Nothing else on this page is worth as much.

Compressed image sizes: node 22 at 389.6 MB, slim at 76.2, alpine at 58, and alpine base at 3.6
Compressed linux/amd64 sizes reported by the Docker Hub registry API for each tag, queried on 21 September 2026. Your own layers are added on top of these.

A smaller image is not a build-time optimisation. The build takes the same time it always did; what changes is every push after it and every pull before it, on every runner, forever, which is why this is a durable saving rather than a one-off one.

Measure in compressed bytes throughout. That is what crosses the network, it is what the registry stores, and it is not the number docker images prints, which is the uncompressed size on disk and can be two to three times larger.

Measure the number that actually moves

There are two sizes and they are not close to each other. The registry stores and transfers compressed layers; the runner stores the uncompressed result. A pull is billed to you in the first unit and the 14 GB of SSD on a standard GitHub-hosted runner is consumed in the second, so both matter and neither substitutes for the other.

docker manifest inspect reads the manifest from the registry and gives you the compressed layer sizes without pulling anything, which makes it the right tool for comparing candidates before you commit to one. docker images tells you what the runner will hold on disk after the pull.

Do this before and after any change on this page, because several of the popular tactics move one number and not the other. Squashing layers, for instance, changes the disk figure far more than it changes the transfer.

Terminal
# compressed bytes, straight from the registry, no pull
docker manifest inspect node:22 \
  | jq '[.layers[].size] | add / 1048576 | round'

# uncompressed bytes on this machine, after a pull
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' node

The base image is most of the answer

Official language images ship a complete build environment: compilers, headers, documentation and a full package manager, because they are meant to be able to build anything. A runtime needs almost none of that, and the vendors publish smaller variants precisely so you do not have to carry it.

The figures below are the compressed linux/amd64 sizes the Docker Hub registry API reported for each tag on 21 September 2026. They move as the images are rebuilt, so treat the ratios as the durable part and re-query the absolute numbers when you need them.

The pattern is consistent across ecosystems: the slim variant is roughly a fifth of the full image and the difference is almost entirely toolchain. If your container only runs an already-built application, the full tag is several hundred megabytes of things you will never execute, pulled on every cold runner you ever start.

TagCompressed linux/amd64Against the full tag
node:22389.6 MBbaseline
node:22-slim76.2 MB80% smaller
node:22-alpine58.0 MB85% smaller
python:3.13393.8 MBbaseline
python:3.13-slim41.0 MB90% smaller
golang:1.25291.2 MBa build stage, not a runtime
debian:13-slim28.4 MBa runtime base
alpine:3.223.6 MBa runtime base

Multi-stage ships the artifact and leaves the toolchain behind

A multi-stage build compiles in one stage and copies only the result into another, so the compiler never appears in what you push. For a compiled language the effect is close to total: the Go toolchain image is 291.2 MB compressed, and a static binary copied onto alpine:3.22 starts from 3.6 MB plus the size of the binary itself.

For interpreted languages the win is smaller and still real. You cannot leave the runtime behind, but you can leave behind the build tools that native module compilation needed, the package manager cache, and the development dependencies, and the final stage can be the slim variant even when the build stage was the full one.

The cost is honest and worth stating: multi-stage builds usually take slightly longer to build, because the copy between stages is real work and because the final stage cannot reuse the build stage's layer cache. You are spending build time, which happens once per change, to buy transfer time, which happens on every run.

Dockerfile
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/server

FROM alpine:3.22
RUN apk add --no-cache ca-certificates
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Layers keep what you deleted

Each instruction adds a layer, and a later layer cannot reclaim space from an earlier one. Installing packages in one RUN and deleting the package lists in the next leaves both the install and the delete in the image: the files are invisible in the final filesystem and they are still being transferred to every runner that pulls it.

So clean up inside the same instruction that made the mess. The package manager cache, the downloaded archive you extracted, and the intermediate build directory all have to go in the same layer that created them, or they are permanent.

This is also why squashing is a poor substitute. It hides the problem from the size figure without changing the Dockerfile that produced it, and it costs you the layer sharing that makes a second image from the same base cheap to pull.

Dockerfile
# leaves the apt lists in an earlier layer, forever
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# cleans up in the layer that created the mess
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

What a .dockerignore does, and what it does not

A .dockerignore excludes paths from the build context, which is the set of files sent to the builder before the build starts. That has two effects and people usually only know about one of them.

The obvious effect is size: if your Dockerfile contains COPY . ., anything excluded from the context cannot be copied into the image, so node_modules, .git and test fixtures stop shipping. The less obvious effect is cache: a context that includes files irrelevant to the build will invalidate the COPY layer whenever any of them changes, which throws away every cached layer below it. A .dockerignore therefore makes builds faster even when it makes the image no smaller.

If your Dockerfile copies specific paths rather than the whole tree, the image size will not change and the context transfer and cache behaviour still will. Write one either way.

.dockerignore
.git
node_modules
dist
coverage
*.log
**/__tests__
.github

What this is worth in CI, and what it is not

Every megabyte you remove is removed from a push after the build and from a pull on every runner that uses the image afterwards, including every matrix leg and every job that runs the image as a service container. That is the durable return, and it scales with how often you run rather than with how often you change the Dockerfile.

What it does not do is make the build itself faster. If the complaint is build duration rather than transfer duration, the lever is layer caching and Dockerfile ordering, which are measured in speeding up Docker builds in GitHub Actions and Docker layer caching in GitHub Actions.

This page carries no timing of its own on purpose. A transfer time is a function of the registry you use, whether the runner has a warm layer cache, and the network between them, so a figure measured on our infrastructure would say more about our registry than about your pipeline. The sizes above are facts you can re-query in one command; the seconds they save are yours to measure. If disk rather than network is what is hurting, freeing disk space on GitHub Actions runners is the other half of the problem.

Key takeaways

  • Compare compressed sizes with docker manifest inspect: that is what the runner transfers, and it is not what docker images prints.
  • A slim variant is roughly a fifth of the full language image, which is a larger saving than every other tactic combined.
  • Clean up inside the instruction that created the files, because a later layer cannot reclaim an earlier one.
  • A .dockerignore helps even when the image size does not move, because it stops irrelevant files invalidating the COPY layer.

Frequently asked questions

How much smaller is an alpine or slim base image?
On the compressed linux/amd64 sizes the Docker Hub registry API reported on 21 September 2026, node:22 is 389.6 MB, node:22-slim is 76.2 MB and node:22-alpine is 58.0 MB; python:3.13 is 393.8 MB against 41.0 MB for its slim variant. The full images carry a complete build toolchain that a runtime container never executes.
Does a smaller image make my CI build faster?
It makes the push after the build and every pull before a job faster, not the build itself. If build duration is the complaint, the lever is layer cache reuse and the order of instructions in the Dockerfile. If your pipeline pulls the image on many jobs or matrix legs, the transfer saving is repeated on every one of them.
Why is my image still large after I deleted the files?
Because a later layer cannot reclaim space from an earlier one. A RUN that installs packages and a separate RUN that deletes the cache leave both layers in the image, so the files are invisible in the final filesystem and still transferred on every pull. Do the cleanup in the same instruction that created the files.
Should I use alpine for everything?
Not automatically. Alpine uses musl rather than glibc, so anything depending on a glibc-only prebuilt binary will either fail or fall back to compiling from source, and some language runtimes behave differently on it. A slim Debian variant is usually most of the saving with none of that risk, and it is the safer first move.

Related guides

References

Switch the base image first. Then stop pulling the layers that did not change, on Latchkey. Start free → 30-day trial · No credit card