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-alpineWORKDIR /appCOPY target/myapp.jar app.jarEXPOSE 8080ENTRYPOINT ["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-alpineRUN addgroup -S spring && adduser -S spring -G springUSER spring:springWORKDIR /appCOPY --chown=spring:spring target/myapp.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app/app.jar"]I also created a .dockerignore file to exclude unnecessary files from the build context:
.git.gitignore*.mdtarget/!target/*.jar.DS_StoreThis 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 builderWORKDIR /appCOPY . .RUN ./gradlew bootJar
FROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=builder /app/build/libs/*.jar app.jarRUN java -Djarmode=layertools -jar app.jar extract
EXPOSE 8080ENTRYPOINT ["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:
# Build imagedocker build -t username/myapp:1.0.0 .
# Tag for Docker Hubdocker tag username/myapp:1.0.0 username/myapp:latest
# Login and pushdocker logindocker push username/myapp:1.0.0docker push username/myapp:latestFor AWS deployments, I use ECR:
# Authenticate to ECRaws ecr get-login-password --region us-east-1 | \ docker login --username AWS --password-stdin \ 123456789.dkr.ecr.us-east-1.amazonaws.com
# Build and tagdocker build -t myapp:1.0.0 .docker tag myapp:1.0.0 \ 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0
# Pushdocker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:1.0.0I 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:
# Build imagegcloud builds submit --tag gcr.io/PROJECT_ID/myapp
# Deploy to Cloud Rungcloud 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 8080Cloud 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/v1kind: Deploymentmetadata: name: spring-boot-app labels: app: spring-bootspec: 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: 5I configure a HorizontalPodAutoscaler to scale based on CPU and memory:
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: spring-boot-hpaspec: 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: 80Kubernetes 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:
# Scan with Trivytrivy image username/myapp:1.0.0For production, I use distroless images which have no shell or package manager:
FROM gcr.io/distroless/java21-debian11COPY --from=builder /app/extract/ .EXPOSE 8080ENTRYPOINT ["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: trueThe /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,metricsSlow 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