View source on GitHub

Module 53 - Docker & Containers

What this module covers

Packaging a Spring Boot application as a container image two ways: a hand-written multi-stage Dockerfile and Google Jib (daemon-free, via jib-maven-plugin). The module also covers docker-compose.yml for local orchestration and the JVM container-awareness flags that prevent memory over-allocation inside cgroups.


Project structure

module-53-docker/
├── Dockerfile                       # multi-stage: builder + slim runtime
├── docker-compose.yml               # single-service Compose file
├── pom.xml                          # Jib plugin configuration
└── src/main/java/com/javatraining/docker/
    ├── DockerApplication.java
    └── product/
        ├── Product.java             # JPA entity
        ├── ProductRepository.java   # JpaRepository
        └── ProductController.java   # GET /products, GET /products/{id}, POST /products

Multi-stage Dockerfile

# Stage 1: Build - full JDK + Maven, used only during image construction
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q        # cached layer - only re-runs if pom.xml changes
COPY src ./src
RUN mvn package -DskipTests -q

# Stage 2: Runtime - slim JRE only (~180 MB vs ~600 MB JDK image)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring                             # non-root user
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

Why two stages?

The builder stage contains the JDK, Maven, source code, and all build artifacts. None of that belongs in the production image. By copying only the fat JAR into a fresh runtime image, the final layer is minimal - no source, no compiler, no Maven cache.

Dependency caching

COPY pom.xml . && RUN mvn dependency:go-offline is a dedicated layer. Docker’s layer cache keeps it until pom.xml changes. Subsequent builds with only source changes skip the dependency download.

Container-aware JVM flags

Flag Effect
-XX:+UseContainerSupport Reads cgroup memory/CPU limits instead of host totals (default on Java 11+, explicit for clarity)
-XX:MaxRAMPercentage=75.0 Heap = 75% of the cgroup memory limit, leaving headroom for non-heap

Without UseContainerSupport, the JVM reads host RAM (e.g., 16 GB) and sizes the heap accordingly, crashing the container when it exceeds the cgroup limit.


Jib

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.2</version>
    <configuration>
        <from>
            <image>eclipse-temurin:21-jre-alpine</image>
        </from>
        <to>
            <image>java-training/module-53-docker</image>
            <tags><tag>latest</tag><tag>${project.version}</tag></tags>
        </to>
        <container>
            <ports><port>8080</port></ports>
            <jvmFlags>
                <jvmFlag>-XX:+UseContainerSupport</jvmFlag>
                <jvmFlag>-XX:MaxRAMPercentage=75.0</jvmFlag>
            </jvmFlags>
        </container>
    </configuration>
</plugin>
Goal What it does
mvn jib:build Builds and pushes directly to a remote registry - no Docker daemon required
mvn jib:dockerBuild Loads image into the local Docker daemon
mvn jib:buildTar Writes target/jib-image.tar - useful in CI where Docker isn’t available

Jib separates the application into layers (dependencies, resources, classes) so that only the changed layer is pushed on rebuild - much faster than re-pushing a fat JAR layer.

Dockerfile vs Jib

  Dockerfile Jib
Docker daemon needed to build Yes No
Layer optimisation Manual Automatic (deps / resources / classes)
Non-root user Manual adduser Default (nonroot user)
Build speed (unchanged deps) Good if layers cached Excellent (only changed layer)
Transparency Full control Convention over configuration

Docker Compose

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: prod
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "wget -qO- http://localhost:8080/actuator/health || exit 1",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    restart: unless-stopped

healthcheck lets Compose (and Docker Swarm) know when the container is actually ready to accept traffic, not just started. Dependent services can use condition: service_healthy.


REST API

GET  /products          → 200 [ { id, name, price, category }, … ]
GET  /products/{id}     → 200 { id, name, price, category }
                        → 404 if not found
POST /products          → 201 { id, name, price, category }
                           Location: /products/{id}

The POST endpoint returns 201 Created with a Location header pointing to the new resource URI - the standard REST convention for creation responses.


Tests

@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {

    @BeforeEach
    void setUp() {
        productRepository.deleteAll();
        productRepository.saveAll(List.of(
            new Product(null, "Widget", new BigDecimal("9.99"),  "Tools"),
            new Product(null, "Gadget", new BigDecimal("19.99"), "Electronics")
        ));
    }

deleteAll() + re-seed in @BeforeEach gives each test a clean, known state without relying on transaction rollback or test execution order.

Test Assertion
get_all_returns_all_products 200, array size 2, correct names
get_by_id_returns_product 200, correct name and category
get_by_unknown_id_returns_404 404
post_creates_product_and_returns_201_with_location 201, id assigned, Location header set

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 4/4 pass


Key decisions

Decision Reason
Multi-stage build separates JDK from JRE Final image contains only the JRE and fat JAR - no compiler, no Maven cache, ~420 MB smaller
COPY pom.xml before COPY src Separates dependency resolution into its own layer; Docker cache skips it when only source changes
Non-root USER spring in Dockerfile Container escapes root by default; if the process is exploited, the attacker has no root privileges on the host
UseContainerSupport + MaxRAMPercentage JVM reads cgroup limits, not host RAM - prevents OOM kills when the container has a memory limit
Jib alongside Dockerfile Jib is the faster, daemon-free path for CI; Dockerfile gives full control and is universal
ResponseEntity.created(location) for POST Returns 201 + Location header - the standard HTTP contract for resource creation