Cutting Container Image Size in GitHub Actions

Most of a container image is base layers and build tooling. Move the toolchain into a stage that never ships, land on a slim runtime base, and pull less.

A container image is mostly base layers and build tooling, so the reduction that matters comes from moving the toolchain into a stage that never ships and landing the runtime on a slim base. The Node service worked through below goes from 1.50 GB to 0.40 GB compressed, which is 1.10 GB less on every push out of a build job and every pull into a deploy job.

This guide covers how to attribute the bytes to base, dependencies, and application layers, the Dockerfile change that removes the largest share, the workflow configuration that keeps the size from creeping back, and a transfer model at a stated deploy frequency. The wider picture is in Docker builds on GitHub Actions.

Diagnosis

Attribute the bytes before editing anything

Two numbers describe an image and they are not interchangeable. docker image ls reports the uncompressed size on disk. The registry manifest reports the compressed size of each layer, and that is what a pull moves over the network:

docker buildx imagetools inspect ghcr.io/acme/api:latest --raw \
  | jq '[.layers[].size] | add / 1000000'

docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' ghcr.io/acme/api:latest

The first command gives the number your transfer bill and your pull step both scale with. The second attributes it to instructions, which is what turns a total into a budget.

The size budget

Here is the budget for a single stage Node 22 API image built from node:22-bookworm, with the shipped image carrying everything the build needed. Replace each row with your own docker history output before acting on it.

ComponentWhat it holdsCompressedShare of totalShips in production?
Base image node:22-bookwormDebian userland, Node runtime, npm, C toolchain for native modules0.45 GB30 percentThe runtime does, the toolchain does not
Dependencies including devDependenciesnode_modules with TypeScript, the test runner, linters, type packages0.48 GB32 percentProduction needs a subset
Native build outputnode-gyp intermediates, downloaded headers, prebuilt binaries kept after install0.30 GB20 percentNo
Application and build contextdist/, the source tree, .git, test fixtures copied by COPY . .0.27 GB18 percentdist/ only
Total1.50 GB100 percent

Three rows in that table are accidental. The C toolchain exists so native modules compile at install time and has no job at run time. The development dependencies exist so the test and lint steps pass. The build context arrived wholesale because the Dockerfile said COPY . . and no .dockerignore filtered it, which also means .git history is inside every deploy.

The base image row is the one that stays, and it is the row a runtime base swap addresses. What a base image is and how it constrains everything above it is covered in base image.

What the size costs you per run

An ephemeral runner starts every job with an empty image store, so every deploy job pulls the whole compressed image, every time. A busy repository pays that pull on each merge and each rollback.

The charge lands in three places. Job seconds spent inside the pull step, cloud data transfer billed per gigabyte on whichever network path the pull takes, and registry storage on every retained tag. ECR bills $0.10 per GB-month for stored images in private repositories (Amazon ECR pricing, checked on 2026-08-13), so a pipeline pushing one tag per merge accumulates the fat version of the image many times over.

Fix

Before

The single stage file that produced the budget above:

FROM node:22-bookworm
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

Everything npm ci installed, everything COPY . . copied, and the whole node:22-bookworm userland ship together.

After

The staged file. The toolchain stages compile and test, a separate stage resolves production dependencies only, and the release stage starts from node:22-slim and copies two directories in.

# syntax=docker/dockerfile:1.7

FROM node:22-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-bookworm AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

FROM node:22-slim AS release
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Pair it with a .dockerignore so the build context stops carrying history and fixtures:

