Docker with Spring Boot
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:
| Term | Meaning |
|---|---|
| Image | A read-only package with everything needed to run the app: base OS files, the Java runtime and your jar |
| Container | A running instance of an image. You can start many containers from one image |
| Dockerfile | A text file with the build steps that produce an image |
| Layer | The result of one build step. Images are stacks of layers, and unchanged layers are reused from cache |
| Registry | A 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:
dependencies: released third-party librariesspring-boot-loader: Spring Boot's own launcher classes, when the extracted layout needs themsnapshot-dependencies:-SNAPSHOTlibraries, which change more oftenapplication: your classes and resources
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:
<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>
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:
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:
./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 Dockerfile | Layered Dockerfile | Buildpacks | |
|---|---|---|---|
| Dockerfile to maintain | Yes, tiny | Yes | No |
| Layer caching | Poor | Good | Good |
| Control over image contents | Full | Full | Limited to buildpack options |
| JVM memory tuning | Manual | Manual | Automatic |
| Best for | Quick tests | Most production services | Teams 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"]
# Send only the built jar to the Docker daemon
*
!target/catalog-service-*.jar
- Stage 1 (
layers) copies the jar asapp.jarand runs Spring Boot'stoolsjar mode to extract it into one folder per layer. This stage is thrown away after the build. - A JRE base image.
eclipse-temurin:21-jrecontains only what is needed to run Java, so the image is smaller than one built on a full JDK. - A non-root user.
groupaddanduseraddcreate a system user namedspring, andUSER springmakes the application run as that user instead ofroot. - Four
COPYsteps in layer order. Each one becomes its own image layer, with the rarely changing libraries first. Theapp.jarleft in/appholds only your code, and its manifest points to the libraries inlib/. JAVA_TOOL_OPTIONSpasses JVM flags without changing the entry point. Section 8 explains the memory setting.- The exec form of
ENTRYPOINT(a JSON array) startsjavadirectly 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. .dockerignoresends only the jar to the Docker daemon. Without it,docker builduploadssrc,.gitand everything else in the folder as the build context.
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);
}
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
protected Product() {
// required by JPA
}
public Product(String sku, String name, BigDecimal price) {
this.sku = sku;
this.name = name;
this.price = price;
}
public Long getId() { return id; }
public String getSku() { return sku; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
}
package com.javabytechie.catalog.product;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
public record ProductRequest(String sku, String name, BigDecimal price) {
}
package com.javabytechie.catalog.product;
import java.math.BigDecimal;
public record ProductResponse(String sku, String name, BigDecimal price) {
static ProductResponse from(Product product) {
return new ProductResponse(product.getSku(), product.getName(), product.getPrice());
}
}
package com.javabytechie.catalog.product;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(String sku) {
super("Product not found: " + sku);
}
}
package com.javabytechie.catalog.product;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Transactional
public ProductResponse create(ProductRequest request) {
Product saved = repository.save(new Product(request.sku(), request.name(), request.price()));
log.info("Created product {}", saved.getSku());
return ProductResponse.from(saved);
}
@Transactional(readOnly = true)
public ProductResponse findBySku(String sku) {
return repository.findBySku(sku)
.map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(sku));
}
@Transactional(readOnly = true)
public List<ProductResponse> findAll() {
return repository.findAll().stream().map(ProductResponse::from).toList();
}
}
package com.javabytechie.catalog.product;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse create(@RequestBody ProductRequest request) {
return productService.create(request);
}
@GetMapping
public List<ProductResponse> findAll() {
return productService.findAll();
}
@GetMapping("/{sku}")
public ProductResponse findBySku(@PathVariable String sku) {
return productService.findBySku(sku);
}
}
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:
healthcheckrunspg_isreadyinside the database container, anddepends_onwithcondition: service_healthyholds 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 plaindocker compose up, which then starts only PostgreSQL. Section 10 uses that for local development.- The named volume
catalog-datakeeps database files when the container is removed. stop_grace_period: 30sgives graceful shutdown time to finish (section 9).
7.4 Build and Run
# 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:
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}
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.
| Property | Environment variable |
|---|---|
spring.datasource.url | SPRING_DATASOURCE_URL |
spring.profiles.active | SPRING_PROFILES_ACTIVE |
server.port | SERVER_PORT |
management.endpoint.health.probes.enabled | MANAGEMENT_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:
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.
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:
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.
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:
- Finds
compose.yaml(orcompose.yml,docker-compose.yaml,docker-compose.yml) in the working directory. - Runs
docker compose up. Because the app service has a profile, only PostgreSQL starts. - Creates the connection details for recognized images such as
postgres, so the app connects without any datasource settings of your own. - Runs
docker compose stopwhen 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:
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 Docker | Poor fit, or not needed |
|---|---|
| Deploying to Kubernetes, ECS, Cloud Run or a similar platform | A platform that runs jars directly and manages the JVM for you |
| Several services or databases that must run together locally | A one-off script or batch job run from a scheduler that already has Java |
| Teams that need identical environments from laptop to production | Fast inner-loop coding, where running from the IDE is quicker than rebuilding an image |
| Scaling the same service to many instances | Desktop 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, neverlatest. - 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
.dockerignorethat 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.
Frequently Asked Questions (FAQ)
Q1. Should I use a Dockerfile or spring-boot:build-image?
Use ./mvnw spring-boot:build-image if you want a good image without maintaining a Dockerfile; it layers the jar and tunes JVM memory for you. Use a layered multi-stage Dockerfile when you need full control over the base image, users, installed tools or JVM flags. Both produce layered images that cache well.
Q2. Why can't my Spring Boot container connect to a database on localhost?
Inside a container, localhost means the container itself. Use the Docker Compose service name, such as jdbc:postgresql://postgres:5432/catalog, when both run in Compose. To reach a database running directly on your machine from Docker Desktop, use host.docker.internal.
Q3. Why does my Spring Boot container exit with code 137?
Exit code 137 means the process was killed with SIGKILL. That is usually the out-of-memory killer, because the heap plus non-heap memory exceeded the container limit, or Docker giving up on a slow shutdown after its stop timeout. Lower MaxRAMPercentage or raise the memory limit, and set the stop grace period to match spring.lifecycle.timeout-per-shutdown-phase.
Q4. How do I pass Spring profiles and properties to a Docker container?
Use environment variables, for example docker run -e SPRING_PROFILES_ACTIVE=prod -e SERVER_PORT=9090 ..., or the environment section in Compose. Spring Boot maps them to properties by turning dots into underscores, removing dashes and using upper case. They override the values in application.properties inside the image.
Q5. Should I use a JDK or a JRE base image for Spring Boot?
Use a JRE image such as eclipse-temurin:21-jre for the final image, because running the app needs only the runtime. A JDK is needed only to compile, which happens before the Docker build in this tutorial or in a separate build stage. Smaller images download faster and contain fewer packages that need security updates.
Q6. What replaced layertools in Spring Boot 4?
The layertools jar mode was removed in Spring Boot 4, and java -Djarmode=layertools now fails with Unsupported jarmode 'layertools'. Use java -Djarmode=tools -jar app.jar extract --layers --destination extracted, available since Spring Boot 3.3. It produces the same four layer folders plus a slim runnable jar.
Q7. Does Spring Boot's Docker Compose support run in production?
No. spring-boot-docker-compose is a development-time feature that starts the services in your compose.yaml when you run the app locally. The repackaged jar excludes it by default, so it is not active inside your production container.
Q8. How do I debug a Spring Boot application running in Docker?
Start the JVM with the debug agent, for example by setting JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005, and publish port 5005 with -p 5005:5005. Then attach your IDE's remote debugger to localhost:5005. Never enable the debug agent in production images.