The problem: "it works on my machine" and a 700 MB image

A developer builds the jar locally, copies it into an image based on a full JDK, and runs it as root. The image is large, every code change invalidates the whole dependency layer, the container is killed by the platform with no clear message, and nobody knows which Java version actually runs in production. Docker is meant to make environments reproducible, but a careless Dockerfile only moves the confusion into a box.

This article covers a Dockerfile you can reuse for a Spring Boot 3 service and the settings around it that matter in practice.

The core idea

An image is a stack of read-only layers. Docker caches each layer and reuses it if the instruction and its inputs did not change. Good Dockerfiles put things that change rarely (the base image, dependencies) before things that change often (your classes). A second idea: the image you ship should contain only what is needed to run the application, not what is needed to build it. Multi-stage builds give you both.

A multi-stage Dockerfile with layered jars

Spring Boot can split an executable jar into layers: dependencies, Spring Boot loader, snapshot dependencies and application classes. Only the last layer changes on a normal commit, so rebuilds and image pushes stay small. The extraction command depends on the Spring Boot version: from Boot 3.3 use java -Djarmode=tools -jar app.jar extract --layers, as below; in earlier versions use java -Djarmode=layertools -jar app.jar extract. The layer directory names are the same, but check the reference documentation of your Boot version.

# build stage
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline
COPY src src
RUN ./mvnw -q package -DskipTests
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination extracted

# runtime stage
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Several choices here are deliberate:

  • The pom.xml is copied and dependencies resolved before src is copied, so a source change does not re-download dependencies.
  • The runtime stage uses a JRE image. It has no compiler and fewer tools, so it is smaller and exposes less.
  • The process runs as a non-root user. If the application is compromised, the attacker does not start as root inside the container.
  • Tests are not run in the image build. Run them in CI before the image is built, so you do not pay for them twice.

Check the exact jar-extraction command for your Spring Boot version in its documentation, because the layering tool has changed between releases.

Add a .dockerignore file excluding target/, .git/, IDE folders and local .env files. Without it, the build context is large and secrets can end up in layers.

Container memory and the JVM

A container limit is not the same as a JVM heap size. If you give a container 1 GiB and the JVM decides its heap can be a large part of that, the heap plus metaspace, thread stacks and native memory can exceed the limit, and the kernel kills the process. This often appears as a container that restarts with exit code 137 and no Java stack trace.

Modern JVMs read the container limit. Control the heap relative to it with a percentage flag instead of a fixed -Xmx:

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70"

Leave room for non-heap memory. The right percentage depends on the number of threads, direct buffers and libraries, so measure it for your service rather than copying a number. See JVM memory and garbage collection for what lives outside the heap.

Health checks

An orchestrator needs to know whether a container is alive and whether it is ready for traffic. Spring Boot Actuator exposes this with liveness and readiness probes.

management:  endpoint:    health:      probes:        enabled: true  endpoints:    web:      exposure:        include: healthserver:  shutdown: graceful

Use the liveness endpoint for "restart me if I am stuck" and readiness for "do not send me traffic yet". Do not include a slow downstream dependency in liveness: if the database is down, restarting every instance does not help and makes recovery worse. Graceful shutdown lets in-flight requests finish when the platform sends SIGTERM.

docker compose for local development

Compose describes the application and its dependencies in one file, so a new developer runs one command instead of installing PostgreSQL by hand.

services:  db:    image: postgres:16-alpine    environment:      POSTGRES_DB: app      POSTGRES_USER: app      POSTGRES_PASSWORD: local-only    ports: ["5432:5432"]    healthcheck:      test: ["CMD-SHELL", "pg_isready -U app"]      interval:       retries: 10  app:    build: .    depends_on:      db:        condition: service_healthy    environment:      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app      SPRING_DATASOURCE_USERNAME: app      SPRING_DATASOURCE_PASSWORD: local-only    ports: ["8080:8080"]

Notice depends_on with a health condition. Plain depends_on only waits for the container to start, not for PostgreSQL to accept connections. The password above is for a throwaway local database only; never put real credentials in a compose file that is committed.

Configuration, signals and logs

Containers are replaceable, so configuration must come from outside. Spring Boot maps environment variables to properties (SPRING_DATASOURCE_URL becomes spring.datasource.url), which is why the same image can run on a laptop, in staging and in production. Write logs to standard output and let the platform collect them; a log file inside the container is lost with it.

Also pay attention to how the process starts and stops. Use the exec form of ENTRYPOINT, as in the example, so that the JVM is PID 1 and receives SIGTERM directly. With the shell form, a wrapper shell may swallow the signal and the platform ends up killing the container after a timeout, dropping in-flight requests.

Common mistakes

  • Using latest tags for base images, so builds change silently.
  • Copying the whole project before resolving dependencies, which destroys layer caching.
  • Running as root and with a writable filesystem when neither is needed.
  • Baking environment-specific configuration or secrets into the image. The same image should run in every environment; configuration comes from environment variables or mounted files.
  • Setting -Xmx larger than the container limit.
  • Treating the database being "up" as the same as it being "ready".
  • Storing state inside the container filesystem. It disappears when the container is replaced.

Trade-offs

Distroless or very small base images reduce the attack surface but make debugging harder because there is no shell. Native images built with GraalVM start fast and use less memory, but build times are longer and reflection-heavy libraries need extra configuration. For most teams a JRE image with layered jars is a sensible starting point, and you can move to something more specialised when you have a measured reason.

Checklist

  • Multi-stage build with a JRE runtime image and pinned tags.
  • Dependency layer cached separately from application classes.
  • Non-root user and a .dockerignore file.
  • Heap set as a percentage of the container limit.
  • Liveness and readiness probes, graceful shutdown.
  • A compose file that starts the app with its database for local work.

Where to go next