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
-
HEALTHCHECKinstruction - JVM flags:
MaxRAMPercentage,ExitOnOutOfMemoryError,UseG1GC - No secrets in the image — pass via environment
-
.dockerignoreexcludes 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.