.git
node_modules
coverage
dist
**/*.test.ts
.github

The release budget after the change:

ComponentCompressedChange
Base image node:22-slim0.07 GB0.38 GB removed with the toolchain userland
Production node_modules0.28 GB0.20 GB removed with devDependencies
Native prebuilt binaries for production dependencies0.01 GB0.29 GB of build intermediates left behind in prod-deps
Compiled dist/0.04 GB0.23 GB of source, fixtures, and .git filtered by .dockerignore
Total0.40 GB1.10 GB smaller per pull

Two properties of this layout matter beyond the total. The --mount=type=cache directories keep the npm cache outside the layer graph, so nothing that speeds up installs ends up in a shipped layer. And prod-deps resolves against the same lockfile as deps, so production gets the exact versions the tests ran against.

Configuration

The workflow

name: image

on:
  pull_request:
  push:
    branches: [main]

jobs:
  release:
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/checkout@v5

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          target: release
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/acme/api:${{ github.sha }}
          profile-name: api-amd64
          timeout: 600000

      - name: Enforce the size budget
        if: github.event_name == 'push'
        run: |
          BYTES=$(docker buildx imagetools inspect \
            ghcr.io/acme/api:${{ github.sha }} --raw | jq '[.layers[].size] | add')
          LIMIT=$((450 * 1000 * 1000))
          echo "compressed image size: $BYTES bytes (limit $LIMIT)"
          test "$BYTES" -le "$LIMIT"

The gate is the part teams skip and then regret. A budget nobody checks drifts back within a quarter, because every dependency addition is individually small.

The job runner here checks out, hands the build to a builder profile, and waits, which warp-ubuntu-latest-x64-2x covers. Image work belongs on the Linux labels. A builder profile is one dedicated builder virtual machine with a persistent layer cache on its own disk, so the deps and prod-deps stages survive between jobs; inputs, session billing, and the profile setup are in the remote Docker builders documentation.

Keep caches out of the image

Anything a build downloads to go faster belongs in a cache action rather than in a layer. WarpBuilds/cache@v1 is a drop-in replacement for the standard cache action, documented in the WarpBuild caching documentation, and it keeps SDKs, fixture sets, and toolchain downloads outside the image entirely.

Observability is what tells you whether the pull step is still a visible share of job duration after the image shrinks.

Cost or Time Model

The model is arithmetic on stated assumptions rather than a measurement. Every rate carries a source link and a checked-on date, and the same rate appears on every WarpBuild surface that states it.

Assumptions

  • A deploy pipeline running 40 jobs per weekday across 22 weekdays, which is 880 deploy jobs per month.
  • One full image pull per job, because ephemeral runners start with an empty image store.
  • 100 MB per second of effective pull throughput, covering transfer plus decompression. Replace this with the wall clock your own timed docker pull step reports.
  • Compressed image sizes as measured from the registry manifest.
  • Transfer priced at $0.09 per GB on the internet path and $0.00 per GB on the same-Region path (Amazon ECR pricing, checked on 2026-08-13).

Image size against pull time and transfer

Compressed imagePull seconds per jobGB pulled per monthInternet path at $0.09 per GBSame-Region path at $0.00 per GB
3.0 GB30.02,640$237.60$0.00
1.5 GB15.01,320$118.80$0.00
1.0 GB10.0880$79.20$0.00
0.4 GB4.0352$31.68$0.00

Read a row for what one image size costs, and read down the column for what the Dockerfile change buys. The 1.50 GB to 0.40 GB move takes 11 seconds out of each deploy and 968 GB out of the monthly transfer.

Runner minutes at those pull times

Job runner rates from the pricing page, checked on 2026-08-13:

Runner labelShapePrice per minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016

At 1.50 GB the pull consumes 220.0 runner minutes per month, which is $0.88 on warp-ubuntu-latest-x64-2x. At 0.40 GB it consumes 58.7 minutes, which is $0.23. Adding transfer on the internet path, the month goes from $119.68 to $31.91.

Two conclusions follow, and they point in different directions. On the internet path the transfer line carries the saving, $87.12 of the $87.77. On the same-Region path the whole difference is $0.65 of runner time, so slimming is a wall clock lever there and machine placement is the invoice lever. The full placement model, including managed NAT residue, is in what ECR pulls cost in GitHub Actions, and the pull-side levers beyond size are in how to reduce Docker image pull time.

Storage stacks on top of both. At $0.10 per GB-month, 30 retained tags of the 1.50 GB image is $4.50 per month against $1.20 for the 0.40 GB image, before any layer sharing between tags.

Teams that want the transfer line removed rather than reduced should read the zero egress page: on the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners.

FAQ

How do I measure the size that actually matters?

Measure the compressed layer sizes in the registry manifest, since those are the bytes a pull moves. Run docker buildx imagetools inspect <image> --raw and sum the layer sizes. docker image ls reports the uncompressed size on disk, which is larger and is not what the network carries.

Does a smaller image reduce my GitHub Actions bill?

Only through the seconds it removes from each job. The larger line is usually cloud data transfer, billed per gigabyte by your registry's provider, so a 1.10 GB reduction across 880 monthly deploy jobs is 968 GB less moved. On a same-Region path that transfer is already $0.00 per GB, and slimming stays a wall clock lever there.

Should I switch to a smaller base image or split the build into stages first?

Split into stages first. The stage split removes the compiler, the package manager caches, and the development dependencies, which is usually the larger share of the total. Swapping the runtime base after that removes what is left of the operating system layer. Stage layout and how it interacts with layer reuse are covered in multi stage Docker builds and layer reuse.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.