Command Palette

Search for a command to run...

[Spring Boot Basics] Packaging and Running Spring Boot: Executable JAR, Profiles and a Simple Dockerfile

Every application in this course so far has been started by Gradle or by an IDE. A deployed application is started from an artifact instead: one file that a server or a container runs without Gradle, without the source code and without your editor. For Spring Boot that artifact is the executable JAR, and this article follows it from ./gradlew bootJar to a container talking to PostgreSQL.

Three things decide whether that goes smoothly: what the build puts into the JAR, how configuration reaches the JAR from outside — the profile, the database URL, the password — and how the container starts and stops the JVM. Advice about the third is where copied Dockerfiles most often go wrong, so the signal handling, the heap sizes and the image sizes below were measured rather than repeated.

A JAR file running on a host and inside a container

The examples use Spring Boot 4.1.1 and Java 21, with Docker, Docker Compose and PostgreSQL 18. The application uses host port 8141 and PostgreSQL host port 5441 instead of the defaults, so those are the ports in the commands. Long paths are shortened to /…/.

The application being packaged

The project was generated by Spring Initializr with seven dependencies:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,postgresql,flyway,actuator" -o demo.zip

On top of it sits a cut-down product catalogue: a Product entity mapped to the products table with an IDENTITY key, a unique sku and a numeric(10,2) price, a JpaRepository, two DTO records and a controller. It is just enough application to have something worth packaging:

Tree
src/main
├── java/com/example/demo
│   ├── DemoApplication.java
│   └── product
│       ├── Product.java
│       ├── ProductController.java
│       ├── ProductRepository.java
│       ├── ProductRequest.java
│       └── ProductResponse.java
└── resources
    ├── application.properties
    ├── application-postgres.properties
    └── db/migration
        └── V1__create_products.sql
src/main/java/com/example/demo/product/ProductController.java
@RestController
@RequestMapping("/api/products")
class ProductController {
 
    private final ProductRepository repository;
 
    ProductController(ProductRepository repository) {
        this.repository = repository;
    }
 
    @GetMapping
    List<ProductResponse> findAll() {
        return repository.findAll(Sort.by("id")).stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/{id}")
    ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
        return ResponseEntity.of(repository.findById(id).map(ProductResponse::from));
    }
 
    @PostMapping
    ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
        Product saved = repository.save(new Product(request.name(), request.sku(), request.price()));
        var location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(saved));
    }
}
src/main/resources/db/migration/V1__create_products.sql
CREATE TABLE products (
    id    BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name  VARCHAR(120)   NOT NULL,
    sku   VARCHAR(40)    NOT NULL,
    price NUMERIC(10, 2) NOT NULL,
    CONSTRAINT uk_products_sku UNIQUE (sku)
);
src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:5441/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validate

With no profile there is no datasource URL, so Boot starts an in-memory H2 database and Flyway migrates it: the development setup. The postgres profile points at PostgreSQL and lets Hibernate validate the schema that Flyway created. It has a username and no password. The password has to reach the application from outside the JAR, and how it gets there is the thread that runs through the rest of this article.

./gradlew build vs ./gradlew bootJar

build is the task most people run. --console=plain makes Gradle print the name of every task it executes:

Bash
./gradlew clean build --console=plain
ls -l build/libs
Text
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
-rw-r--r--@ 1 you      wheel      7749 Sep 16 15:22 demo-0.0.1-SNAPSHOT-plain.jar
-rw-r--r--@ 1 you      wheel  58557202 Sep 16 15:22 demo-0.0.1-SNAPSHOT.jar

build runs assemble, which runs both archive tasks, and then check, which runs the tests. bootJar runs only the tasks the executable JAR needs, and it skips the tests:

Bash
./gradlew clean bootJar --console=plain
Text
> Task :clean
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar

It leaves only demo-0.0.1-SNAPSHOT.jar in build/libs. Use build when you want the tests to gate the artifact, and bootJar when the tests already ran somewhere else.

What the -plain.jar is and how to turn it off

demo-0.0.1-SNAPSHOT-plain.jar is what the ordinary jar task of Gradle's java plugin produces: 7,749 bytes of your own classes and resources, with no dependencies and no Main-Class, so java -jar cannot run it. It only matters when another project consumes this one as a library; for an application it is a file someone will deploy by mistake. Disable the task:

build.gradle
tasks.named('test') {
	useJUnitPlatform()
}
 
tasks.named('jar') { 
	enabled = false
} 

./gradlew build now prints > Task :jar SKIPPED and leaves one file. The Dockerfile later in this article depends on that.

Maven reaches the same state differently, so this is one of the few places where the build tool matters. The command that builds only the executable JAR:

./gradlew bootJar

What each leaves behind, from the same source code:

build/libs/
└── demo-0.0.1-SNAPSHOT.jar                58,557,202 bytes, executable

In Maven 3.9.16 the jar plugin writes the plain jar first, and spring-boot-maven-plugin then repackages it in place and renames the original out of the way:

Text
[INFO] --- jar:3.5.1:jar (default-jar) @ demo ---
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ demo ---
[INFO] Replacing main artifact /…/demo/target/demo-0.0.1-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.

How to change the name of the Spring Boot JAR

Gradle builds the file name from rootProject.name in settings.gradle and version in build.gradle. Changing version = '0.0.1-SNAPSHOT' to version = '1.0.0' produced build/libs/demo-1.0.0.jar, whose manifest said Implementation-Version: 1.0.0. To choose the whole name:

build.gradle
tasks.named('bootJar') {
	archiveFileName = 'app.jar'
}

They produced build/libs/app.jar, and target/app.jar next to target/app.jar.original. The rest of this article keeps the default name, because a version in the file name is often the quickest way to tell what is running on a server; the Dockerfile renames the file inside the image anyway.

