A Fully Dockerized Gradle Workflow

In this post, I will introduce the Fully Dockerized Gradle Workflow.

Basic Optimization

Add .dockerignore

Add the .dockerignore file to the root directory of your project.

# Gradle build output
build/

# Gradle wrapper files (except the wrapper JAR itself)
.gradle/
!gradle-wrapper.jar

# IDE files
.idea/
*.iml
.vscode/
*.swp

# Logs
*.log

# OS junk
.DS_Store
Thumbs.db

# Git
.git
.gitignore

gradle.properties optimization

Add the gradle.properties file to the root directory of your project.

# Enable Configuration Cache
org.gradle.configuration-cache=true
# Enable Build Cache (reuse build outputs between runs)
org.gradle.caching=true
# Parallel Execution (build independent modules at the same time)
org.gradle.parallel=true
# Configure Workers & Threads. Set based on your CPU cores (cores - 1 is often a good rule).
org.gradle.workers.max=7
# Configure Incremental Compilation. Only recompile files that actually changed.
kotlin.incremental=true
java.incremental=true
# Avoid Scanning for Updates When Not Needed. Only configures the modules you actually use in a build.
org.gradle.configureondemand=true
########################################
# Optional tuning
########################################
# Parallel & sensible JVM args
org.gradle.jvmargs=-Xmx1g -XX:+UseParallelGC
# Run Kotlin compiler in-process (faster for small/medium projects)
kotlin.compiler.execution.strategy=in-process

build.gradle.kts optimization

//...

// Avoid generating multiple large jars. If you’re using Spring Boot, disable the plain jar if you only need the boot jar
tasks.jar { enabled = false }

tasks.bootJar {
// Give the jar a constant name during the Gradle build
archiveFileName.set(myapp.jar")
// Adds metadata to the JAR manifest
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version
)
}
}

//...

Dockerfile optimization tips

  1. Copy only build metadata first, not sources (layer caching). Put settings.gradle(.kts), build.gradle(.kts), gradle.properties, gradlew, and gradle/ first. Run a lightweight Gradle task (that resolves dependencies) so that those downloads sit in an earlier image layer.
  2. Use BuildKit cache mounts for Gradle caches. Reuse /home/gradle/.gradle (or whatever GRADLE_USER_HOME you set) across docker build invocations so dependency + transform caches persist outside layers.
  3. Only build what you need for the image. Instead of gradle build (which usually runs tests + all archives), run just the task that produces the runnable artifact, e.g. bootJar or shadowJar, and skip tests for the container image build (-x test) unless you intentionally want them there.
  4. Consider allowing the daemon even in Docker. If you chain multiple Gradle invocations in one RUN (e.g., prefetch + build), the daemon helps. A single invocation gains less, so –no-daemon is okay if you only call once. (Inside a disposable container there’s no “leak” concern.)
  5. Use a multi-stage build with a slim runtime base image. Build with a Gradle or JDK image; run on a small JRE / distroless base.
  6. Warm remote build cache (if you have CI). A remote build cache lets local/Docker builds pull already built class/jar outputs (after matching inputs). Requires setup (Gradle Enterprise or an OSS alternative), but can cut first build time dramatically.

Build the Docker Image

Dockerfile

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY build/libs/myapp.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Or

FROM eclipse-temurin:21-jre-alpine AS runtime

ENV TZ=Asia/Shanghai
ARG DEPENDENCY=build/dependency
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","com.taogen.application.SimpleWebApplication"]

Note:

  • Don’t add the build/ directory to the .dockerignore if you use build/ files in the Dockerfile.
  • Change the main class path com.example.demo.DemoApplication to your project main class.

1. Using the eclipse-temurin:21-alpine JDK base image for building

FROM eclipse-temurin:21-alpine AS build
WORKDIR /app
COPY . .
# Time spent on first builds or when files change: About 70 seconds (A simple Spring Boot web application)
RUN ./gradlew build --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/myapp.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

2. Or using the gradle:9-jdk21-alpine Gradle base image for building

FROM gradle:9-jdk21-alpine AS build
WORKDIR /app
COPY . .
# Time spent on first builds or when files change: About 70 seconds (A simple Spring Boot web application)
RUN gradle build --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/myapp.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Very slow on first builds or when file change.

  1. Gradle distribution / wrapper download (if you use ./gradlew and the wrapper isn’t cached yet).
  2. Dependency resolution & download (all your Maven / Ivy artifacts, including transitive ones).
  3. Creation of Gradle’s local caches (module metadata, transformed artifacts, Kotlin DSL script compilation, annotation processors, etc.).
  4. Compilation from scratch (no incremental / incremental annotation processing yet).
  5. Test execution (if you didn’t exclude tests for the image build).
  6. Fat / boot jar packaging (e.g., Spring Boot repackaging).
  7. JIT warm‑up (JVM + Kotlin/Java compilers start cold).
  8. No daemon: –no-daemon forces Gradle to spin up a new JVM, do all configuration, then exit. The Daemon normally amortizes startup cost across builds.
  9. Docker layering mistakes: copying the whole source tree before running Gradle causes every code change to invalidate the dependency download layer, so you re-download every time (feels like “always first time”).
  10. No (or unused) build cache: Gradle build cache and configuration cache aren’t primed yet.

1. Using the eclipse-temurin:21-alpine JDK base image for building

# --- Build stage ---
FROM eclipse-temurin:21-alpine AS build
WORKDIR /app

