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:latestThe 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.
| Component | What it holds | Compressed | Share of total | Ships in production? |
|---|---|---|---|---|
Base image node:22-bookworm | Debian userland, Node runtime, npm, C toolchain for native modules | 0.45 GB | 30 percent | The runtime does, the toolchain does not |
| Dependencies including devDependencies | node_modules with TypeScript, the test runner, linters, type packages | 0.48 GB | 32 percent | Production needs a subset |
| Native build output | node-gyp intermediates, downloaded headers, prebuilt binaries kept after install | 0.30 GB | 20 percent | No |
| Application and build context | dist/, the source tree, .git, test fixtures copied by COPY . . | 0.27 GB | 18 percent | dist/ only |
| Total | 1.50 GB | 100 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
.githubThe release budget after the change:
| Component | Compressed | Change |
|---|---|---|
Base image node:22-slim | 0.07 GB | 0.38 GB removed with the toolchain userland |
Production node_modules | 0.28 GB | 0.20 GB removed with devDependencies |
| Native prebuilt binaries for production dependencies | 0.01 GB | 0.29 GB of build intermediates left behind in prod-deps |
Compiled dist/ | 0.04 GB | 0.23 GB of source, fixtures, and .git filtered by .dockerignore |
| Total | 0.40 GB | 1.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 pullstep 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 image | Pull seconds per job | GB pulled per month | Internet path at $0.09 per GB | Same-Region path at $0.00 per GB |
|---|---|---|---|---|
| 3.0 GB | 30.0 | 2,640 | $237.60 | $0.00 |
| 1.5 GB | 15.0 | 1,320 | $118.80 | $0.00 |
| 1.0 GB | 10.0 | 880 | $79.20 | $0.00 |
| 0.4 GB | 4.0 | 352 | $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 label | Shape | Price per minute |
|---|---|---|
warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 |
warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 |
warp-ubuntu-latest-x64-8x | 8 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.