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.
FROM 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
- Build on a JDK image. Copy the Maven wrapper and pom, then the source, and run the build. A BuildKit cache mount keeps
~/.m2between builds without baking it into a layer. - Extract.
java -Djarmode=tools -jar application.jar extract --layerssplits the jar intodependencies,spring-boot-loader,snapshot-dependenciesandapplicationdirectories. - 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'runAsNonRootcheck can verify. - No
.dockerignore. The whole project, including.gitand local.envfiles, 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
curljust for aHEALTHCHECK. In Kubernetes, probes call the actuator endpoints instead.
Example
# 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.
# .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.
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>.
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