← Blog · Guides

Docker Image Layers: Why One Fat Layer Costs You Even Under the Limit

October 28, 2026

On 28 October 2026, Vercel raised one of its container registry limits: "Vercel Container Registry now supports up to 2 GB per compressed image layer, up from 500 MB, allowing larger layers to be pushed without splitting them. The total image size limit remains 15 GB." Vercel says who it is for: "images with large toolchains or dependency sets, such as custom Vercel Sandbox environments for coding-agent evaluations."

Those are Vercel's figures for Vercel's registry, not an industry standard. Other registries set their own limits, or none. But the change is a good moment to look at a problem that does not go away when a ceiling moves: one very large layer is expensive long before it hits any limit. This post is for someone whose push just failed on a layer size, or whose builds and deploys got slow without anything obvious changing.

Two ceilings, and the one that bites first

Vercel's announcement names two limits: a per-layer limit (now 2 GB, compressed) and a total image limit (15 GB, unchanged). They measure different things. An image is a stack of layers, and each layer is stored and transferred as its own compressed file, identified by a hash of its contents. A 3 GB image made of ten 300 MB layers passes a 500 MB per-layer limit. A 900 MB image whose dependencies all landed in one layer fails it.

That is why the per-layer limit is usually the one people meet first. Total size grows slowly as an app grows. Layer size jumps the day someone puts a whole toolchain install into one RUN line.

How a single layer gets fat

In a Dockerfile, every RUN, COPY and ADD that changes the filesystem produces a layer. Three habits produce most oversized ones:

  • One RUN that installs everything. Compilers, SDKs, system libraries and language packages chained with && into a single instruction become a single layer.
  • COPY . . before the dependency install. The install then runs after every code change, and its output is rebuilt as a new layer every time, as the test below shows.
  • Build tools left in the runtime image. The compiler you needed to build a native module is still in the image that runs in production, in a layer nobody needs at runtime.

What a fat layer costs you while it is still under the limit

A layer is the unit of transfer and the unit of cache. When you push, the registry already has any layer whose hash it has seen and skips it. Anything else is uploaded in full. Pulls work the same way in reverse. So if one byte inside a 2 GB layer changes, the whole 2 GB is a new layer: built again, pushed again, pulled again by every machine that runs it.

The build cache follows the same rule. When the inputs to one instruction change, that instruction and every instruction after it are rebuilt. Order decides how much of your image a small change throws away.

We tested this on 29 October 2026 with Docker and BuildKit, pushing to a throwaway local registry. A 40 MB file of random bytes stood in for a toolchain install. Two Dockerfiles differ only in order:

# Dependencies first
FROM alpine:latest
RUN head -c 40000000 /dev/urandom > /toolchain.bin
COPY app.txt /app.txt
# App first
FROM alpine:latest
COPY app.txt /app.txt
RUN head -c 40000000 /dev/urandom > /toolchain.bin

We built and pushed both, changed one line in app.txt, then built and pushed again. The compressed sizes come from the registry's manifests:

OrderWhat the rebuild didNew layer pushed
Dependencies firstToolchain step CACHED; registry reported "Layer already exists"105 bytes
App firstToolchain step ran again40,012,403 bytes

Same change, same final files, about 380,000 times more data to push. A real install step behaves the same way. Re-running it usually produces a layer with a different hash even when the package versions have not changed, because timestamps and generated files differ. Scale the 40 MB to a 1.5 GB toolchain and every one-line code change becomes a 1.5 GB push and a 1.5 GB pull.

Deleting a file in a later step does not make the image smaller

Layers only add. A later instruction that deletes a file hides it from the running container, but the earlier layer that contains it is still part of the image and is still pushed and pulled. In the same test:

DockerfilePushed layer holding the 40 MB file
RUN head -c 40000000 /dev/urandom > /big.bin then a separate RUN rm /big.bin40,012,401 bytes
Both in one RUN, joined with &&32 bytes

This is why package caches should be removed in the same instruction that creates them:

RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential \
 && rm -rf /var/lib/apt/lists/*

Measure the right size before you split anything

docker history lists each layer and its size, which shows you where the weight is:

docker history your-image:tag

That size is uncompressed. Vercel's limit is per compressed layer, and the two can be very far apart. In our test, a layer holding 40 MB of zeros showed as 40 MB in docker history and was 38,983 bytes in the registry, because zeros compress almost completely. Random data, like most compiled binaries and already-compressed archives, barely compresses at all. To see the compressed sizes a registry actually stores, read the manifest of an image you have pushed:

docker manifest inspect -v registry.example.com/your-image:tag

Each platform's entry lists its layers with a size in bytes. Current Docker versions also push a provenance attestation by default, which shows up as an extra entry with an unknown platform. Ignore it when you are looking for your image's layers.

The fix is ordinary Dockerfile craft

  1. Order layers from least to most volatile. Base image, then system packages, then dependency manifests and the install, then your code last. A code change then rebuilds only the last, small layer.
  2. Split the toolchain from the app. If a toolchain really is large, give it its own instruction, so it is cached and pushed once and reused until it actually changes.
  3. Keep build tools out of the runtime image with a multi-stage build. Build in one stage, copy only the output into a clean final stage.
  4. Add a .dockerignore so COPY . . does not pull node_modules, .git or build output from your machine into a layer.

A Node.js version of all four, which we built and ran on Node.js 22 before publishing:

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The dev dependencies and the build's intermediate files stay in the build stage. Only the production dependencies and the built output reach the final image.

Python packages that need a compiler can be built into wheels in one stage and installed in the next without the wheels ever becoming a layer. BuildKit's bind mount makes the files available for that one instruction only:

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
RUN --mount=type=bind,from=build,source=/wheels,target=/wheels \
    pip install --no-cache-dir --no-index --find-links=/wheels /wheels/*
COPY . .
CMD ["python", "app.py"]

We built and ran this one too. The final image has no /wheels directory, and its install layer holds only the installed packages. If your packages compile native code, install the build tools in the first stage only.

What a higher limit does and does not change

A higher per-layer limit means a push that used to fail now succeeds. It does not make that layer cheaper to rebuild, push or pull, and it does not change which instruction your next code change invalidates. If you hit the old 500 MB limit, the limit was telling you something about how the image is built. Splitting by volatility fixes the cause, and the image will then fit under almost any registry's limit as a side effect.

If you are earlier in the journey, start with Docker Basics for Web Developers and How to Dockerize Your Web Application, then come back to this when the images get big.

Source: Vercel changelog, "Vercel Container Registry now supports 2 GB compressed image layers", published 28 October 2026, read 29 October 2026. Test figures are from our own builds on 29 October 2026, Docker with BuildKit and a local registry.