Dockerizing Spring Boot Applications

Packaging your Spring Boot application as a Docker container is the standard way to deploy it — to Kubernetes, cloud platforms, or any container runtime. This article covers building production-quality images.

The Naive Dockerfile (Don’t Use This)

FROM eclipse-temurin:21-jdk
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Problems:

  • 600MB+ image (JDK, not JRE)
  • No layer caching — every code change rebuilds the whole JAR layer
  • Runs as root (security risk)
  • No health check

Layered JARs (Better Cache Utilization)

Spring Boot 3 creates layered JARs by default. Dependencies (which rarely change) are in a separate layer from your application code (which changes frequently):

# Extract layers from JAR
java -Djarmode=tools -jar target/order-service.jar extract --layers --destination target/layers
# Dockerfile.layered
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:21-jre
WORKDIR /app

# Create non-root user
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

# Copy layers in order of change frequency (least-changed first)
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./

USER appuser

EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:8080/actuator/health/liveness || exit 1

ENTRYPOINT ["java", \
    "-XX:+UseG1GC", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+ExitOnOutOfMemoryError", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "org.springframework.boot.loader.launch.JarLauncher"]

Why this is better:

  • Dependencies layer (rarely changes) is cached — subsequent builds only push the application layer
  • JRE instead of JDK — 200MB smaller
  • Non-root user — reduces attack surface
  • Health check — container runtime knows when the app is ready

Multi-Stage Build (Compile + Package)

If you don’t want a pre-built JAR:

# Stage 1: Build
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app

# Copy Maven wrapper and pom first for dependency caching
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q

# Copy source and build
COPY src/ src/
RUN ./mvnw package -DskipTests -q

# Stage 2: Extract layers
FROM eclipse-temurin:21-jre AS layers
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# Stage 3: Runtime
FROM eclipse-temurin:21-jre
WORKDIR /app

RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

COPY --from=layers /app/extracted/dependencies/ ./
COPY --from=layers /app/extracted/spring-boot-loader/ ./
COPY --from=layers /app/extracted/snapshot-dependencies/ ./
COPY --from=layers /app/extracted/application/ ./

USER appuser
EXPOSE 8080

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

dependency:go-offline downloads all Maven dependencies in a cached layer. Subsequent builds use the cache unless pom.xml changes — typically saving 2–3 minutes.

Cloud Native Buildpacks (Zero Dockerfile)

Spring Boot includes Buildpacks support — no Dockerfile needed:

./mvnw spring-boot:build-image \
    -Dspring-boot.build-image.imageName=devopsmonk/order-service:latest

Buildpacks analyze your JAR, choose the right JRE version, apply security best practices, and produce an OCI-compliant image. It automatically uses layered JARs, sets JVM flags correctly, and runs as a non-root user.

<!-- pom.xml — customize buildpack image -->
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>devopsmonk/${project.artifactId}:${project.version}</name>
            <builder>paketobuildpacks/builder-jammy-base</builder>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
                <JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75.0</JAVA_TOOL_OPTIONS>
            </env>
        </image>
    </configuration>
</plugin>

Environment Variables and Configuration

# Pass configuration via environment variables
ENV SPRING_PROFILES_ACTIVE=prod
ENV SERVER_PORT=8080
ENV MANAGEMENT_SERVER_PORT=8081

Or pass at runtime:

docker run \
    -e SPRING_PROFILES_ACTIVE=prod \
    -e SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/orders \
    -e SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} \
    -p 8080:8080 \
    devopsmonk/order-service:latest

Never bake secrets into the image. Pass them at runtime via environment variables, Docker secrets, or Kubernetes secrets.

Docker Compose for Local Development

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: orders
      POSTGRES_PASSWORD: orders
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orders"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  kafka:
    image: apache/kafka:3.7.0
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

  order-service:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: dev
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/orders
      SPRING_DATASOURCE_USERNAME: orders
      SPRING_DATASOURCE_PASSWORD: orders
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
      SPRING_DATA_REDIS_HOST: redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
      kafka:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health/readiness"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
docker-compose up -d
docker-compose logs -f order-service
docker-compose down -v   # stops and removes volumes

Image Scanning

Scan your images for vulnerabilities before pushing:

# Docker Scout (built into Docker Desktop)
docker scout cves devopsmonk/order-service:latest

# Or Trivy (CI-friendly)
trivy image devopsmonk/order-service:latest

# Grype
grype devopsmonk/order-service:latest

Integrate into CI — fail the pipeline on critical vulnerabilities:

# .github/workflows/build.yml
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: devopsmonk/order-service:${{ github.sha }}
    format: sarif
    exit-code: 1
    severity: CRITICAL,HIGH

Production Image Checklist

  • JRE, not JDK (smaller, less attack surface)
  • Non-root user
  • Layered JARs or Buildpacks for efficient layer caching
  • HEALTHCHECK instruction
  • JVM flags: MaxRAMPercentage, ExitOnOutOfMemoryError, UseG1GC
  • No secrets in the image — pass via environment
  • .dockerignore excludes target/, .git, IDE files
  • Image scanned for vulnerabilities
# .dockerignore
.git
.gitignore
target/
*.iml
.idea/
.DS_Store

What You’ve Learned

  • Layered JARs separate dependencies from application code — dependency layer is cached across builds
  • Multi-stage builds compile, extract layers, and produce a minimal runtime image in one docker build
  • Cloud Native Buildpacks (./mvnw spring-boot:build-image) produce an optimized image with no Dockerfile
  • Pass secrets via environment variables or Docker/Kubernetes secrets — never bake them into the image
  • Docker Compose wires up the full local environment: PostgreSQL, Redis, Kafka, and the app
  • Scan images with Trivy or Docker Scout before pushing to a registry

Next: Article 53 — Spring Boot on Kubernetes — deploy, scale, and operate your containerized Spring Boot application in Kubernetes.