What is inside a Spring Boot executable JAR?

A JAR is a zip file, so any zip tool can list it:

Bash
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar

Trimmed to one or two entries of each kind:

Text
Archive:  build/libs/demo-0.0.1-SNAPSHOT.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
      424  02-01-1980 00:00   META-INF/MANIFEST.MF
      855  02-01-1980 00:00   org/springframework/boot/loader/launch/JarLauncher.class
     8388  02-01-1980 00:00   org/springframework/boot/loader/launch/LaunchedClassLoader.class
      733  02-01-1980 00:00   BOOT-INF/classes/com/example/demo/DemoApplication.class
     4658  02-01-1980 00:00   BOOT-INF/classes/com/example/demo/product/ProductController.class
       59  02-01-1980 00:00   BOOT-INF/classes/application.properties
      131  02-01-1980 00:00   BOOT-INF/classes/application-postgres.properties
      239  02-01-1980 00:00   BOOT-INF/classes/db/migration/V1__create_products.sql
 15279748  02-01-1980 00:00   BOOT-INF/lib/hibernate-core-7.4.5.Final.jar
  3623633  02-01-1980 00:00   BOOT-INF/lib/tomcat-embed-core-11.0.24.jar
  1220948  02-01-1980 00:00   BOOT-INF/lib/postgresql-42.7.13.jar
   172312  02-01-1980 00:00   BOOT-INF/lib/HikariCP-7.0.2.jar
     3725  02-01-1980 00:00   BOOT-INF/classpath.idx
      212  02-01-1980 00:00   BOOT-INF/layers.idx
---------                     -------
 58751978                     226 files

Every date is 02-01-1980 00:00 because Boot's Gradle plugin writes reproducible archives: two builds of the same code gave the same 58,557,202 bytes with the same SHA-256 checksum. Grouped, the 226 entries form this tree:

Tree
demo-0.0.1-SNAPSHOT.jar                       58,557,202 bytes, 226 entries
├── META-INF/
│   └── MANIFEST.MF                           Main-Class and Start-Class
├── org/springframework/boot/loader/          99 classes copied in by the build
│   ├── launch/JarLauncher.class              the Main-Class
│   ├── launch/LaunchedClassLoader.class
│   └── jar/NestedJarFile.class               reads a jar stored inside the jar
└── BOOT-INF/
    ├── classes/                              your code and src/main/resources
    │   ├── com/example/demo/…                6 classes
    │   ├── application.properties
    │   ├── application-postgres.properties
    │   └── db/migration/V1__create_products.sql
    ├── lib/                                  84 dependency jars, 58,332,704 bytes
    │   ├── hibernate-core-7.4.5.Final.jar
    │   ├── tomcat-embed-core-11.0.24.jar
    │   └── …
    ├── classpath.idx                         83 jars in classpath order
    └── layers.idx                            layer groups for container images

Nearly all of the file is other people's code: the 84 dependency jars account for 58,332,704 of the 58,751,978 bytes in the listing, and the six classes and three resource files of the application for a few kilobytes.

The manifest ties the layout together:

Bash
unzip -p build/libs/demo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF
Text
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Version: 4.1.1
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Build-Jdk-Spec: 21
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT
AttributeWhat it is for
Main-ClassThe class the JVM starts: Spring Boot's JarLauncher, not your class
Start-ClassYour @SpringBootApplication class, which the launcher calls once the class loader is ready
Spring-Boot-ClassesWhere the launcher finds your compiled classes and resources
Spring-Boot-LibWhere it finds the dependency jars
Spring-Boot-Classpath-Indexclasspath.idx: the jars in classpath order — 83 of the 84, all except spring-boot-jarmode-tools-4.1.1.jar, which is a tool rather than a dependency
Spring-Boot-Layers-Indexlayers.idx: groups entries into dependencies, spring-boot-loader, snapshot-dependencies and application for layered images, an Advanced-course topic
Build-Jdk-Spec, Implementation-*The Java version recorded by the build, the project name and the version

That layout is also why the JAR cannot be treated as an ordinary classpath entry:

Bash
java -cp build/libs/demo-0.0.1-SNAPSHOT.jar com.example.demo.DemoApplication
Text
Error: Could not find or load main class com.example.demo.DemoApplication
Caused by: java.lang.ClassNotFoundException: com.example.demo.DemoApplication

The JDK's class loader looks for com/example/demo/DemoApplication.class at the root of the archive, while the build put it under BOOT-INF/classes, and it never opens a jar stored inside another jar, so none of the 84 dependencies would be visible either. JarLauncher exists to read exactly this layout: it builds a LaunchedClassLoader over BOOT-INF/classes and the nested jars that classpath.idx lists, and only then loads Start-Class and calls its main method.

The executable JAR's four parts with their sizes, and the path from java -jar through Main-Class JarLauncher to Start-Class DemoApplication

Running the JAR with java -jar

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141

The lines of the startup log that answer the questions you have at this point:

Text
2026-09-16T15:15:41.661+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6 with PID 55211 (/…/demo/build/libs/demo-0.0.1-SNAPSHOT.jar started by you in /…/demo)
2026-09-16T15:15:41.662+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : No active profile set, falling back to 1 default profile: "default"
2026-09-16T15:15:43.337+07:00  INFO 55211 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8141 (http) with context path '/'
2026-09-16T15:15:43.340+07:00  INFO 55211 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.843 seconds (process running for 2.049)
  • Starting … names the application version, the Java runtime that is actually running it (21.0.6: whatever java is on the PATH, not the toolchain Gradle compiled with), the PID, the JAR, the user and the working directory. The last two matter later in this article.
  • No active profile set means the H2 setup. Further down, Flyway printed Successfully applied 1 migration to schema "PUBLIC".
  • Tomcat started on port 8141 confirms the port the server really bound to.
  • Started DemoApplication in 1.843 seconds is the best of three runs at a load average of about 4, so treat it as indicative.

