Building a good image
Shipping furniture flat-packed is cheaper than shipping it assembled, and if only one shelf changes, you shouldn’t have to ship the whole wardrobe again. A container image is your service flat-packed: the operating system bits, Java, your libraries, your code. Built well, it’s small, and a code change only ships the part that changed.
Image quality affects deploy speed, startup, attack surface and cost. The essentials: multi-stage builds (build tools never reach the runtime image), a minimal JRE base (or distroless) pinned by version, a non-root user, Spring Boot’s layered extraction so dependency layers stay cached across releases, a .dockerignore, no secrets in any layer, and reproducible tags (git SHA, not latest). Cloud Native Buildpacks (./mvnw spring-boot:build-image, ./gradlew bootBuildImage) do most of this without a Dockerfile.
# 1. Build the jar
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline # cached until pom.xml changes
COPY src src
RUN ./mvnw -q package -DskipTests
# 2. Split it into layers (Spring Boot 3.3+)
FROM eclipse-temurin:25-jdk AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
# 3. The runtime image: JRE only, non-root, layers from least to most frequently changed
FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app
USER app
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]- Stages: the first stage has Maven and the JDK to build the jar; only the result is copied into the final image, which has just a JRE. The build tools never ship.
- Layers: Docker caches each step. Libraries (
dependencies, ~150 MB) rarely change, your code (application, a few MB) changes every release, so the code goes last: a new release only uploads and downloads the small top layer. - Non-root: if someone breaks into the app, they’re not root inside the container.
- No build tool needed at all:
./mvnw spring-boot:build-imagebuilds a similar image with Buildpacks.
Tap the line with the bug.
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY . .
ENV SPRING_DATASOURCE_PASSWORD=Pr0d-db-2026!
RUN ./mvnw -q package -DskipTests
ENTRYPOINT ["java", "-jar", "target/shop.jar"]Hint: Who can read an image's environment variables and layers once it's in a registry?
The database password is baked into the image: anyone who can pull it (CI, every developer, a leaked registry token) can run docker inspect or docker history and read it, and it can’t be rotated without rebuilding. Removing the line in a later layer doesn’t help; earlier layers keep it.
Secrets are injected at runtime (Kubernetes Secrets, a secrets manager, environment variables set by the platform), never at build time. The Dockerfile has other problems too: COPY . . copies .git, local .env files and IDE folders (add a .dockerignore), the JDK and Maven ship in the final image, it runs as root, and every code change invalidates the dependency download. The layered Dockerfile above fixes all of them.
# Kubernetes: the secret comes from the cluster, not the image
env:
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef: { name: shop-db, key: password }Predict the output
With the layered Dockerfile, you change one line in a controller and rebuild and push. Which layers are rebuilt and uploaded?
Docker reuses a cached layer when its inputs haven't changed. pom.xml didn't change, so dependency:go-offline is cached; the extracted dependencies layer has the same content as before, so the registry already has it. Only the application layer (your classes and resources, a few MB) is new. Deploys push and pull megabytes instead of hundreds of megabytes, which also makes new pods start faster.
Write a Dockerfile
Write a Dockerfile for a Kotlin Spring Boot service built with Gradle (./gradlew bootJar produces build/libs/app.jar). Requirements: build in a separate stage, run on a JRE, as a non-root user, with dependencies in their own layer.
›
The structure is the same as the Maven version; only the build commands and the jar path change. Copy the Gradle wrapper and build files before the sources, so dependency resolution is cached.
SolutionTry it yourself first
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN ./gradlew --no-daemon dependencies > /dev/null # cached until build files change
COPY src src
RUN ./gradlew --no-daemon bootJar
FROM eclipse-temurin:25-jdk AS extract
WORKDIR /app
COPY --from=build /src/build/libs/app.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app
USER app
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Add a .dockerignore with .git, build, .gradle, .idea and any .env files, so they never enter the build context. Tag images with the git commit (shop:3f9a2c1), not just latest, so you always know what’s running and can roll back to an exact version.
Supply chain and startup
- Pin and scan: pin base images by digest for reproducible builds, rebuild regularly for security patches, scan images (Trivy, Grype) in CI, and generate an SBOM (Buildpacks and
spring-bootplugins can embed one). - Smaller attack surface: distroless or
-jreminimal images have no shell or package manager; jlink can produce a custom runtime with only the modules you use. - Startup time: Class Data Sharing archives and, since Java 24/25, the AOT cache (
-XX:AOTCache, JEP 483 and successors) cut startup substantially; Spring Boot can produce a training run during the build. GraalVM native images start in tens of milliseconds at the cost of build time and reflection configuration.
- Multi-stage builds, JRE/distroless base pinned by digest, non-root, layered extraction (
-Djarmode=tools … extract --layers --launcher, Boot 3.3+) or Buildpacks (spring-boot:build-image). .dockerignore; no secrets in any layer (runtime injection only); immutable tags (git SHA).- Scan images, ship SBOMs, rebuild for patches; CDS/AOT cache or native images for startup.
Check this part
Why put dependencies and application code in separate image layers?
Where should the database password come from?
Why tag images with the git commit instead of only
latest?