← Docker articles

ACE-U field notes

Faster Docker builds for a Java service: context, layers and cache mounts

Most of the speed-up comes from three boring changes: a smaller build context, a sensible layer order and a BuildKit cache mount for Gradle.

Shubham Anandani··4 min read

Problem

When a container build takes minutes, people stop iterating and start batching changes, which is when bugs hide. Teams usually respond by turning caching on and hoping. That helps a little, but the real gains come from what you send to the builder and the order you ask it to do things in. A Java service is a good case. The dependencies change rarely and the source changes constantly, yet a naive Dockerfile downloads the world on every edit.

Options and trade-offs

  • Do nothing and rely on the default layer cache: fine until the first COPY of the whole repo, after which every code change re-downloads dependencies.
  • Copy only the build files first, resolve dependencies, then copy the source: much better, but a changed build file still re-downloads everything.
  • Add a BuildKit cache mount for the Gradle home: dependency downloads survive across builds, even when the layer itself is invalidated.

My recommendation

Start with a .dockerignore, because it is the cheapest win. Order layers from least to most volatile, use a cache mount for the dependency cache, and use a multi-stage build so the JDK and build tools stay out of the runtime image. Pin dependency versions so speed does not come at the cost of repeatability.

Worked example

A two-stage Dockerfile for a Gradle project. The cache mount is only available during the RUN step, so the build that needs it happens inside the same step.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src

# Build files first: this layer only changes when dependencies change
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew --no-daemon dependencies > /dev/null

# Source last: this is the layer that changes on every edit
COPY src src
RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew --no-daemon installDist -x test

FROM eclipse-temurin:21-jre
WORKDIR /opt/app
RUN useradd --uid 10001 --create-home appuser
COPY --from=build /src/build/install/app/ ./
USER 10001
EXPOSE 8080
ENTRYPOINT ["/opt/app/bin/app"]

And the context file that keeps the build fast before any layer is considered:

.git
.gradle/
build/
out/
*.iml
.idea/
.env

To see the effect, time a cold build, change one line of source, and time it again:

time DOCKER_BUILDKIT=1 docker build -t demo/app:dev .
echo '// touch' >> src/main/java/App.java
time docker build -t demo/app:dev .

The second build should skip the dependency steps entirely. Bump a dependency version and confirm that step does re-run.

Checklist

  1. Add a .dockerignore before changing anything else.
  2. Copy build files, resolve dependencies, then copy source.
  3. Use a cache mount for the Gradle home, and run the build in the same RUN step.
  4. Use a multi-stage build and run as a non-root user.
  5. Pin dependency versions, and rebuild without cache now and then to catch drift.

Where this doesn't apply

If your dependencies change on every commit, or the project is tiny, none of this will be noticeable. A remote build service with its own cache may also change which of these steps matter.

One honest limitation

Cache mounts live on the builder, not in the image, so a fresh CI runner starts cold unless you export and import the cache. A fast local build says little about a CI build.

Sources

  • Docker Docs: Optimize cache usage in builds
  • Docker Docs: Multi-stage builds
  • Docker Docs: Dockerfile reference (RUN --mount)
  • Docker Docs: .dockerignore