JOptimize Team
Your Spring Boot application works perfectly on your laptop.
Then you push to production and everything breaks.
Different OS. Different Java version. Different environment variables. Different dependencies.
This is the problem Docker solves.
In this guide, I'll show you how to:
| Before Docker | With Docker |
|---|---|
| "Works on my machine" | Works everywhere |
| Manual environment setup | Reproducible builds |
| 1-2 hours to deploy | 5 minutes to deploy |
| Hard to scale | Auto-scaling trivial |
| Ops nightmare | Infrastructure as code |
Bottom line: If you're not containerizing, you're wasting time.
# Multi-stage build (production best practice) # Stage 1: Build FROM eclipse-temurin:21-jdk as builder WORKDIR /app # Copy only pom.xml first (caching layer) COPY pom.xml . # Download dependencies (cached if pom.xml doesn't change) RUN mvn dependency:go-offline -B # Copy source code COPY src ./src # Build the application RUN mvn clean package -DskipTests # Stage 2: Runtime (small final image) FROM eclipse-temurin:21-jre WORKDIR /app # Copy only the JAR from build stage COPY /app/target/*.jar app.jar # Add non-root user (security) RUN useradd -m -u 1000 appuser USER appuser # Health check HEALTHCHECK \ CMD curl -f http://localhost:8080/actuator/health || exit 1 # Expose port EXPOSE 8080 # Run the application ENTRYPOINT ["java", "-jar", "app.jar"]
Why multi-stage?
# Even better: Use Spring Boot optimized layers FROM eclipse-temurin:21-jdk as builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # Extract JAR layers RUN java -Djarmode=layertools -jar target/*.jar extract # Final stage FROM eclipse-temurin:21-jre WORKDIR /app # Copy layers in order (most frequently changed last) COPY /app/dependencies/ ./ COPY /app/spring-boot-loader/ ./ COPY /app/snapshot-dependencies/ ./ COPY /app/application/ ./ RUN useradd -m -u 1000 appuser USER appuser EXPOSE 8080 HEALTHCHECK \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
This enables layer caching:
# Build docker build -t myapp:1.0.0 . # Check size docker images myapp:1.0.0 # myapp 1.0.0 abcd1234 165MB # Run locally docker run -p 8080:8080 myapp:1.0.0
version: '3.8' services: app: build: . ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mydb SPRING_DATASOURCE_USERNAME: postgres SPRING_DATASOURCE_PASSWORD: password depends_on: postgres: condition: service_healthy networks: - app-network postgres: image: postgres:15-alpine ports: - "5432:5432" environment: POSTGRES_DB: mydb POSTGRES_PASSWORD: password healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 networks: - app-network redis: image: redis:7-alpine ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 3 networks: - app-network networks: app-network: driver: bridge
Run everything with one command:
docker-compose up # App is at http://localhost:8080 # Postgres at localhost:5432 # Redis at localhost:6379
Deployment (deployment.yaml):
apiVersion: apps/v1 kind: Deployment metadata: name: myapp namespace: production spec: replicas: 3 # Run 3 instances strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 # Zero-downtime deployments selector: matchLabels: app: myapp template: metadata: labels: app: myapp annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/actuator/prometheus" spec: serviceAccountName: myapp containers: - name: myapp image: myregistry.azurecr.io/myapp:1.0.0 imagePullPolicy: Always ports: - containerPort: 8080 name: http env: - name: SPRING_PROFILES_ACTIVE value: "production" - name: SPRING_DATASOURCE_URL valueFrom: secretKeyRef: name: db-secret key: url - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password # Resource limits (critical!) resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1000m memory: 1Gi # Health checks livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 60 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 20 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 # Graceful shutdown lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 15"] # Pod disruption budget (for safe drains) affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: - myapp topologyKey: kubernetes.io/hostname
Service (service.yaml):
apiVersion: v1 kind: Service metadata: name: myapp namespace: production spec: type: LoadBalancer # Or ClusterIP for internal only selector: app: myapp ports: - port: 80 targetPort: 8080 protocol: TCP name: http sessionAffinity: None
ConfigMap (config.yaml):
apiVersion: v1 kind: ConfigMap metadata: name: myapp-config namespace: production data: application.yml: | spring: jpa: hibernate: ddl-auto: validate show-sql: false cache: type: redis redis: time-to-live: 3600000 logging: level: root: WARN com.myapp: INFO
Secret (secret.yaml):
apiVersion: v1 kind: Secret metadata: name: db-secret namespace: production type: Opaque data: url: amRiYzpwb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6NTQzMi9teWRi # base64 encoded password: c3VwZXJzZWNyZXRwYXNzd29yZA== # base64 encoded
HPA (Auto-scaling - hpa.yaml):
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: myapp-hpa namespace: production spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: myapp minReplicas: 3 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 behavior: scaleDown: stabilizationWindowSeconds: 300 policies: - type: Percent value: 50 periodSeconds: 60 scaleUp: stabilizationWindowSeconds: 60 policies: - type: Percent value: 100 periodSeconds: 30
# Create namespace kubectl create namespace production # Apply manifests kubectl apply -f deployment.yaml kubectl apply -f service.yaml kubectl apply -f config.yaml kubectl apply -f secret.yaml kubectl apply -f hpa.yaml # Check deployment kubectl get deployment -n production kubectl get pods -n production kubectl get svc -n production # Watch rollout kubectl rollout status deployment/myapp -n production # Get logs kubectl logs -f deployment/myapp -n production
# Update image kubectl set image deployment/myapp myapp=myregistry.azurecr.io/myapp:2.0.0 -n production # Watch the rollout (old → new pods gradually) kubectl rollout status deployment/myapp -n production # If something goes wrong, rollback kubectl rollout undo deployment/myapp -n production
resources: requests: cpu: 500m # Container gets at least 0.5 CPU memory: 512Mi # Container gets at least 512MB limits: cpu: 1000m # Container can use max 1 CPU memory: 1Gi # Container can use max 1GB
Without limits:
// Spring Boot Actuator provides health checks @Configuration public class HealthConfig { @Bean public HealthIndicator customHealth() { return new HealthIndicator() { @Override public Health health() { // Check database if (databaseConnected()) { return Health.up().build(); } return Health.down().build(); } }; } }
Kubernetes uses:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 15"] # Give in-flight requests 15s
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s
annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/actuator/prometheus"
Access metrics:
kubectl port-forward service/myapp 8080:80 -n production curl http://localhost:8080/actuator/prometheus
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: myapp-network-policy namespace: production spec: podSelector: matchLabels: app: myapp policyTypes: - Ingress - Egress ingress: - from: - namespaceSelector: matchLabels: name: ingress-nginx ports: - protocol: TCP port: 8080 egress: - to: - namespaceSelector: {} ports: - protocol: TCP port: 5432 # Database - protocol: TCP port: 443 # HTTPS
Before: 500MB After: 150MB
Solution:
Before: 30 seconds After: 10 seconds
Solution:
-XX:+FlightRecorder# Container using 1GB but limited to 512MB? kubectl describe pod myapp-xyz -n production # Solution: Increase JVM heap docker run -e JAVA_OPTS="-Xmx1g" myapp:1.0.0
# Check events kubectl describe pod myapp-xyz -n production # Solutions: # 1. Increase memory limits # 2. Fix memory leak # 3. Add more replicas and distribute load
Before containerizing, audit your code:
npm install -g @joptimize/cli joptimize auth YOUR_API_KEY joptimize analyze .
JOptimize detects:
npm install -g @joptimize/cli joptimize auth YOUR_API_KEY joptimize analyze .
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.