00:00

Docker with Spring Boot

Last updated:

Running Docker with Spring Boot means packaging your application, its Java runtime and its settings into one image that starts the same way on a laptop, a CI server or a cloud cluster. The familiar "it works on my machine" problem goes away, because every environment runs exactly the same image.

This tutorial explains how Spring Boot images are built and why layers matter, then writes a production-ready Dockerfile step by step. You will run a product catalog service with PostgreSQL using Docker Compose, configure memory, health checks and graceful shutdown, use Spring Boot's Docker Compose support for local development, and avoid the pitfalls that make containers slow, insecure or flaky.

1. What Is Docker and Why Use It with Spring Boot?

Docker is a tool that runs applications in containers: isolated processes that bring their own file system, libraries and runtime, while sharing the host's operating system kernel. A few terms you will see throughout this page:

TermMeaning
ImageA read-only package with everything needed to run the app: base OS files, the Java runtime and your jar
ContainerA running instance of an image. You can start many containers from one image
DockerfileA text file with the build steps that produce an image
LayerThe result of one build step. Images are stacks of layers, and unchanged layers are reused from cache
RegistryA server that stores images, such as Docker Hub or a private registry, so other machines can pull them

A Spring Boot application is a good fit for Docker. It is already a single executable jar with an embedded web server, so the image only needs a Java runtime and that jar.

2. When a Spring Boot App Needs Docker

  • Consistent environments. The same image moves from your laptop to test and production, with the same Java version and libraries.
  • Container platforms. Kubernetes, Amazon ECS, Azure Container Apps and Google Cloud Run all run images, so a Docker image is the deployment unit.
  • Local dependencies. PostgreSQL, Redis or Kafka start with one command instead of a manual install on every developer machine.
  • Isolation. Several services can run side by side on one host without fighting over Java versions or ports.

3. How a Spring Boot Docker Image Is Built: Layers and Caching

Each instruction in a Dockerfile creates a layer. When you rebuild, Docker reuses every layer whose input has not changed, and only the layers from the first change onward are rebuilt, pushed and pulled again.

A Spring Boot "fat" jar puts your few kilobytes of code in the same file as tens of megabytes of libraries. Copy that jar as one layer, and every code change produces a brand-new layer of the full size. Spring Boot solves this with layered jars: the jar contains an index (layers.idx) that splits its contents into four groups, from least to most likely to change:

  1. dependencies: released third-party libraries
  2. spring-boot-loader: Spring Boot's own launcher classes, when the extracted layout needs them
  3. snapshot-dependencies: -SNAPSHOT libraries, which change more often
  4. application: your classes and resources
How a layered Spring Boot image is builtThe 53 MB catalog-service fat jar holds both the application classes and all libraries in one file. Extracting it with the tools jar mode and the --layers option splits it into four layers. From bottom to top: dependencies, 51 MB, which changes when libraries are upgraded; spring-boot-loader and snapshot-dependencies, both empty in this application; and application, 12 KB, which changes on every build. A code change rebuilds and pushes only the application layer. Fat jarImage layers (bottom = reused most) catalog-service-1.0.0.jar 53 MB, one file your classes Spring, Hibernate, Tomcat, JDBC driver and other libraries extract --layers application 12 KB changes on every build snapshot-dependencies empty here changes when a SNAPSHOT changes spring-boot-loader empty here changes with Spring Boot upgrades dependencies 51 MB changes when you upgrade libraries Only the top layer changes after a code change.
Figure 1: extracting the jar into layers keeps the 51 MB of libraries in a cached layer, so a code change produces only a 12 KB layer.

For the catalog service in this tutorial, the extracted dependencies layer is 51 MB and the application layer is 12 KB. A code change rebuilds and pushes only those 12 KB.

4. Setup

You need:

  • Docker: Docker Desktop on macOS or Windows, or Docker Engine on Linux. Compose v2 is included as docker compose.
  • JDK 21 and the Maven wrapper (./mvnw) to build the jar.
  • A Spring Boot project. The example uses these dependencies:
pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-docker-compose</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>
Version note: Spring Boot 4 renamed the web starter to spring-boot-starter-webmvc (the old spring-boot-starter-web still works but is deprecated). It also removed the layertools jar mode that older Dockerfiles use with java -Djarmode=layertools -jar app.jar extract. Spring Boot 4.1 rejects it with Unsupported jarmode 'layertools'. Use -Djarmode=tools with extract --layers, as shown below. It has been available since Spring Boot 3.3.

5. Three Ways to Containerize a Spring Boot App

5.1 A Simple Single-Layer Dockerfile

The shortest Dockerfile copies the fat jar and runs it:

Dockerfile (single layer)
FROM eclipse-temurin:21-jre
COPY target/catalog-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

It works, but the 53 MB jar is one layer, so every build and every deployment moves the whole file. Use it only for quick experiments.

5.2 A Layered Dockerfile

A multi-stage Dockerfile uses one stage to split the jar into its layers and a second stage to assemble the final image from them. You control the base image, user, JVM options and every file in the image. Section 6 builds one step by step. This is the approach most teams use.

5.3 Cloud Native Buildpacks

Buildpacks build an image from your project without a Dockerfile. The Spring Boot Maven plugin runs them for you:

Terminal
./mvnw spring-boot:build-image

# or choose the image name yourself
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=javabytechie/catalog-service:1.0.0

The plugin uses the Paketo builder (paketobuildpacks/builder-noble-java-tiny by default in Spring Boot 4.1). It produces a layered image named after your artifact and version (here docker.io/library/catalog-service:1.0.0), picks the Java version from your build, and sizes the JVM's memory for the container automatically. It needs a running Docker daemon.

Single-layer DockerfileLayered DockerfileBuildpacks
Dockerfile to maintainYes, tinyYesNo
Layer cachingPoorGoodGood
Control over image contentsFullFullLimited to buildpack options
JVM memory tuningManualManualAutomatic
Best forQuick testsMost production servicesTeams that don't want to own a Dockerfile

6. Writing a Spring Boot Dockerfile Step by Step

Here is the layered Dockerfile used by the complete example, followed by what each part does.

# Stage 1: split the Spring Boot jar into layers
FROM eclipse-temurin:21-jre AS layers
WORKDIR /build
COPY target/catalog-service-*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# Stage 2: the image that actually runs
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app

# Least likely to change first, so Docker can reuse these layers between builds
COPY --from=layers /build/extracted/dependencies/ ./
COPY --from=layers /build/extracted/spring-boot-loader/ ./
COPY --from=layers /build/extracted/snapshot-dependencies/ ./
COPY --from=layers /build/extracted/application/ ./

USER spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENTRYPOINT ["java", "-jar", "app.jar"]
  • Stage 1 (layers) copies the jar as app.jar and runs Spring Boot's tools jar mode to extract it into one folder per layer. This stage is thrown away after the build.
  • A JRE base image. eclipse-temurin:21-jre contains only what is needed to run Java, so the image is smaller than one built on a full JDK.
  • A non-root user. groupadd and useradd create a system user named spring, and USER spring makes the application run as that user instead of root.
  • Four COPY steps in layer order. Each one becomes its own image layer, with the rarely changing libraries first. The app.jar left in /app holds only your code, and its manifest points to the libraries in lib/.
  • JAVA_TOOL_OPTIONS passes JVM flags without changing the entry point. Section 8 explains the memory setting.
  • The exec form of ENTRYPOINT (a JSON array) starts java directly as the main process, so it receives the stop signal from Docker. The shell form (ENTRYPOINT java -jar app.jar) wraps it in a shell that may not pass the signal on.
  • .dockerignore sends only the jar to the Docker daemon. Without it, docker build uploads src, .git and everything else in the folder as the build context.