# Copy only Gradle files first for dependency caching
COPY gradle ./gradle
COPY settings.gradle.kts build.gradle.kts gradle.properties gradlew ./
# or
# COPY settings.gradle build.gradle gradle.properties gradlew ./

# Download dependencies (will be cached unless gradle files change)
# Time spent on first builds or when Gradle config files change: About 70 seconds (A simple Spring Boot web application)
RUN ./gradlew help
# or
# RUN ./gradlew dependencies || return 0

# Now copy the rest of the source code
COPY . .

# Build the application
RUN ./gradlew -x test build

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

2. Or using the gradle:9-jdk21-alpine Gradle base image for building

# --- Build stage ---
FROM gradle:9-jdk21-alpine AS build
WORKDIR /app

# Copy only Gradle files first for dependency caching
COPY gradle ./gradle
COPY settings.gradle.kts build.gradle.kts gradle.properties gradlew ./
# or
# COPY settings.gradle build.gradle gradle.properties gradlew ./


# Download dependencies (will be cached unless gradle files change)
# Time spent on first builds or when Gradle config file change: About 70 seconds (A simple Spring Boot web application)
RUN gradle help
# or
# RUN ./gradlew dependencies || return 0

# Now copy the rest of the source code
COPY . .

# Build the application
RUN gradle -x test build

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Very slow on first builds or when Gradle config files change.

1. Using the eclipse-temurin:21-alpine JDK base image for building

# --- Build stage ---
FROM eclipse-temurin:21-alpine AS build
ARG WORKDIR=/app
WORKDIR ${WORKDIR}

# Copy Gradle config first for dependency caching
COPY gradle ./gradle
COPY settings.gradle.kts build.gradle.kts gradle.properties gradlew ./
# or
# COPY settings.gradle build.gradle gradle.properties gradlew ./

# Use the host's Gradle cache via build args + mount
# Dependencies are downloaded here
# RUN --mount=type=cache,target=/home/gradle/.gradle \
# ./gradlew dependencies || return 0
# Time cost: 5 seconds
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew help
# or ./gradlew dependencies || return 0

# Now copy source code
COPY . .

# Build the application
# RUN --mount=type=cache,target=/home/gradle/.gradle \
# ./gradlew -x test build
# Time cost: 5 seconds
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew -x test bootJar
# or gradlew -x test build

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

2. Or using the gradle:9-jdk21-alpine Gradle base image for building

# --- Build stage ---
FROM gradle:9-jdk21-alpine AS build
WORKDIR /app

# Copy Gradle config first for dependency caching
COPY gradle ./gradle
COPY settings.gradle.kts build.gradle.kts gradle.properties gradlew ./
# or
# COPY settings.gradle build.gradle gradle.properties gradlew ./

# Use the host's Gradle cache via build args + mount
# Dependencies are downloaded here
RUN --mount=type=cache,target=/home/gradle/.gradle \
gradle help
# or gradle dependencies || return 0

# Now copy source code
COPY . .

# Build the application
RUN --mount=type=cache,target=/home/gradle/.gradle \
gradle -x test bootJar
# or gradle -x test build

# --- Runtime stage ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

To set the BuildKit environment variable when running the docker build command, run:

$ DOCKER_BUILDKIT=1 docker build -t myapp .

Or

$ export DOCKER_BUILDKIT=1
$ docker build -t myapp .

Using dev-mode container

dev-mode container

  • Mounts your code: instant changes.
  • Persistent Gradle cache: fast restarts.

1. Using the eclipse-temurin:21-alpine JDK base image for running

docker-compose.yml (Dev Mode)

services:  
app:
image: eclipse-temurin:21-alpine
working_dir: /app
command: ./gradlew bootRun --no-daemon
ports:
- "8080:8080"
volumes:
# Mount project source code
- .:/app
# Mount Gradle cache from host
- gradle-cache-1:/app/.gradle
- gradle-cache-2:/root/.gradle

volumes:
gradle-cache-1:
gradle-cache-2:

2. Or using the gradle:9-jdk21-alpine Gradle base image for running

services:
app:
image: gradle:9-jdk21-alpine
working_dir: /app
command: gradle bootRun --no-daemon
ports:
- "8080:8080"
volumes:
# Mount project source code
- .:/app
# Mount Gradle cache from host
- gradle-cache:/home/gradle/.gradle

volumes:
gradle-cache:

Start the dev container

docker compose up -d

Using Makefile

Using a Makefile so you can run make dev and make build instead of typing long Docker commands. It makes the workflow even cleaner.

Add the Makefile to the root directory of your project.

Makefile

# Project name
APP_NAME=myapp

# Default target
.DEFAULT_GOAL := help

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

dev: ## Run app in dev mode with live reload
docker compose up

dev-down: ## Stop dev mode
docker compose down

test: ## Run tests in dev container
docker compose run --rm app ./gradlew test

prod-build: ## Build production image (requires BuildKit)
export DOCKER_BUILDKIT=1
DOCKER_BUILDKIT=1 docker build -t $(APP_NAME) .

prod-run: ## Run production image
docker run -p 8080:8080 $(APP_NAME)

clean: ## Remove build artifacts and Gradle cache
docker compose down -v
rm -rf build .gradle

Usage

make help        # See available commands
make dev # Start in dev mode (hot reload)
make dev-down # Stop dev container
make test # Run tests inside container
make prod-build # Build small production image
make prod-run # Run production image
make clean # Clean everything