Bar chart of seconds to rebuild after a one-line code change. Python: 17.5 s copying everything first, 1.6 s with requirements.txt first. Spring Boot: 25.4 s copying everything first, 6.2 s with a Maven cache mount. .NET 8: 9.4 s copying everything first, 5.6 s with app.csproj first

Most deploys change a few lines of code and no dependencies, yet many Dockerfiles reinstall every package on every build. Docker caches each step and reuses it until something it depends on changes, so the order of the steps decides how much work a code-only rebuild redoes. BuildKit cache mounts add a second cache for package downloads. We built the same small Python, Spring Boot and .NET apps 144 times with four Dockerfile layouts and timed a cold build, a rebuild with no change, a code-only change and a dependency change.

Short version: copying the lock file first and installing before copying the code cut a code-only rebuild from 17.5 s to 1.6 s for Python and from 9.4 s to 5.6 s for .NET. For Spring Boot the Maven cache mount mattered more than the order: 25.4 s down to 6.2 s, and the classic mvn dependency:go-offline step made cold builds 21 s slower because it downloads 267 jars where the build needs 109. A cache mount saved 6 s of Python's 19 s dependency reinstall; switching pip to uv saved another 6.

How we measured

Each app answers GET / with a small JSON object and pulls in the kind of dependencies a real SaaS app starts with. A script copies the app to a fresh folder, empties the build cache with docker builder prune -af (base images stay pulled, so we time the build, not the base image download), then builds four times in a row: cold, again with nothing changed, after editing one string in the code, and after adding one dependency. Every layout ran 3 times; the tables show the median.

text
machine    Intel i5-12500H, Docker Desktop on WSL2 (6 vCPU, 12 GB for the VM), Docker Engine 29.6.1, BuildKit
           home broadband; every package came from the public registries (PyPI, Maven Central, nuget.org)
Python     python:3.12-slim, pip 25.0.1; fastapi, uvicorn[standard], sqlalchemy, psycopg[binary],
           pydantic-settings, httpx (pinned in requirements.txt); added: python-multipart
Java       maven:3.9-eclipse-temurin-21 (Maven 3.9.16) to build, eclipse-temurin:21-jre to run;
           Spring Boot 3.5.6 starter-web + actuator; added: org.postgresql:postgresql
.NET       dotnet/sdk:8.0 (SDK 8.0.427) to build, dotnet/aspnet:8.0 to run; Npgsql 8.0.5 + Dapper 2.1.35;
           added: Serilog.AspNetCore 8.0.3
runs       4 layouts x 4 cases x 3 stacks x 3 runs = 144 builds, all succeeded; each final image answered

The four layouts

Shown for Python; the Java and .NET versions follow the same pattern with pom.xml and app.csproj in place of requirements.txt, and a second stage that copies only the jar or the publish output into the runtime image.

dockerfile
# 1. copy everything, then install: any changed file reruns the install
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

# 2. the same, with a BuildKit cache mount for pip's download cache
COPY . .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# 3. lock file first: the install layer is reused until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# 4. lock file first, with the cache mount
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .

Why the order matters: a COPY step's cache key is a checksum of the files it copies (modification times are ignored), and once a step misses the cache every step after it runs again. With COPY . . near the top, a one-line code change reruns the install. A cache mount is different: it is a folder that BuildKit keeps between builds and mounts only while that RUN step runs. It is not part of the image, and it survives when the layer itself has to be rebuilt, so a rerun install finds its downloads already there. Cache mounts need no # syntax line on current Docker; BuildKit has been the default builder since Docker Engine 23.

Python: lock file first takes a code change from 17.5 s to 1.6 s

DockerfileCold buildNo changeCode-only changeOne dependency added
COPY . . then pip install18.9 s1.1 s17.5 s18.6 s
COPY . . + pip cache mount19.2 s1.0 s12.6 s13.2 s
requirements.txt first18.8 s1.1 s1.6 s19.2 s
requirements.txt first + cache mount19.5 s1.2 s1.6 s12.8 s

With the lock file first, a code change only reruns the final COPY; nothing is installed. The cache mount helps when the install has to run: 18.6 s down to 12.8 s after a dependency change. That 6 s is the download of 19 MB in 27 wheels; the other 11 to 12 s is pip unpacking and installing them and compiling the .pyc files, which the download cache can't skip. Layout 4 is the clear winner: the fastest or within 0.1 s of it in every case except cold, where all four took about 19 s.

Since the install itself was the slow part, we tried uv in place of pip in layout 4, with its own cache mount and bytecode compilation turned on so it does the same work as pip. Three more runs each:

Layout 4 withCold buildCode-only changeOne dependency added
pip install19.2 s1.9 s13.9 s
uv pip install --system15.0 s2.0 s7.6 s
dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.9.5 /uv /usr/local/bin/uv
ENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv uv pip install --system -r requirements.txt
COPY . .
USER 10001
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

UV_LINK_MODE=copy stops uv warning that it can't hardlink from the cache mount into the image, which sit on different filesystems.

Spring Boot: the Maven cache mount beats the order

DockerfileCold buildNo changeCode-only changeOne dependency added
COPY . . then mvn package29.0 s1.3 s25.4 s29.2 s
COPY . . + Maven cache mount29.3 s1.3 s6.2 s7.1 s
pom.xml first, dependency:go-offline50.4 s1.3 s6.4 s49.2 s
pom.xml first, go-offline + cache mount47.4 s1.4 s6.3 s12.5 s

