# Reduce Docker image size CI pushes and pulls

> Reduce Docker image size CI transfers on every run: the measured base image sizes, what multi-stage actually saves, and the layers that never shrink.

Source: https://latchkey.dev/learn/speed/reduce-docker-image-size-for-ci  
Updated: 2026-09-21

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.

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.

| Tag | Compressed linux/amd64 | Against the full tag |
| --- | --- | --- |
| `node:22` | 389.6 MB | baseline |
| `node:22-slim` | 76.2 MB | 80% smaller |
| `node:22-alpine` | 58.0 MB | 85% smaller |
| `python:3.13` | 393.8 MB | baseline |
| `python:3.13-slim` | 41.0 MB | 90% smaller |
| `golang:1.25` | 291.2 MB | a build stage, not a runtime |
| `debian:13-slim` | 28.4 MB | a runtime base |
| `alpine:3.22` | 3.6 MB | a runtime base |

> Queried from the Docker Hub registry API on 21 September 2026, taking the linux/amd64 entry for each tag. Sizes change whenever an image is rebuilt.

## 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](/learn/speed/speed-up-docker-builds-in-github-actions) and [Docker layer caching in GitHub Actions](/learn/speed/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](/learn/failures/free-disk-space-on-github-actions-runners) is the other half of the problem.

## FAQ

### 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.

## References

- [Docker: multi-stage builds, and copying between stages (verified 2026-09-21)](https://docs.docker.com/build/building/multi-stage/)
- [Docker: the build context and what .dockerignore excludes (verified 2026-09-21)](https://docs.docker.com/build/concepts/context/)
- [Docker: building best practices, layers and package manager cleanup (verified 2026-09-21)](https://docs.docker.com/build/building/best-practices/)
- [Docker Hub: the official node image and its published tags (verified 2026-09-21)](https://hub.docker.com/_/node)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