A product round trip and the health endpoint:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90}' http://localhost:8141/api/products
Text
HTTP/1.1 201 
Location: http://localhost:8141/api/products/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:04:20 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90}
Bash
curl -s http://localhost:8141/actuator/health
Text
{"groups":["liveness","readiness"],"status":"UP"}

/actuator/health is the one Actuator endpoint exposed over HTTP by default; the groups array lists the liveness and readiness groups that Boot 4.1.1 also enables by default. The Compose file at the end of this article uses this endpoint as the container's health check.

Running the postgres profile against PostgreSQL 18

PostgreSQL runs in a container, published on host port 5441:

Bash
docker run -d --name sb-a41-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 5441:5432 postgres:18

Activating the profile and nothing else shows why the password has to come from somewhere:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8141
Text
2026-09-16T15:19:05.789+07:00 ERROR 58594 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
...
Caused by: org.postgresql.util.PSQLException: The server requested SCRAM-based authentication, but no password was provided.

The process exited with status 1. The profile itself can be activated in three ways, and all three printed the same The following 1 profile is active: "postgres" on this JAR:

Where the profile comes fromCommand
Command-line argumentjava -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres
Environment variableSPRING_PROFILES_ACTIVE=postgres java -jar demo-0.0.1-SNAPSHOT.jar
JVM system propertyjava -Dspring.profiles.active=postgres -jar demo-0.0.1-SNAPSHOT.jar

Article 13 measured how these rank against each other when more than one is set — the argument wins, then the system property, then the environment variable — and showed that -Dspring.profiles.active placed after the JAR name is silently ignored. None of that changes for a real application.

Overriding a property with an environment variable

Environment variables can set any property, not only the profile. This run takes the profile, the password and the port from the environment:

Bash
SPRING_PROFILES_ACTIVE=postgres SPRING_DATASOURCE_PASSWORD=secret SERVER_PORT=8141 java -jar build/libs/demo-0.0.1-SNAPSHOT.jar
Text
2026-09-16T15:19:06.435+07:00  INFO 58623 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T15:19:07.252+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2026-09-16T15:19:07.336+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@34aa8b61
2026-09-16T15:19:07.337+07:00  INFO 58623 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2026-09-16T15:19:07.347+07:00  INFO 58623 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:5441/shop (PostgreSQL 18.6)
2026-09-16T15:19:07.380+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.009s)
2026-09-16T15:19:07.437+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>
2026-09-16T15:19:07.441+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create products"
2026-09-16T15:19:07.456+07:00  INFO 58623 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.006s)
2026-09-16T15:19:08.562+07:00  INFO 58623 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8141 (http) with context path '/'

PgConnection in the Hikari line means PostgreSQL accepted the login, and Flyway created products in the empty database. The mapping from property to variable is mechanical: upper-case the name, turn dots into underscores and drop dashes, so spring.datasource.password becomes SPRING_DATASOURCE_PASSWORD and server.port becomes SERVER_PORT. The URL and the username still came from application-postgres.properties inside the JAR. This is the form every container platform uses, so the Docker sections below reuse it unchanged.

A config file outside the JAR for the password

On a server without a container, a file next to the application is the other usual home for a secret. Boot reads config/application.properties in the working directory and ranks it above every file packaged in the JAR:

Tree
deploy/
├── demo-0.0.1-SNAPSHOT.jar
└── config/
    └── application.properties        spring.datasource.password=secret
Bash
cd deploy && java -jar demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8141
Text
2026-09-16T15:19:19.297+07:00  INFO 58883 --- [demo] [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6 with PID 58883 (/…/deploy/demo-0.0.1-SNAPSHOT.jar started by you in /…/deploy)
2026-09-16T15:19:19.299+07:00  INFO 58883 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T15:19:20.293+07:00  INFO 58883 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:5441/shop (PostgreSQL 18.6)
2026-09-16T15:19:20.347+07:00  INFO 58883 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 1
2026-09-16T15:19:20.348+07:00  INFO 58883 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Schema "public" is up to date. No migration necessary.

The external file supplied the one key the JAR lacks, and Flyway found the schema already at version 1. The words in /…/deploy on the first line are the directory Boot searched: article 13 showed that the same JAR started from a different directory never sees the file. The file belongs on the server, not in the repository.

Stopping the application: graceful shutdown and exit codes

Ctrl+C in the terminal sends SIGINT; kill <pid> sends SIGTERM. Both run the JVM's shutdown hooks, and Spring Boot's hook closes the application in order. kill 58883 on the run above printed:

Text
2026-09-16T15:19:21.877+07:00  INFO 58883 --- [demo] [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
2026-09-16T15:19:21.881+07:00  INFO 58883 --- [demo] [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete
2026-09-16T15:19:21.883+07:00  INFO 58883 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-16T15:19:21.885+07:00  INFO 58883 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2026-09-16T15:19:21.886+07:00  INFO 58883 --- [demo] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

The web server stops taking new requests and waits for the active ones, then the EntityManagerFactory and the connection pool close. Graceful shutdown is not something you have to switch on: the property metadata in spring-boot-web-server-4.1.1.jar gives server.shutdown the default graceful, and spring.lifecycle.timeout-per-shutdown-phase defaults to 30s, the longest Boot waits for requests in flight.

How it stoppedExit status
kill <pid> (SIGTERM)143
Ctrl+C (SIGINT)130
Startup failure, such as the missing password1

143 and 130 are 128 plus the signal number, the JVM's way of reporting that a signal ended it. A process supervisor that treats anything but 0 as a crash needs to know that 143 is a normal stop.

JVM options go before -jar

Everything after the JAR name is passed to main, including options meant for the JVM. With -Xmx256m in both positions, jcmd read the command line and the flags of the running JVM, whose PID the Starting line prints:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256m
Bash
jcmd 70676 VM.command_line | grep -E 'jvm_args|java_command'
jcmd 70676 VM.flags | tr ' ' '\n' | grep '^-XX:MaxHeapSize'
Text
java_command: build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141 -Xmx256m
-XX:MaxHeapSize=4294967296

The same two jcmd commands against the JVM started with the option in front:

Bash
java -Xmx256m -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141
Text
jvm_args: -Xmx256m 
java_command: build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8141
-XX:MaxHeapSize=268435456

After the JAR, -Xmx256m was just a string in args, no JVM argument was recorded, and the heap limit stayed at the default 4 GiB, a quarter of this machine's 16 GB; before -jar it became a 256 MiB limit. Keeping the process alive after you log out, restarting it on failure and starting it at boot is the job of a service manager such as systemd, which the Advanced course covers. The rest of this article hands that job to a container runtime.

A simple Dockerfile for a Spring Boot JAR

The base image only needs a Java runtime. eclipse-temurin is the Eclipse Adoptium build of OpenJDK, and its 21-jre tag carries the runtime without the compiler:

Bash
docker run --rm eclipse-temurin:21-jre sh -c 'cat /etc/os-release | head -4; java -version'
Text
PRETTY_NAME="Ubuntu 26.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="26.04"
VERSION="26.04.1 LTS (Resolute Raccoon)"
openjdk version "21.0.12" 2026-07-21 LTS
OpenJDK Runtime Environment Temurin-21.0.12+8 (build 21.0.12+8-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.12+8 (build 21.0.12+8-LTS, mixed mode, sharing)

21-jre is a moving tag: when this was written it pointed at Temurin 21.0.12 on Ubuntu 26.04. Pin a more specific tag or a digest when you need the same base every time. The first Dockerfile copies a JAR built on the host:

Dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
  • COPY build/libs/*.jar app.jar gives the JAR a fixed name inside the image, whatever the version in its file name.
  • EXPOSE 8080 documents the port. It publishes nothing; -p does.
  • ENTRYPOINT [...] is the exec form, a JSON array. The next section shows what changes without it.

A .dockerignore keeps the rest of the project out of reach of COPY:

.dockerignore
.git
.gradle
.idea
build/*
!build/libs/

The last two lines exclude everything under build except build/libs. BuildKit only sends the paths a COPY names, so for this Dockerfile the build context was 58.57 MB with and without the file. It starts to matter as soon as a COPY takes a whole directory: a COPY . . would otherwise put .git, the local .gradle directory and every build output into the image.

Bash
docker build -t sb-a41-demo:1 .

Trimmed to the steps:

Text
#4 [1/3] FROM docker.io/library/eclipse-temurin:21-jre@sha256:f5e749f83c8a6d0b14b729ad35eebb9a96493b38178d2ddd5429e5a0733b6ee5
#5 [internal] load build context
#5 transferring context: 58.57MB 0.3s done
#7 [3/3] COPY build/libs/*.jar app.jar
#7 DONE 0.1s
#8 naming to docker.io/library/sb-a41-demo:1 done
Bash
docker images sb-a41-demo
docker images eclipse-temurin
Text
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
sb-a41-demo:1   b9d8beb897de        585MB          166MB        
IMAGE                    ID             DISK USAGE   CONTENT SIZE   EXTRA
eclipse-temurin:21-jdk   56a062b5a795        750MB          227MB        
eclipse-temurin:21-jre   f5e749f83c8a        479MB          118MB        

Docker 29 prints two sizes. CONTENT SIZE is the compressed image content, close to what a pull downloads — the layers of the arm64 21-jre image add up to 112,962,074 bytes. DISK USAGE also counts the unpacked layers on this machine; du -sh / inside the JRE image reported 344M. The JAR added 48 MB of compressed content to the base.

Bash
docker run -d --name sb-a41-app -p 8141:8080 sb-a41-demo:1
curl -s -i http://localhost:8141/api/products/1

After creating the keyboard with the same POST as on the host:

Text
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:08:41 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90}

-p 8141:8080 maps host port 8141 to the container's 8080, where Tomcat listens because nothing overrides server.port inside the container. The startup log shows two differences from the host run: Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.12 with PID 1 (/app/app.jar started by root in /app), and timestamps in UTC, because the image has no time zone configured.

COPY build/libs/*.jar with the plain JAR still enabled

Before the jar task was disabled, build/libs held two files and the wildcard matched both. The build did not fail. /app/app.jar was the executable JAR, but only by luck: BuildKit copied each match to the same destination in turn, and a test with two files named demo-a.jar and demo-z.jar confirmed that the last name in sort order wins. demo-0.0.1-SNAPSHOT.jar sorts after demo-0.0.1-SNAPSHOT-plain.jar because . comes after -. Any other naming could put the plain jar in the image without an error at build time. With the plain jar disabled the wildcard matches exactly one file. With Maven the line is COPY target/*.jar app.jar, and demo-0.0.1-SNAPSHOT.jar.original does not match *.jar.

Exec form vs shell form ENTRYPOINT: does docker stop reach the JVM?

The shell form looks like the same instruction without the brackets:

Dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
ENTRYPOINT java -jar /app/app.jar

Docker stores it as ["/bin/sh","-c","java -jar /app/app.jar"], and that changes which process is PID 1. The shell-form image was built as sb-a41-demo:shell and run as sb-a41-shell with the same docker run options as sb-a41-app. The same ps in each, exec form first:

Bash
docker exec sb-a41-app ps -o pid,ppid,user,args
docker exec sb-a41-shell ps -o pid,ppid,user,args
Text
  PID  PPID USER     COMMAND
    1     0 root     java -jar /app/app.jar
   58     0 root     ps -o pid,ppid,user,args
Text
  PID  PPID USER     COMMAND
    1     0 root     /bin/sh -c java -jar /app/app.jar
    7     1 root     java -jar /app/app.jar
   59     0 root     ps -o pid,ppid,user,args

docker stop sends SIGTERM to PID 1, waits 10 seconds, then sends SIGKILL. Each container was started, left until Started DemoApplication appeared, and stopped:

Bash
time docker stop sb-a41-app
Text
sb-a41-app
docker stop sb-a41-app  0.01s user 0.00s system 6% cpu 0.176 total
Bash
time docker stop sb-a41-shell
Text
sb-a41-shell
docker stop sb-a41-shell  0.01s user 0.01s system 0% cpu 10.235 total
Exec formShell form
PID 1java/bin/sh (dash 0.5.12), with java as PID 7
docker stop took0.176 s10.235 s
Shutdown logCommencing graceful shutdown… through HikariPool-1 - Shutdown completed.nothing after Started DemoApplication
Exit code143 (SIGTERM)137 (SIGKILL)

The times are the best of three runs at a load average of about 5. In the shell form the SIGTERM went to dash, which neither handles it nor passes it on, and a PID 1 process without a handler for a signal is not terminated by it. The JVM never learned it was being stopped: no graceful shutdown, no connection pool closed, and every stop costs the full 10 seconds before SIGKILL. Whether the shell stays in between depends on the shell: started with bash -c "java -jar /app/app.jar" in the same image, bash replaced itself and java was PID 1, while dash did not. Do not build on that difference; use the exec form. If you really need a shell, for variable expansion for example, ENTRYPOINT exec java -jar /app/app.jar made java PID 1 again, and docker stop returned in a fraction of a second with exit code 143.

Running the container as a non-root user

The Starting line said started by root. Nothing in this application needs root, and a process that is compromised as root inside a container has more to work with. The Temurin image is Ubuntu, so groupadd and useradd are in /usr/sbin:

Dockerfile
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

--system picks an id below 1000, which matters on this base: Ubuntu images already contain a user ubuntu with uid 1000. USER comes after COPY, so the JAR stays owned by root and the application can read it but not replace it. Built as sb-a41-demo:2 and started the same way:

Bash
docker exec sb-a41-app id
docker exec sb-a41-app ps -o pid,user,args
docker exec sb-a41-app ls -l /app
docker exec sb-a41-app sh -c 'touch /app/x 2>&1; echo rc=$?'
Text
uid=999(spring) gid=999(spring) groups=999(spring)
  PID USER     COMMAND
    1 spring   java -jar /app/app.jar
   64 spring   ps -o pid,user,args
total 57192
-rw-r--r-- 1 root root 58557202 Sep 16 08:14 app.jar
touch: cannot touch '/app/x': Permission denied
rc=1

The log line changed to (/app/app.jar started by spring in /app), and /actuator/health still answered {"groups":["liveness","readiness"],"status":"UP"}. The application writes nothing under /app: Tomcat created its working directories, /tmp/tomcat.8080.… and /tmp/tomcat-docbase.8080.…, owned by spring in /tmp, which any user can write to.

Multi-stage Dockerfile: building the JAR inside Docker

The single-stage image depends on a JAR someone built on their machine with whatever JDK they had. A multi-stage build compiles inside Docker with a pinned JDK and ships only the result:

Dockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon > /dev/null
COPY src src
RUN ./gradlew bootJar -x test --no-daemon
 
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY build/libs/*.jar app.jar
COPY --from=build /workspace/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
  • The build stage starts from the JDK image. Only the stage after it becomes the image; the JDK, Gradle and the sources are left behind.
  • The order of the COPY lines is the point. Docker reuses a layer until something it depends on changes. The wrapper and the build files change rarely, so ./gradlew dependencies — which downloads the Gradle 9.7.1 distribution and resolves the dependency graph — stays cached until they do. src changes on every commit, so it is copied last.
  • -x test skips the tests. They belong to CI or to ./gradlew build on the host, before an image is built, not to every docker build.
  • --no-daemon stops Gradle from leaving a daemon behind; it still forked a single-use one, and said so.

The first build, from an empty cache:

Bash
docker build --progress=plain -t sb-a41-demo:3 .
Text
#11 [build 5/7] RUN ./gradlew dependencies --no-daemon > /dev/null
#11 DONE 32.2s
#12 [build 6/7] COPY src src
#12 DONE 0.0s
#13 [build 7/7] RUN ./gradlew bootJar -x test --no-daemon
#13 DONE 10.8s

Then ProductController changed — findAll now sorts by id, which is one import and one edited line — and the same command ran again. Trimmed:

Text
#8 [build 2/7] WORKDIR /workspace
#8 CACHED
#9 [build 3/7] COPY gradlew settings.gradle build.gradle ./
#9 CACHED
#10 [build 4/7] COPY gradle gradle
#10 CACHED
#11 [build 5/7] RUN ./gradlew dependencies --no-daemon > /dev/null
#11 CACHED
#12 [build 6/7] COPY src src
#12 DONE 0.0s
#13 [build 7/7] RUN ./gradlew bootJar -x test --no-daemon
#13 9.171 > Task :compileJava
#13 10.52 > Task :bootJar
#13 10.52 BUILD SUCCESSFUL in 10s
#13 DONE 10.7s
#14 [stage-1 2/4] RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
#14 CACHED
#15 [stage-1 3/4] WORKDIR /app
#15 CACHED
#16 [stage-1 4/4] COPY --from=build /workspace/build/libs/*.jar app.jar
#16 DONE 0.1s

Everything up to the dependency step came from the cache, including the 32 seconds of downloads. From COPY src src on, the build ran again, and in the runtime stage only the layer holding the new JAR was rebuilt.

What the dependency layer does not cache

bootJar still took 10.7 seconds, and nine of them passed before compileJava printed anything. Building only the first five lines of the Dockerfile as a separate image showed what the cached layer contains:

Bash
head -5 Dockerfile > Dockerfile.deps
docker build -q -f Dockerfile.deps -t sb-a41-deps:1 .
docker run --rm --entrypoint sh sb-a41-deps:1 -c 'du -sh /root/.gradle/wrapper /root/.gradle/caches; find /root/.gradle/caches/modules-2/files-2.1 -name "*.jar" | wc -l; find /root/.gradle/caches/modules-2/files-2.1 -name "*.pom" | wc -l'
Text
165M	/root/.gradle/wrapper
65M	/root/.gradle/caches
23
285

The Gradle distribution and 285 POM files, but only 23 jars — the application needs 84. ./gradlew dependencies resolves the dependency graph from metadata and does not download the artifacts, so every source change downloaded the dependency jars again. A BuildKit cache mount keeps Gradle's cache between builds without putting it in a layer:

Dockerfile
COPY src src
RUN ./gradlew bootJar -x test --no-daemon
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemon

The first build with the mount filled it and took 29.1 seconds for that step; after the next two small edits, each adding a @Size constraint to ProductRequest, the step took 5.7 and 5.9 seconds, against 10.7 without the mount. The numbers were taken at load averages between 3 and 8.5 and are indicative. The mount lives in the builder, not in the image, so docker builder prune or a fresh CI runner starts it empty again. This is the version the Compose file below builds.

The images, from docker images:

ImageDISK USAGECONTENT SIZE
eclipse-temurin:21-jdk750MB227MB
eclipse-temurin:21-jre479MB118MB
sb-a41-demo:3, the multi-stage image586MB166MB
The build stage on its own (--target build)1.4GB502MB

The final image is the JRE base plus the JAR and one user: the same 166 MB of content as the single-stage image. Shipping the build stage instead would mean 502 MB of compressed content, including a JDK, a Gradle distribution and a dependency cache that nothing at runtime uses.

The JDK build stage with cached layers for the wrapper and build files and a rebuilt source layer, producing the JAR that the JRE runtime stage copies and runs as the spring user

How much heap does a Spring Boot container get?

The JVM sizes its default maximum heap from the memory it can see, and inside a container that is the container's limit. -XX:+PrintFlagsFinal shows the result without starting the application:

Bash
docker run --rm --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
docker run --rm --memory=512m --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
docker run --rm --memory=512m -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 --entrypoint java sb-a41-demo:2 -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize | MaxRAMPercentage '
Text
   size_t MaxHeapSize                              = 2082471936                                {product} {ergonomic}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
 
   size_t MaxHeapSize                              = 134217728                                 {product} {ergonomic}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
 
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75
   size_t MaxHeapSize                              = 402653184                                 {product} {ergonomic}
   double MaxRAMPercentage                         = 75.000000                                 {product} {environment}
docker run optionsMax heap
no limit (Docker Desktop VM with 7.75 GiB)2,082,471,936 bytes, about 1.94 GiB
--memory=512m134,217,728 bytes = 128 MiB
--memory=512m -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75402,653,184 bytes = 384 MiB

Java 21 reads the cgroup v2 limit — java -XshowSettings:system -version in the same container printed Memory Limit: 512.00M — and gives the heap 25% of it by default. A quarter is a conservative choice for a container that runs nothing but the JVM. JAVA_TOOL_OPTIONS is read by the JVM itself, so it works with the exec-form ENTRYPOINT without a shell to expand variables, and the Picked up line confirms it arrived. It is not worth going to 100%: both variants of the application used about 288 MiB of the 512 MiB limit right after startup in docker stats, and the JVM needs memory outside the heap for classes, compiled code and threads. With that little memory the JVM also chose a different garbage collector; tuning either belongs to the Advanced course.

Running the Spring Boot container with PostgreSQL

Two containers on a user-defined network

Containers on the same user-defined network reach each other by container name:

Bash
docker network create sb-a41-net
docker run -d --name sb-a41-db --network sb-a41-net -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop postgres:18
docker run -d --name sb-a41-app --network sb-a41-net -p 8141:8080 -e SPRING_PROFILES_ACTIVE=postgres -e SPRING_DATASOURCE_URL=jdbc:postgresql://sb-a41-db:5432/shop -e SPRING_DATASOURCE_PASSWORD=secret sb-a41-demo:3

The database needs no -p because only the application talks to it, over the network. The JDBC URL names the container, sb-a41-db, and the port inside it, 5432:

Text
2026-09-16T08:16:54.847Z  INFO 1 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
2026-09-16T08:16:55.836Z  INFO 1 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@112c2930
2026-09-16T08:16:55.847Z  INFO 1 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://sb-a41-db:5432/shop (PostgreSQL 18.6)
2026-09-16T08:16:55.923Z  INFO 1 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.004s)
2026-09-16T08:16:56.926Z  INFO 1 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'

A POST through port 8141 created a product, and PostgreSQL has the row:

Bash
docker exec sb-a41-db psql -U shop -d shop -c 'select id, name, sku, price from products'
Text
 id |      name      |  sku  | price 
----+----------------+-------+-------
  1 | Wireless mouse | MS-01 | 24.50
(1 row)

Leave out SPRING_DATASOURCE_URL and the application uses the URL from the JAR, localhost:5441. Inside a container localhost is the container itself, so the start fails:

Text
Caused by: org.postgresql.util.PSQLException: Connection to localhost:5441 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
Caused by: java.net.ConnectException: Connection refused

The same stack with Docker Compose

Three docker run commands with a dozen flags are hard to repeat exactly. Compose writes them down:

compose.yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-h", "localhost", "-U", "shop", "-d", "shop"]
      interval: 2s
      timeout: 3s
      retries: 15
 
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: postgres
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_PASSWORD: secret
    ports:
      - "8141:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
 
volumes:
  db-data:
  • build: . builds the multi-stage Dockerfile in the same directory.
  • db-data:/var/lib/postgresql is the mount point for postgres:18, whose image declares VOLUME /var/lib/postgresql and keeps the data in /var/lib/postgresql/18/docker. A volume at the older path /var/lib/postgresql/data made the container exit at once with Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster".
  • pg_isready -h localhost checks over TCP. On first start the image's entrypoint runs a temporary server that listens only on the Unix socket while it creates the database; polling both forms during that start, the socket form reported accepting connections while the TCP form still said no response, and the server then restarted.
  • condition: service_healthy holds back the application until that check passes. Without a condition, depends_on only waits for the container to start.
  • The application's health check calls /actuator/health with curl, which the Temurin image includes, so docker compose ps can report the application as healthy rather than merely running.
  • The service name db is the host name in the JDBC URL, as the container name was on the plain network.
Bash
docker compose -p sb-a41 up --build -d

-p sets the project name, which otherwise defaults to the directory name and prefixes everything Compose creates. After the BuildKit steps of the image build, the output was:

Text
 Image sb-a41-app Built 
 Network sb-a41_default Creating 
 Network sb-a41_default Created 
 Volume sb-a41_db-data Creating 
 Volume sb-a41_db-data Created 
 Container sb-a41-db-1 Creating 
 Container sb-a41-db-1 Created 
 Container sb-a41-app-1 Creating 
 Container sb-a41-app-1 Created 
 Container sb-a41-db-1 Starting 
 Container sb-a41-db-1 Started 
 Container sb-a41-db-1 Waiting 
 Container sb-a41-db-1 Healthy 
 Container sb-a41-app-1 Starting 
 Container sb-a41-app-1 Started 

Waiting and Healthy are condition: service_healthy at work: the application container started only after the database's health check passed.

Bash
docker compose -p sb-a41 ps
Text
NAME           IMAGE         COMMAND                  SERVICE   CREATED          STATUS                    PORTS
sb-a41-app-1   sb-a41-app    "java -jar /app/app.…"   app       11 seconds ago   Up 8 seconds (healthy)    0.0.0.0:8141->8080/tcp, [::]:8141->8080/tcp
sb-a41-db-1    postgres:18   "docker-entrypoint.s…"   db        11 seconds ago   Up 10 seconds (healthy)   5432/tcp
Bash
docker compose -p sb-a41 logs app

The lines that show the profile, the connection and the migration:

Text
app-1  | 2026-09-16T08:17:58.749Z  INFO 1 --- [demo] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "postgres"
app-1  | 2026-09-16T08:17:59.739Z  INFO 1 --- [demo] [           main] org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://db:5432/shop (PostgreSQL 18.6)
app-1  | 2026-09-16T08:17:59.808Z  INFO 1 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.006s)
app-1  | 2026-09-16T08:18:00.803Z  INFO 1 --- [demo] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'

A round trip through the stack:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","sku":"HUB-07","price":39.00}' http://localhost:8141/api/products
Text
HTTP/1.1 201 
Location: http://localhost:8141/api/products/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 16 Sep 2026 08:18:06 GMT
 
{"id":1,"name":"USB-C hub","sku":"HUB-07","price":39.00}

Now remove both containers and the network, and create them again:

Bash
docker compose -p sb-a41 down
docker volume ls --filter name=sb-a41
docker compose -p sb-a41 up -d
Text
 Container sb-a41-app-1 Stopping 
 Container sb-a41-app-1 Stopped 
 Container sb-a41-app-1 Removing 
 Container sb-a41-app-1 Removed 
 Container sb-a41-db-1 Stopping 
 Container sb-a41-db-1 Stopped 
 Container sb-a41-db-1 Removing 
 Container sb-a41-db-1 Removed 
 Network sb-a41_default Removing 
 Network sb-a41_default Removed 
DRIVER    VOLUME NAME
local     sb-a41_db-data

down removed the containers and the network but not the named volume. After up, the application log said Current version of schema "public": 1 and Schema "public" is up to date. No migration necessary., and the product was still there:

Bash
curl -s http://localhost:8141/api/products
Text
[{"id":1,"name":"USB-C hub","sku":"HUB-07","price":39.00}]

docker compose down -v removes the volume too, and the data with it. The password written into compose.yaml is fine for a stack on your own machine and nowhere else: anything in that file ends up in version control, so real deployments inject it from the platform's secret store.

The same JAR run three ways — on the host with arguments and an external file, on the host with environment variables, and in Compose — with the profile, URL, username, password and port each run ended up with and where each value came from

Beyond a simple Dockerfile

Each of these builds on what this article did, and each belongs to the Advanced course:

  • Layered JARs (java -Djarmode=tools -jar app.jar extract --layers --launcher) split the dependencies into their own image layers, so a code change does not rewrite 58 MB.
  • Cloud Native Buildpacks (./gradlew bootBuildImage) build an image with no Dockerfile at all.
  • Spring Boot's Docker Compose support (spring-boot-docker-compose) starts compose.yaml services when the application starts in development.
  • GraalVM native images compile the application ahead of time into a native executable.
  • Kubernetes, systemd services, CI/CD pipelines, JVM and garbage collector tuning, and a reverse proxy with TLS in front of the application.

FAQ

Should I run ./gradlew build or ./gradlew bootJar?

build compiles, runs the tests and writes both the executable and the plain JAR; bootJar only compiles and writes the executable JAR. Use build on CI so failing tests stop the artifact, and bootJar inside a Docker build or anywhere the tests have already run. Disable the jar task in an application so build stops producing the -plain.jar.

Why does docker stop take 10 seconds on my Spring Boot container?

Almost always a shell-form ENTRYPOINT, or a script that starts java without exec. /bin/sh becomes PID 1, receives the SIGTERM and does nothing with it, and Docker sends SIGKILL after its 10-second timeout — measured here as 10.235 s with exit code 137 and no shutdown log. The exec form, ENTRYPOINT ["java", "-jar", "/app/app.jar"], stopped in 0.176 s with a graceful shutdown and exit code 143.

Why can my Spring Boot container not connect to PostgreSQL on localhost?

Because inside a container localhost is that container. Put both containers on one user-defined network, or in one Compose file, and use the other container's name or service name as the host: jdbc:postgresql://db:5432/shop. The port is the one PostgreSQL listens on inside its container, 5432, not a published host port.

Should the base image be the JDK or the JRE?

The JRE for the image you run. The application needs a runtime, not a compiler. eclipse-temurin:21-jre is 118 MB of content against 227 MB for 21-jdk, and a multi-stage build uses the JDK only in the stage that gets thrown away.

How do I pass -Xmx or other JVM options to a Spring Boot container?

Set JAVA_TOOL_OPTIONS, for example -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 or an environment: entry in Compose. The JVM reads the variable itself, so it works with an exec-form ENTRYPOINT, and it prints Picked up JAVA_TOOL_OPTIONS at startup. A percentage follows the container's memory limit; under --memory=512m the default 25% gave a 128 MiB heap and 75% gave 384 MiB.

Can I keep the database password in application-postgres.properties?

Not if the file is in src/main/resources: everything there is copied into the JAR, and anyone with the JAR or the repository can read it. Keep the profile file to non-secret settings and supply the password at runtime, through SPRING_DATASOURCE_PASSWORD, a config/application.properties next to the application on the server, or a secret store in production.

Conclusion

./gradlew bootJar produces one 58.6 MB file whose manifest starts JarLauncher, which builds a class loader over BOOT-INF/classes and 84 nested JARs before calling your main. That same file runs with the H2 default, with the postgres profile against PostgreSQL 18, and inside a container, while everything that differs between those runs — the profile, the URL, the password, the port — arrives from outside through arguments, environment variables or a file in the working directory. Stopped with SIGTERM, it shuts down gracefully by default and exits with 143.

In Docker, three details decide whether the container behaves: an exec-form ENTRYPOINT, so docker stop reaches the JVM in a fraction of a second instead of killing it after 10; a non-root user; and a heap sized for the container, which by default gets only 25% of its memory limit. A multi-stage build keeps the JDK and Gradle out of the 166 MB image, although its dependency layer caches the Gradle distribution rather than the dependency jars unless a cache mount keeps them. Compose ties the application to PostgreSQL with a health check, the service name as the host name and a named volume that survives down.

The next article is the capstone project of the basic course: a complete REST API for order management that brings together JPA, validation, JWT, tests, API documentation and Docker, reusing this Dockerfile and compose.yaml.

Related Posts

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1: SLF4J as the facade and Logback as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.

[Spring Boot Basics] Pagination and Sorting in Spring Boot: Pageable, Sort and Paged API Responses

Pagination and sorting in Spring Boot 4.1.1 with Spring Data JPA, on H2 and PostgreSQL: Sort with ignoreCase, nullsFirst and nullsLast and the SQL each produced on both databases, the deprecated TypedSort, zero-based PageRequest, Page vs Slice vs List and the count query and extra row behind each, when Spring Data skips the count, @Query and native queries with Pageable, the inflated totalElements of a paged JOIN FETCH, a Pageable controller with @PageableDefault, max-page-size and one-indexed parameters, the PageImpl serialization warning, PagedModel versus a PageResponse record, a 400 ProblemDetail for an unknown sort property, and OFFSET versus keyset scrolling with Window.

[Spring Boot Basics] Productivity Tools in Spring Boot: DevTools, Lombok and Actuator Basics

Spring Boot DevTools, Lombok and Actuator on Spring Boot 4.1.1: why developmentOnly keeps DevTools out of bootJar, the base and restart classloaders with a measured 0.185 s restart against a 1.488 s cold start, triggering restarts with ./gradlew -t classes, why a Gradle resource build restarts the app anyway, the property defaults DevTools applies and LiveReload deprecated in 4.1.0; what Lombok generates according to javap, @Value and @Builder against Java records with Jackson 3 and @Jacksonized, the @Data entity traps (StackOverflowError, a HashSet that loses an entity, LazyInitializationException, @Builder without a no-args constructor) and the safe subset; Actuator /actuator, /actuator/health with show-details and a 503 DOWN, exposure of /actuator/info with build, git, java and os info, why include=* is dangerous, and securing Actuator next to a securityMatcher("/api/**") chain.

[Spring Boot Basics] Global Exception Handling in Spring Boot: @RestControllerAdvice, @ExceptionHandler and ProblemDetail

Global exception handling in Spring Boot 4.1.1: the default /error body and BasicErrorController, spring.web.error.* replacing server.error.*, @ResponseStatus and ResponseStatusException, @ExceptionHandler in a controller and in @RestControllerAdvice, how Spring picks one handler by type distance, controller, @Order and cause, ProblemDetail (RFC 9457) and application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler with a 422 field error list, and a catch-all that keeps framework 4xx responses.