Java 25 option: with Spring Boot 4 on Java 25, you can add an AOT cache training run to the final stage (java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar app.jar) and start with -XX:AOTCache=app.aot to cut startup time. This needs the same JDK for the training run and the real run, so use a Java 25 base image in both stages.

7. A Complete Example: Product Catalog Service with PostgreSQL

The example is a small REST API that creates and reads products, stores them in PostgreSQL and exposes health probes for the container platform. It runs on Spring Boot 4.1 with Java 21.

7.1 The Application Code

There is nothing Docker-specific in the Java code. A containerized service is an ordinary Spring Boot application. The REST API tutorial covers this kind of service in detail.

package com.javabytechie.catalog;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CatalogApplication {

    public static void main(String[] args) {
        SpringApplication.run(CatalogApplication.class, args);
    }
}

7.2 Configuration

spring.application.name=catalog-service

spring.datasource.url=jdbc:postgresql://localhost:5432/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true

The datasource values point to localhost for running the jar on your machine. Inside Docker Compose they are replaced by environment variables (section 8). ddl-auto=update keeps the example short. In production, manage the schema with Flyway database migrations.

7.3 The Compose File

Docker Compose starts several containers together from one YAML file. This one defines PostgreSQL and the catalog service:

services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: catalog
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: catalog
    ports:
      - "5432:5432"
    volumes:
      - catalog-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U catalog -d catalog"]
      interval: 5s
      timeout: 3s
      retries: 10

  catalog-service:
    profiles: ["app"]
    build: .
    image: javabytechie/catalog-service:1.0.0
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
      SPRING_DATASOURCE_USERNAME: catalog
      SPRING_DATASOURCE_PASSWORD: catalog
    depends_on:
      postgres:
        condition: service_healthy
    stop_grace_period: 30s

volumes:
  catalog-data:
  • healthcheck runs pg_isready inside the database container, and depends_on with condition: service_healthy holds the app back until PostgreSQL accepts connections, not just until its container has started.
  • The app reaches the database at postgres:5432, the service name, because Compose puts both containers on a private network with built-in DNS.
  • profiles: ["app"] keeps the app out of a plain docker compose up, which then starts only PostgreSQL. Section 10 uses that for local development.
  • The named volume catalog-data keeps database files when the container is removed.
  • stop_grace_period: 30s gives graceful shutdown time to finish (section 9).

7.4 Build and Run

Terminal
# 1. Build the jar (the Dockerfile copies it from target/)
./mvnw clean package

# 2. Build the image and start PostgreSQL and the catalog service
docker compose --profile app up --build

# 3. Stop and remove the containers (the named volume keeps the data)
docker compose --profile app down

Once both containers are up, call the API from another terminal:

Terminal
curl -i -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}'

HTTP/1.1 201
{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}

curl http://localhost:8080/api/products/KB-100

{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}
Terminal
curl -i http://localhost:8080/api/products/XX-1

HTTP/1.1 404
{"timestamp":"2026-10-24T17:59:01.197Z","status":404,"error":"Not Found","path":"/api/products/XX-1"}

Every unknown SKU returns 404 Not Found through ProductNotFoundException.

8. Configuring the Container: Environment Variables, Memory and Ports

8.1 Environment Variables

The same image must run in every environment, so settings come from outside the image. Spring Boot maps environment variables to properties: replace dots with underscores, remove dashes and use upper case.

PropertyEnvironment variable
spring.datasource.urlSPRING_DATASOURCE_URL
spring.profiles.activeSPRING_PROFILES_ACTIVE
server.portSERVER_PORT
management.endpoint.health.probes.enabledMANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED

Environment variables override application.properties, so the values baked into the jar act as defaults. Use them to pick a Spring profile per environment, and pass passwords from your platform's secret store instead of writing them into the image or a committed Compose file.

8.2 JVM Memory

