Skip to content

How to Deploy Spring Boot with Docker: Complete Guide

Purpose

I recently containerized my Spring Boot applications, and Docker has become essential for deploying Java workloads. The consistent environment from development to production eliminated “it works on my machine” issues. In this post, I’ll show you how I deploy Spring Boot with Docker, focusing on multi-stage builds to reduce image size by 50-70% and Docker Compose for local development.

Creating a Dockerfile

I started with a basic Dockerfile for my Spring Boot application:

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

This worked, but I learned several important practices. First, I switched to the official Eclipse Temurin images (based on OpenJDK) because they’re well-maintained and have good security updates. Second, I added a non-root user for security:

FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --chown=spring:spring target/myapp.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

I also created a .dockerignore file to exclude unnecessary files from the build context:

.git
.gitignore
*.md
target/
!target/*.jar
.DS_Store

This reduced my build context size significantly and prevented accidentally copying sensitive files.

Multi-Stage Builds for Smaller Images

The basic Dockerfile produced a ~450MB image. I wanted something smaller and faster to deploy. Spring Boot 2.3+ introduced layered JARs with jarmode=layertools, which I used to create a multi-stage build:

FROM eclipse-temurin:21-alpine AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

The extract command creates four layers:

  • dependencies: Third-party libraries (change rarely)
  • spring-boot-loader: Boot loader classes (never change)
  • snapshot-dependencies: Snapshot dependencies (change occasionally)
  • application: Your application code (change frequently)

This layering strategy means Docker caches the unchanged layers. When I only modify my application code, only the application layer rebuilds. My rebuild time dropped from 2 minutes to 20 seconds for small changes.

The multi-stage build reduced my image from ~450MB to ~210MB. The builder stage includes Gradle and build tools, but the final runtime image only contains the JRE and extracted application layers.

Customizing Layer Configuration

I wanted more control over the layers, so I customized the layer configuration in my build.gradle:

bootJar {
layered {
application {
into "app"
}
dependencies {
include "org.springframework.boot:spring-boot-loader"
into "spring-boot-loader"
}
layer "snapshot-dependencies" {
include ".*:.*-SNAPSHOT"
}
layerOrder = ["spring-boot-loader", "dependencies", "snapshot-dependencies", "app"]
}
}

This puts the Spring Boot loader in its own layer (which never changes) and separates snapshot dependencies from release dependencies. The build cache hits improved even more.

Docker Compose for Development

Running my Spring Boot app locally required PostgreSQL, Redis, and other services. I used Docker Compose to orchestrate everything:

version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydb
- SPRING_DATASOURCE_USERNAME=app
- SPRING_DATASOURCE_PASSWORD=secret
depends_on:
- db
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=mydb
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
volumes:
- db-data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
db-data:

Now I run docker-compose up and everything starts together. The health check ensures the app waits for the database to be ready.

Spring Boot 3.1+ has built-in Docker Compose support. I added this dependency:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<optional>true</optional>
</dependency>

And configured it in application.yml:

spring:
docker:
compose:
file: "./docker-compose.yml"
lifecycle-management: start-and-stop
profiles:
active: "dev"

Spring Boot now automatically discovers the services in my Docker Compose file and configures the connection strings.

Pushing to Container Registries

I tried several container registries. For open source projects, I use Docker Hub:

Terminal window
# Build image
docker build -t username/myapp:1.0.0 .
# Tag for Docker Hub
docker tag username/myapp:1.0.0 username/myapp:latest
# Login and push
docker login
docker push username/myapp:1.0.0
docker push username/myapp:latest

For AWS deployments, I use ECR:

Terminal window
# Authenticate to ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789.dkr.ecr.us-east-1.amazonaws.com
# Build and tag
docker build -t myapp:1.0.0 .
docker tag myapp:1.0.0 \
123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0
# Push
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0

I tag images with semantic versions (1.0.0, 1.1.0) for releases and Git commit SHAs for traceability. The latest tag always points to the latest stable release.

Deploying to Cloud

I’ve deployed Spring Boot containers to several cloud platforms. For AWS ECS/Fargate, I created a task definition:

{
"family": "spring-boot-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"containerDefinitions": [
{
"name": "spring-boot",
"image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0",
"portMappings": [
{
"containerPort": 8080,
"protocol": "tcp"
}
],
"environment": [
{
"name": "SPRING_PROFILES_ACTIVE",
"value": "prod"
},
{
"name": "JAVA_OPTS",
"value": "-Xmx512m -Xms256m"
}
],
"healthCheck": {
"command": [
"CMD-SHELL",
"curl -f http://localhost:8080/actuator/health || exit 1"
],
"interval": 30,
"timeout": 5,
"retries": 3
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/spring-boot",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}

For Google Cloud Run, the deployment is simpler:

Terminal window
# Build image
gcloud builds submit --tag gcr.io/PROJECT_ID/myapp
# Deploy to Cloud Run
gcloud run deploy myapp \
--image gcr.io/PROJECT_ID/myapp \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars SPRING_PROFILES_ACTIVE=prod \
--memory 512Mi \
--port 8080

Cloud Run automatically scales to zero when there’s no traffic, which is great for side projects.

Kubernetes Deployment

For production workloads that need auto-scaling, I deploy to Kubernetes. Here’s my deployment YAML:

apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
labels:
app: spring-boot
spec:
replicas: 3
selector:
matchLabels:
app: spring-boot
template:
metadata:
labels:
app: spring-boot
spec:
containers:
- name: spring-boot
image: username/myapp:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
- name: JAVA_OPTS
value: "-Xmx512m -Xms256m"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 5

I configure a HorizontalPodAutoscaler to scale based on CPU and memory:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: spring-boot-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: spring-boot-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

Kubernetes restarts failed containers automatically and scales up when traffic increases. The liveness and readiness probes ensure traffic only goes to healthy pods.

Best Practices

From my experience deploying Spring Boot with Docker, here are the practices that matter most:

JVM Memory Configuration

Containers have memory limits, and the JVM needs to know about them:

ENV JAVA_OPTS="-Xmx512m -Xms256m -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

I set the heap size to 75% of the container limit to leave room for the JVM overhead and other processes.

Security

I run as a non-root user and scan my images for vulnerabilities:

Terminal window
# Scan with Trivy
trivy image username/myapp:1.0.0

For production, I use distroless images which have no shell or package manager:

FROM gcr.io/distroless/java21-debian11
COPY --from=builder /app/extract/ .
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

This reduces the attack surface significantly.

Monitoring

I enable Spring Boot Actuator for health checks and metrics:

management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
probes:
enabled: true
liveness-state:
enabled: true
readiness-state:
enabled: true
metrics:
export:
prometheus:
enabled: true

The /actuator/health/liveness and /actuator/health/readiness endpoints work with Kubernetes probes. Prometheus metrics feed into my monitoring system.

Configuration

I externalize configuration with environment variables:

spring:
datasource:
url: ${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}

For Kubernetes, I use ConfigMaps for non-sensitive config and Secrets for credentials. This keeps the same image across environments.

Troubleshooting

I’ve encountered several common issues:

Container exits immediately Check the logs: docker logs <container-id>. Usually it’s a missing dependency or wrong entrypoint.

Out of memory errors The JVM heap exceeds the container limit. I reduce the heap size or increase the container memory.

Slow startup times Large classpath and database connection pooling cause this. I use layered JARs and tune the connection pool size. Spring Native with GraalVM is an option for instant startup, but it has limitations.

Health check failures Make sure Actuator endpoints are exposed:

management:
endpoints:
web:
exposure:
include: health,info,metrics

Slow image builds Use .dockerignore and leverage layer caching. Order COPY commands by change frequency—put pom.xml or build.gradle before source code to maximize cache hits.

Summary

Docker deployment gives Spring Boot applications consistency from development to production. The multi-stage build with layered JARs reduced my image size by 50% and rebuild time by 80%. Docker Compose simplified local development with dependencies. Kubernetes provides production-grade orchestration with auto-scaling.

If you’re containerizing Spring Boot applications, start with a multi-stage Dockerfile using Spring Boot’s layering support. Add Docker Compose for local development with databases. Choose a container registry based on your cloud platform. For production, deploy to Kubernetes with health checks and resource limits.

The investment in proper Dockerization pays off in faster deployments, more reliable scaling, and fewer environment-related bugs.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments