Writing a Dockerfile

Write a multi-stage Dockerfile that builds the jar, splits it into layers and runs it on a JRE as a non-root user that shuts down cleanly.

SyntaxFROM eclipse-temurin:25-jdk AS build # compile FROM eclipse-temurin:25-jdk AS extract # extract --layers FROM eclipse-temurin:25-jre # COPY layers, USER 1001 ENTRYPOINT ["java", "-jar", "application.jar"]

Write your own Dockerfile when buildpacks do not fit: a mandated base image, extra operating system packages, or a CI system without a Docker daemon for the build plugin. The aim is the same as with buildpacks: small layers, a JRE rather than a JDK, no root user, and a process that receives the stop signal.

Three stages

  1. Build on a JDK image. Copy the Maven wrapper and pom, then the source, and run the build. A BuildKit cache mount keeps ~/.m2 between builds without baking it into a layer.
  2. Extract. java -Djarmode=tools -jar application.jar extract --layers splits the jar into dependencies, spring-boot-loader, snapshot-dependencies and application directories.
  3. Runtime on a JRE image. Copy the layers from least to most often changed. Docker reuses unchanged layers, so a code change pushes a layer of a few hundred kilobytes instead of the whole dependency set.

The extracted directory still contains an application.jar whose manifest points at the lib folder beside it, so java -jar application.jar works and starts faster than the nested jar.

Signals and PID 1

Orchestrators stop a container by sending SIGTERM to process 1, then SIGKILL after a grace period. With the exec form, ENTRYPOINT ["java", ...], Java is process 1 and Spring Boot runs its graceful shutdown. With the shell form, ENTRYPOINT java -jar app.jar, a shell is process 1, does not forward the signal, and the JVM is killed mid-request.

Memory

The JVM reads the container's memory limit. Without -Xmx it takes a quarter of that limit as heap, which wastes most of the container. -XX:MaxRAMPercentage=75 leaves room for metaspace, thread stacks and direct buffers. Put it in JAVA_TOOL_OPTIONS so it can be changed at deploy time.

Mistakes people make

  • Running as root. A remote code execution bug then owns the container. Create a user and switch with a numeric USER, which Kubernetes' runAsNonRoot check can verify.
  • No .dockerignore. The whole project, including .git and local .env files, is sent to the builder and can end up in a layer.
  • Floating base tags. Pin a major version, rebuild often for patches, and scan the image.
  • Adding curl just for a HEALTHCHECK. In Kubernetes, probes call the actuator endpoints instead.

Example

Example · dockerfile
# syntax=docker/dockerfile:1

# 1. Build the jar with the same JDK the image runs
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q package -DskipTests

# 2. Split the jar into layers that change at different rates
FROM eclipse-temurin:25-jdk AS extract
WORKDIR /extract
COPY --from=build /src/target/*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination out

# 3. Runtime: JRE only, non-root, layers from least to most often changed
FROM eclipse-temurin:25-jre
RUN groupadd --system --gid 1001 spring \
 && useradd --system --uid 1001 --gid spring --no-create-home spring
WORKDIR /app
COPY --from=extract /extract/out/dependencies/ ./
COPY --from=extract /extract/out/spring-boot-loader/ ./
COPY --from=extract /extract/out/snapshot-dependencies/ ./
COPY --from=extract /extract/out/application/ ./
USER 1001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
# Exec form: java is PID 1 and receives SIGTERM directly
ENTRYPOINT ["java", "-jar", "application.jar"]

When to use it

  • Build an image on a company-approved base image that buildpacks cannot use.
  • Cut image pushes from hundreds of megabytes to a small application layer per release.
  • Fix a service whose deployments drop in-flight requests because SIGTERM never reached Java.

More examples

A .dockerignore for a Spring Boot project

The build stage compiles from source, so local <code>target/</code> output is noise that only slows the context upload. Excluding <code>.git</code> and <code>.env</code> keeps history and local secrets out of the builder entirely, which is safer than relying on never copying them.

Example · text
# .dockerignore: keep the build context small and free of secrets
target/
build/
.gradle/
.idea/
*.iml
.git/
.env
compose.yaml

Build and run with production limits

Running locally with <code>--memory</code> and <code>--cpus</code> shows the heap and thread pool sizes you will get in production. Relaxed binding maps <code>SPRING_DATASOURCE_URL</code> to <code>spring.datasource.url</code>, so the same image runs in every environment. <code>docker stop</code> sends SIGTERM first, like Kubernetes does.

Example · bash
docker build -t bookings:1.4.0 .

# Give it the same limits it will have in production
docker run --rm -p 8080:8080 --memory=768m --cpus=1 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/bookings \
  -e SPRING_DATASOURCE_PASSWORD="$DB_PASSWORD" \
  bookings:1.4.0

# Stop it the way an orchestrator does, and watch the shutdown in the log
docker stop --timeout 30 <container-id>

Prove the stop signal arrives

A <code>SmartLifecycle</code> bean's <code>stop()</code> runs during an orderly shutdown. If <code>docker stop</code> takes the full timeout and this line never appears, the signal is not reaching the JVM: check the <code>ENTRYPOINT</code> form and any wrapper script, which must use <code>exec java ...</code>.

Example · java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;

@Component
public class ShutdownLogger implements SmartLifecycle {

    private static final Logger log = LoggerFactory.getLogger(ShutdownLogger.class);

    private volatile boolean running;

    @Override
    public void start() {
        running = true;
    }

    @Override
    public void stop() {
        // Only printed when SIGTERM reaches the JVM. Missing in the log means
        // the process was killed, usually because of a shell-form ENTRYPOINT.
        log.info("Shutdown signal received, stopping");
        running = false;
    }

    @Override
    public boolean isRunning() {
        return running;
    }
}

Discussion

  • Be the first to comment on this lesson.