Back to blog
Cloud & DevOpsintermediate

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.

LearnixoApril 16, 20265 min read
Spring BootDockerProductionActuatorPrometheusDevOpsJavaCI/CD
Share:𝕏

Spring Boot Actuator

Actuator exposes operational endpoints — health, metrics, environment info, thread dumps, and more. Add it:

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Configure in application.yml:

YAML
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:

JAVA
@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.
JAVA
@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:

XML
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Custom business metrics:

JAVA
@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:

YAML
scrape_configs:
  - job_name: 'portal-api'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['portal-api:8080']

Graceful Shutdown

YAML
server:
  shutdown: graceful    # wait for in-flight requests to complete

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # max wait time

Spring 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:

Bash
# 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.3

Produces a layered, optimized image with JVM tuning applied automatically.

Manual Dockerfile (when you need control)

DOCKERFILE
# 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

YAML
# .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: true

Environment Variables & Secrets in Production

YAML
# 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.