Backend EngineeringMay 2, 20257 min read

Docker + Spring Boot: Production-Ready Setup

From a naive 900MB image to a lean 180MB multi-stage build — Dockerfile best practices, environment config, health checks, and Docker Compose for local dev.

DockerSpring BootJavaDevOpsCI/CD

Most Spring Boot Docker tutorials produce a 900MB image that takes 3 minutes to start. Here's how I build production images that are under 200MB and start in under 10 seconds.

Multi-Stage Dockerfile

Dockerfile
java
1# Stage 1: Build
2FROM eclipse-temurin:17-jdk-alpine AS builder
3WORKDIR /app
4COPY mvnw pom.xml ./
5COPY .mvn .mvn
6RUN ./mvnw dependency:go-offline -B
7COPY src ./src
8RUN ./mvnw package -DskipTests -B
9
10# Stage 2: Extract layers for better caching
11FROM builder AS extractor
12RUN java -Djarmode=layertools -jar target/*.jar extract
13
14# Stage 3: Runtime (JRE only, not JDK)
15FROM eclipse-temurin:17-jre-alpine
16WORKDIR /app
17COPY --from=extractor /app/dependencies/ ./
18COPY --from=extractor /app/spring-boot-loader/ ./
19COPY --from=extractor /app/snapshot-dependencies/ ./
20COPY --from=extractor /app/application/ ./
21EXPOSE 8080
22ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Layer tools split the JAR into dependencies, Spring Boot loader, and application code. Since dependencies change rarely, Docker reuses their layer on rebuilds — cutting CI build time from 4 minutes to 45 seconds.

Health Check and Graceful Shutdown

application.yml
java
1management:
2  endpoint:
3    health:
4      show-details: always
5  endpoints:
6    web:
7      exposure:
8        include: health,info,metrics
9
10server:
11  shutdown: graceful   # wait for in-flight requests before stopping
12
13spring:
14  lifecycle:
15    timeout-per-shutdown-phase: 30s

Docker Compose for Local Dev

docker-compose.yml
java
1services:
2  app:
3    build: .
4    ports: ["8080:8080"]
5    environment:
6      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
7      SPRING_DATASOURCE_USERNAME: postgres
8      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
9    depends_on:
10      db:
11        condition: service_healthy
12
13  db:
14    image: postgres:15-alpine
15    environment:
16      POSTGRES_DB: myapp
17      POSTGRES_PASSWORD: ${DB_PASSWORD}
18    healthcheck:
19      test: ["CMD", "pg_isready", "-U", "postgres"]
20      interval: 5s
21      retries: 5

.dockerignore — Often Forgotten

.dockerignore
java
1target/
2*.iml
3.idea/
4.git/
5*.log
6.env
7.env.*
8node_modules/
9*.md

Without a .dockerignore, Docker COPY sends the entire project including your target/ folder (hundreds of MB of compiled classes) to the Docker daemon as the build context. This alone can add 30+ seconds to every build.

JVM Memory Tuning for Containers

Dockerfile
java
1# Tell the JVM it's running in a container (Java 11+ does this automatically)
2# But still set sensible limits
3ENTRYPOINT ["java",
4  "-XX:+UseContainerSupport",
5  "-XX:MaxRAMPercentage=75.0",
6  "-XX:+ExitOnOutOfMemoryError",
7  "-Djava.security.egd=file:/dev/./urandom",
8  "org.springframework.boot.loader.JarLauncher"]

Don't set -Xmx manually in containers. Use -XX:MaxRAMPercentage=75.0 instead — it reads the container's memory limit (from Docker/Kubernetes cgroups) and sets the heap to 75% of it. Hardcoded -Xmx values break when the container limit changes.

Secrets Management

  • Never put secrets in Dockerfile or docker-compose.yml — they get committed to git and baked into image layers.
  • Use Docker secrets (Swarm) or Kubernetes secrets mounted as env vars or files.
  • For local dev, use a .env file with docker-compose — add .env to .gitignore immediately.
  • For production on Azure/AWS, use Key Vault / Secrets Manager with managed identity — no credentials in config files at all.

More in Backend Engineering