Modern JVMs (Java 10 and later) detect the container's memory limit. By default, though, they use only about a quarter of it for the heap (MaxRAMPercentage=25). In a 1 GB container that leaves the heap at roughly 256 MB. -XX:MaxRAMPercentage=75 raises it to 75% and leaves the rest for metaspace, thread stacks and other native memory. When the container starts, the JVM confirms the setting:

Console
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75

Set a memory limit on the container itself (docker run -m 1g, or deploy.resources.limits.memory in Compose). Without a limit, the JVM sizes itself from the whole host's memory.

8.3 Ports

EXPOSE 8080 only documents the port. Publishing it is done at run time with -p 8080:8080 (host port first, then container port), or with ports in Compose. To run two copies on one host, change only the host side: -p 8081:8080.

9. Health Checks and Graceful Shutdown

Container platforms need to know two things: is the app still alive, and is it ready to receive traffic? Spring Boot Actuator answers both with two health groups, /actuator/health/liveness and /actuator/health/readiness. Since Spring Boot 4.0 they are enabled by default (management.endpoint.health.probes.enabled defaults to true). In Spring Boot 3.x you had to set that property unless the app ran on Kubernetes, so the example sets it explicitly to work on both.

Terminal
curl http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}

curl http://localhost:8080/actuator/health/liveness
{"status":"UP"}

curl http://localhost:8080/actuator/health/readiness
{"status":"UP"}

Kubernetes can call these as liveness and readiness probes. The Spring Boot Actuator tutorial covers health groups and custom indicators.

Graceful shutdown is on by default for the embedded web server. When the container is stopped, Spring Boot stops accepting new requests, lets in-flight requests finish, then closes the database pool:

Console
INFO o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
INFO o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete
INFO j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
INFO com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
INFO com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
Graceful shutdown of a Spring Boot containerdocker stop sends SIGTERM to the java process. Tomcat stops accepting new requests, in-flight requests are given up to 30 seconds to finish, the JPA EntityManagerFactory and Hikari pool close, and the JVM exits. Separately, Docker and Compose wait only 10 seconds by default before sending SIGKILL, which ends the process with exit code 137, so the stop grace period should be raised to 30 seconds. docker stop sends SIGTERM Tomcat stops new requests In-flight requests finish (up to 30s) JPA and Hikari close cleanly JVM exits exit code 143 Graceful shutdown inside the container Docker / Compose stop timeout default 10s, then SIGKILL (exit 137). Set stop_grace_period: 30s
Figure 2: Spring Boot needs up to 30 seconds to drain requests, but Docker kills the container after 10 seconds unless you raise the grace period.

Spring Boot waits up to 30 seconds per shutdown phase by default (spring.lifecycle.timeout-per-shutdown-phase). But docker stop and Compose send SIGKILL after only 10 seconds by default. That is why the Compose file sets stop_grace_period: 30s. Kubernetes has the same setting as terminationGracePeriodSeconds.

10. Spring Boot Docker Compose Support for Local Development

The spring-boot-docker-compose dependency in the pom.xml above makes local development even simpler. When you run the app from your IDE or with ./mvnw spring-boot:run, Spring Boot:

  1. Finds compose.yaml (or compose.yml, docker-compose.yaml, docker-compose.yml) in the working directory.
  2. Runs docker compose up. Because the app service has a profile, only PostgreSQL starts.
  3. Creates the connection details for recognized images such as postgres, so the app connects without any datasource settings of your own.
  4. Runs docker compose stop when the application shuts down. Services that were already running are left alone.

It needs the docker CLI on your PATH, and it is skipped when tests run unless you set spring.docker.compose.skip.in-tests=false. It is a development-time tool only. The repackaged jar does not include it, which you can confirm by listing the extracted lib folder, so it never runs inside your container.

11. Common Pitfalls and How to Fix Them

11.1 Connecting to localhost from Inside a Container