The usual Maven advice is layout 3: copy pom.xml, run mvn dependency:go-offline, then copy src. It does make a code change fast (6.4 s, of which about 5 s is Maven starting and compiling), but cold builds and dependency changes took 50 s instead of 29 s. The reason is what go-offline downloads. In an empty local repository, mvn package alone fetched 109 jars (57 MB); dependency:go-offline fetched 267 jars (97 MB), because it resolves every plugin Maven might use, not only the ones this build runs. It is thorough, though: after go-offline the package step downloaded nothing, and mvn -o package (offline) built fine with Maven 3.9.16 and the plugin versions Spring Boot 3.5's parent pins. The old complaint that go-offline misses plugin dependencies (Maven issue MDEP-82) didn't show up in this setup.

A cache mount on /root/.m2 makes the download a one-time cost no matter which step reruns. With it, plain COPY . . was the fastest layout for both changes: 6.2 s for code, 7.1 s for a new dependency, because Maven fetches only the one new jar. With go-offline and the mount, a dependency change still took 12.5 s: a second Maven start and the full plugin resolution, done offline from the mount, every time pom.xml changes.

dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/target/app.jar .
USER 10001
CMD ["java", "-jar", "app.jar"]

.NET: project file first saves 4 s, the cache mount little

DockerfileCold buildNo changeCode-only changeOne dependency added
COPY . . then dotnet publish11.3 s1.2 s9.4 s12.4 s
COPY . . + NuGet cache mount11.5 s1.3 s7.4 s10.9 s
app.csproj first, restore, then code11.9 s1.3 s5.6 s14.1 s
app.csproj first + cache mount11.7 s1.3 s5.8 s12.5 s

Copying app.csproj first and running dotnet restore as its own step cut a code change from 9.4 s to 5.6 s; what remains is the compile and publish. The cache mount on /root/.nuget/packages hardly changed anything here, and the dependency-change runs varied by up to 4.6 s between runs, more than the differences between layouts. The reason is size: our app's packages came to 15 MB in four packages, because ASP.NET Core itself ships in the SDK image and isn't downloaded. An app with dozens of packages has more to gain from the mount; ours didn't.

One trap if you use the mount: the packages live only in the mount, not in a layer. Restoring with the mount and then running dotnet publish --no-restore without it failed with error NETSDK1064: Package Microsoft.Extensions.Logging.Abstractions, version 8.0.0 was not found. Mount the same cache on both steps. The same goes for Maven: the step that compiles has to see the .m2 mount the download step filled.

dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY app.csproj .
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages dotnet restore
COPY . .
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages dotnet publish -c Release -o /out --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /out .
ENV ASPNETCORE_URLS=http://+:8080
USER 10001
CMD ["dotnet", "app.dll"]

What it adds up to

A no-change rebuild took 1.0 to 1.4 s in every layout, so the cache works for everyone; the layouts differ on the builds you actually make. Say a small team deploys 10 times a working day, 22 days a month, and almost every deploy is a code-only change:

StackWorst code-only rebuildBestSaved per buildSaved per month (220 builds)
Python17.5 s1.6 s15.9 s58 min
Spring Boot25.4 s6.2 s19.2 s70 min
.NET 89.4 s5.6 s3.8 s14 min

An hour a month is not dramatic for one app, but it is an hour of waiting in the middle of deploys, multiplied by every app and every developer who builds locally. Our dependencies were small and our connection fast; a slower link or a bigger dependency tree makes the copy-everything layouts worse, because each rebuild repeats the download.

Cache mounts and CI runners

A cache mount lives in the builder that ran the build. On your laptop or on a server that builds on deploy, that builder stays, so the mount keeps paying off. A fresh CI runner starts with an empty builder: layer cache can be exported and imported (--cache-to / --cache-from), but Docker's documentation says BuildKit doesn't keep cache mounts in the GitHub Actions cache by default. There, the layer from the lock-file-first step is what survives between runs, so put the lock file first even where the mount made the order look unimportant (Spring Boot). Our test ran on one machine with a warm builder, which is the case the mount helps most.

The order that wins

StackLayout to useCode-only / dependency change in our test
Pythonrequirements.txt first, install with a cache mount; uv instead of pip if you can1.6 s / 12.8 s with pip, 2.0 s / 7.6 s with uv
Spring Boot (Maven)Cache mount on /root/.m2 on every mvn step; on CI without a kept builder, pom.xml first6.2 s / 7.1 s (COPY . . + mount)
.NETapp.csproj first, restore as its own step; the same cache mount on restore and publish5.6 s / 12.5 s to 14.1 s

Two more habits keep the cache hitting: a .dockerignore with bin/, obj/, target/ and __pycache__/, so build output from your machine doesn't change the context, and pinned versions in the lock file, so a dependency change is a change you made. Smaller base images speed up the first pull; see our base image test for slim, Alpine, distroless and chiselled, and our cold start test for how long each runtime takes to answer once the image is built.

Sources: our measurements of 30 Oct 2026 (apps, the four Dockerfiles per stack, the build script and the raw timings are kept with the test); Docker docs: Optimize cache usage in builds (layer order, cache mounts for pip and NuGet), Build cache invalidation (COPY checksums, mtime ignored, later steps rerun) and Cache management with GitHub Actions (cache mounts not kept by default); Apache Maven dependency:go-offline and issue MDEP-82; uv Docker integration (cache mount, UV_LINK_MODE, bytecode compilation).

On systemi you deploy a Python, Java, .NET, PHP or static app from its requirements.txt, pom.xml or .csproj, and we build and run the container, with a PostgreSQL database per app and a subdomain.

See how deploys work