Spring Boot Production: Actuator, Docker & Deployment
Prepare a Spring Boot application for production — configure Actuator health checks, expose Prometheus metrics, build a lean Docker image with Buildpacks, set up graceful shutdown, and deploy with GitHub Actions.
Spring Boot Actuator
Actuator exposes operational endpoints — health, metrics, environment info, thread dumps, and more. Add it:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>Configure in application.yml:
management:
endpoints:
web:
base-path: /actuator
exposure:
include: health, info, metrics, prometheus, loggers, env
endpoint:
health:
show-details: when-authorized # don't expose internals publicly
probes:
enabled: true # /actuator/health/liveness and /actuator/health/readiness
info:
enabled: true
info:
env:
enabled: true
build:
enabled: true
info:
app:
name: ${spring.application.name}
version: '@project.version@'
description: "Clinic Portal API"Health Indicators
Spring auto-configures health checks for DB, cache, disk space. Add custom ones:
@Component
public class ConnectHealthIndicator implements HealthIndicator {
private final AmazonConnectClient connectClient;
@Override
public Health health() {
try {
connectClient.describeInstance(instanceId);
return Health.up()
.withDetail("instanceId", instanceId)
.withDetail("status", "ACTIVE")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}Liveness vs Readiness
- Liveness (
/actuator/health/liveness): Is the JVM alive? Kubernetes restarts the pod if this fails. - Readiness (
/actuator/health/readiness): Is the app ready to serve traffic? Kubernetes removes it from load balancer rotation if this fails.
@Component
public class StartupReadinessIndicator implements ReadinessHealthIndicator {
private volatile boolean ready = false;
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
ready = true;
}
@Override
public Health getHealth(boolean includeDetails) {
return ready ? Health.up().build()
: Health.down().withDetail("reason", "Startup in progress").build();
}
}Prometheus Metrics
Add the Micrometer Prometheus registry:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>Custom business metrics:
@Service
public class AppointmentService {
private final Counter appointmentsCreated;
private final Counter appointmentsCancelled;
private final Timer appointmentCreateTimer;
public AppointmentService(MeterRegistry registry, ...) {
this.appointmentsCreated = Counter.builder("appointments.created")
.description("Total appointments created")
.tag("version", "v1")
.register(registry);
this.appointmentsCancelled = Counter.builder("appointments.cancelled")
.register(registry);
this.appointmentCreateTimer = Timer.builder("appointments.create.duration")
.description("Time to create an appointment")
.register(registry);
}
@Transactional
public AppointmentResponse create(AppointmentRequest request, String userId) {
return appointmentCreateTimer.record(() -> {
AppointmentResponse response = doCreate(request, userId);
appointmentsCreated.increment();
return response;
});
}
}Prometheus scrapes /actuator/prometheus. In prometheus.yml:
scrape_configs:
- job_name: 'portal-api'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['portal-api:8080']Graceful Shutdown
server:
shutdown: graceful # wait for in-flight requests to complete
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # max wait timeSpring stops accepting new requests, waits up to 30s for active requests to finish, then shuts down.
Dockerfile with Buildpacks (Recommended)
Spring Boot 2.3+ supports Cloud Native Buildpacks — no Dockerfile needed:
# Build OCI image with Buildpacks
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myregistry/portal-api:latest
# Or with custom name and tag
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=ghcr.io/myorg/portal-api:1.2.3Produces a layered, optimized image with JVM tuning applied automatically.
Manual Dockerfile (when you need control)
# Build stage
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q # cache dependencies
COPY src ./src
RUN ./mvnw package -DskipTests -q
# Extract layers for better caching
FROM eclipse-temurin:21-jdk-alpine AS layers
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Runtime stage — minimal image
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Copy layers in order of how frequently they change (dependencies first)
COPY --from=layers /app/dependencies/ ./
COPY --from=layers /app/spring-boot-loader/ ./
COPY --from=layers /app/snapshot-dependencies/ ./
COPY --from=layers /app/application/ ./
EXPOSE 8080
# JVM tuning for containers
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]Why layered JARs? Dependencies rarely change. Application code changes every build. Docker caches each layer — only the application layer re-downloads on most builds.
GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches: [main]
paths: ['portal-api/**']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/portal-api
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: portal_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
- name: Run tests
working-directory: portal-api
env:
SPRING_PROFILES_ACTIVE: test
DB_HOST: localhost
DB_NAME: portal_test
DB_USER: test
DB_PASSWORD: test
JWT_SECRET: ${{ secrets.TEST_JWT_SECRET }}
run: ./mvnw verify
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
- name: Build and push image
working-directory: portal-api
run: |
./mvnw spring-boot:build-image \
-DskipTests \
-Dspring-boot.build-image.imageName=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: portal-api-task-def.json
service: portal-api
cluster: production
wait-for-service-stability: trueEnvironment Variables & Secrets in Production
# Kubernetes Secret (base64-encoded values)
apiVersion: v1
kind: Secret
metadata:
name: portal-api-secrets
type: Opaque
stringData:
DB_PASSWORD: "super-secret"
JWT_SECRET: "base64-encoded-256-bit-key"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: portal-api
spec:
template:
spec:
containers:
- name: portal-api
image: ghcr.io/myorg/portal-api:abc123
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
- name: DB_HOST
value: rds.us-east-1.amazonaws.com
envFrom:
- secretRef:
name: portal-api-secrets
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 5
resources:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "1Gi", cpu: "1000m" }Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.