Inside a container, localhost is the container itself, not your machine or the database container. A URL like jdbc:postgresql://localhost:5432/catalog fails with "Connection refused". How to fix it: use the Compose service name (postgres). To reach a service running on your own machine, use host.docker.internal on Docker Desktop.

11.2 Exit Code 137

Exit code 137 means the process was killed with SIGKILL. That happens either when the container hits its memory limit (the kernel's out-of-memory killer) or when docker stop runs out of patience during shutdown. How to fix it: leave room outside the heap (around 75% for the heap, not 100%), raise the memory limit if the app really needs it, and set the stop grace period to match Spring's shutdown timeout.

11.3 The App Starts Before the Database Is Ready

A plain depends_on waits only for the database container to start, not for PostgreSQL to accept connections. How to fix it: add a healthcheck to the database and use condition: service_healthy, as in section 7.3.

11.4 Slow Builds and Huge Pushes

Copying the fat jar as one layer, or sending the whole project folder as build context, makes every build slow. How to fix it: use the layered Dockerfile and a .dockerignore file.

11.5 Running as root

Most base images run as root unless told otherwise. If the app is compromised, the attacker then has root inside the container. How to fix it: create a system user and switch to it with USER.

11.6 Floating Tags

FROM eclipse-temurin:latest can silently move to a new Java version. How to fix it: pin at least the major version (21-jre). For fully repeatable builds, pin a specific release tag or an image digest, and update it on purpose.

12. Verifying the Running Container

After every image change, run a quick smoke test against the started container:

Terminal
docker compose ps
docker compose logs catalog-service

curl http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}

docker compose ps shows the state of each container, including the health status of the database, and docker compose logs catalog-service shows the startup log, including the Picked up JAVA_TOOL_OPTIONS line from section 8.2. For automated integration tests against a real PostgreSQL, use Testcontainers with Spring Boot's @ServiceConnection support, which starts containers from your test code.

13. When to Containerize and When to Skip It

Good fit for DockerPoor fit, or not needed
Deploying to Kubernetes, ECS, Cloud Run or a similar platformA platform that runs jars directly and manages the JVM for you
Several services or databases that must run together locallyA one-off script or batch job run from a scheduler that already has Java
Teams that need identical environments from laptop to productionFast inner-loop coding, where running from the IDE is quicker than rebuilding an image
Scaling the same service to many instancesDesktop or command-line tools shipped to end users

14. Best Practices

  • Use a layered, multi-stage Dockerfile (or Buildpacks), so code changes rebuild and push only the small application layer.
  • Run on a JRE base image with a pinned version, such as eclipse-temurin:21-jre, never latest.
  • Run as a non-root user created in the Dockerfile.
  • Use the exec form of ENTRYPOINT, so Java receives stop signals and can shut down gracefully.
  • Add a .dockerignore that sends only the jar to the build.
  • Set a container memory limit and MaxRAMPercentage, leaving about a quarter of the memory for non-heap use.
  • Keep configuration out of the image, using environment variables and a secret store for passwords.
  • Expose liveness and readiness probes with Actuator, and wire them into your platform.
  • Match the stop grace period to Spring's shutdown timeout, so in-flight requests are not cut off.
  • Wait for real readiness of dependencies with health checks and condition: service_healthy.
  • Use Spring Boot's Docker Compose support for local development, and keep it out of production images.

15. Summary

To run Docker with Spring Boot, build the jar, split it into layers with java -Djarmode=tools -jar app.jar extract --layers, and copy those layers into a JRE image that runs as a non-root user with an exec-form entry point. Supply configuration through environment variables, set a memory limit and MaxRAMPercentage, expose Actuator's liveness and readiness probes, and give graceful shutdown enough time before the platform kills the container. Use Docker Compose with health checks to run the service and its database together, and Spring Boot's Docker Compose support to start dependencies automatically during development. The main recommendation: use a layered multi-stage Dockerfile when you want full control, or spring-boot:build-image when you would rather not maintain a Dockerfile at all.