Skip to main content
Java intermediate Lesson 58 of 58

Dockerizing Spring Boot Applications

Package and run Spring Boot applications in Docker — writing Dockerfiles, multi-stage builds, Docker Compose for local development, and best practices.

Containerising a Spring Boot app makes it portable across environments — the same image runs identically on a developer’s laptop, in CI, and in production. Docker eliminates “works on my machine” problems by bundling the application and everything it needs to run into a single, self-contained unit.

Prerequisites

  • Docker Desktop installed and running
  • A built Spring Boot project

Simple Dockerfile

The simplest Dockerfile copies the built JAR into a JRE image and runs it. This is fine for getting started, but has a drawback: every rebuild copies the entire JAR, even if you only changed one line of application code. The dependencies — the large part of the JAR — get re-sent to the Docker daemon every time.

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

Build and run:

./mvnw clean package -DskipTests
docker build -t my-app:latest .
docker run -p 8080:8080 my-app:latest

A multi-stage build separates the build environment from the runtime image. The first stage uses a full JDK image to compile and package. The second stage uses a minimal JRE image and copies only the compiled output. The final image has no Maven, no JDK, no source code — just what’s needed to run.

Spring Boot’s layered JAR mode goes further by splitting the JAR into layers ordered by how often they change. Docker caches each layer independently, so if you only change application code, only the last (smallest) layer is rebuilt and pushed.

# Stage 1: Build — uses full JDK + Maven to compile the project
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build

# Copy dependency descriptor first — Docker caches this layer until pom.xml changes
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN ./mvnw dependency:go-offline -q  # pre-download all dependencies into the cache layer

# Copy source and build — this layer only rebuilds when source changes
COPY src ./src
RUN ./mvnw clean package -DskipTests -q

# Extract layered JAR — splits into: dependencies / spring-boot-loader / snapshot-deps / application
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted

# Stage 2: Runtime — minimal JRE image, no build tools
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Run as a non-root user — important for container security
RUN groupadd -r appuser && useradd -r -g appuser appuser

# Copy layers in order of change frequency: rarely-changing layers first
# Docker can reuse cached layers for dependencies even when application code changes
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./  # changes most often — last layer

USER appuser
EXPOSE 8080

# Exec form ensures the JVM receives OS signals (SIGTERM) for graceful shutdown
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

The layered approach means: if you only changed application code, Docker rebuilds only the last layer. The large dependencies layer — which rarely changes — stays cached, making builds and pushes much faster.

.dockerignore

Exclude files that don’t belong in the build context. Without this, Docker sends your entire project directory (including target/, .git/, IDE files) to the daemon on every build, which is slow and wastes bandwidth.

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar

JVM Tuning for Containers

Without JVM flags, the JVM reads the host machine’s memory limits and may allocate far more heap than the container is allowed to use. -XX:+UseContainerSupport tells the JVM to respect cgroup memory limits. -XX:MaxRAMPercentage=75.0 caps heap at 75% of the container’s memory, leaving room for off-heap memory and the OS.

ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:+ExitOnOutOfMemoryError", \
  "org.springframework.boot.loader.launch.JarLauncher"]

Or pass JVM flags at runtime without rebuilding the image:

docker run -p 8080:8080 \
  -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" \
  my-app:latest

Environment Variables and Secrets

Never hardcode credentials in a Dockerfile or image. Pass them at runtime so the same image works across all environments and secrets don’t end up in the image layer history.

docker run -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/mydb \
  -e DB_PASSWORD=secret \
  -e JWT_SECRET=my-secret-key \
  my-app:latest

Spring Boot’s relaxed binding maps SPRING_DATASOURCE_URLspring.datasource.url automatically, so environment variable names follow the same pattern as property names.

Docker Compose — Local Development

Docker Compose lets you define and run a multi-container setup with a single command. It’s the standard way to run your app alongside its database and any other services during development, without installing them locally. The depends_on with health check ensures the database is actually ready before the app starts.

# docker-compose.yml
version: '3.9'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/demodb
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_PROFILES_ACTIVE: docker
    depends_on:
      db:
        condition: service_healthy  # wait for the health check to pass before starting the app

  db:
    image: mysql:8.3
    environment:
      MYSQL_DATABASE: demodb
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql  # persist data across container restarts
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  mysql-data:  # named volume — survives docker compose down

.env file (not committed to git — add to .gitignore):

DB_PASSWORD=devpassword123
docker compose up --build        # build images and start all services
docker compose up -d             # start in background (detached mode)
docker compose logs -f app       # tail the app's log output
docker compose down              # stop and remove containers (data is preserved in volumes)
docker compose down -v           # also remove volumes — wipes the database

application-docker.properties

Create a Docker-specific Spring profile for settings that differ when running in a container. The hostname db resolves to the database container via Docker’s internal DNS.

# src/main/resources/application-docker.properties
spring.datasource.url=jdbc:mysql://db:3306/demodb   # 'db' is the service name in docker-compose.yml
spring.jpa.hibernate.ddl-auto=update
logging.level.com.example=INFO

Buildpacks — Zero-Config Image Build

Spring Boot can build an OCI image without a Dockerfile using Cloud Native Buildpacks. It automatically detects the runtime and applies security hardening. The tradeoff is speed — buildpacks are slower and less transparent than a hand-written Dockerfile.

./mvnw spring-boot:build-image -DskipTests

# Run the generated image
docker run -p 8080:8080 my-app:0.0.1-SNAPSHOT

Configure the image name in pom.xml:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>myregistry/my-app:${project.version}</name>
        </image>
    </configuration>
</plugin>

Publishing to a Registry

Once your image is built, push it to a registry so it can be pulled in CI and production:

# Tag for Docker Hub
docker tag my-app:latest yourusername/my-app:1.0.0

# Login and push to Docker Hub
docker login
docker push yourusername/my-app:1.0.0

# Or for GitHub Container Registry
docker tag my-app:latest ghcr.io/yourusername/my-app:1.0.0
echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin
docker push ghcr.io/yourusername/my-app:1.0.0

Useful Docker Commands

docker ps                          # list running containers
docker images                      # list all local images
docker stop <container-id>         # gracefully stop a container
docker system prune                # remove stopped containers and dangling images
docker exec -it <container-id> /bin/sh  # open a shell inside a running container (for debugging)
docker logs -f <container-id>      # stream container logs
docker stats                       # real-time CPU and memory usage per container

Frequently Asked Questions

What is the difference between a Docker image and a container?
An image is a read-only template — the packaged application and its runtime. A container is a running instance of an image. You can run many containers from the same image. Think of an image as a class and a container as an object.
What is a multi-stage Docker build?
A multi-stage build uses multiple FROM instructions in one Dockerfile. The first stage (builder) compiles your code — it includes the full JDK and Maven/Gradle. The second stage (runtime) copies only the compiled artifact into a minimal JRE image. The final image contains no build tools, making it smaller and more secure.
Should I use JAR layers or buildpacks?
Spring Boot's layered JARs (with COPY --from=builder) give you full control and fast rebuilds because unchanged layers (dependencies, Spring libs) are cached. Buildpacks (./mvnw spring-boot:build-image) are zero-config and produce OCI-compliant images but are slower and less transparent. Use layered JARs for most projects.