← All projects

Ship It to Production

Containerize a Spring Boot service, build and publish it with GitHub Actions, deploy it to Kubernetes with probes, and expose Prometheus metrics.

DevOps & CloudIntermediate6-8 hours#docker#kubernetes#ci-cd#observabilitySpring BootDockerGitHub ActionsKubernetesPrometheus

Revise first: Docker for Java ApplicationsKubernetes BasicsCI/CD PipelinesLogging, Monitoring, Tracing, Prometheus & Grafana

Take any Spring Boot service from the earlier projects and make it deployable: a small non-root image, a CI pipeline that tests and publishes it, a Kubernetes Deployment with correct health probes, and metrics Prometheus can scrape. Interviewers use this to separate people who write code from people who have run it: they ask why a container gets OOM-killed, why a rollout drops requests, and what readiness actually gates.

Project layout

Dockerfile
.github/workflows/ci.yml
k8s/deployment.yaml
k8s/service.yaml
src/main/resources/application.yml      (actuator config)

Dockerfile

Use a multi-stage build so the JDK and Maven cache stay out of the runtime image, and run as a non-root user. Spring Boot's layered jars improve cache reuse because dependencies change less often than your code.

FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline
COPY src src
RUN ./mvnw -q -DskipTests package && \
    java -Djarmode=tools -jar target/*.jar extract --layers --destination /layers

FROM eclipse-temurin:21-jre
RUN useradd -r -u 1001 app
WORKDIR /app
COPY --from=build /layers/dependencies/ ./
COPY --from=build /layers/spring-boot-loader/ ./
COPY --from=build /layers/snapshot-dependencies/ ./
COPY --from=build /layers/application/ ./
USER 1001
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

The jarmode=tools extract command is Spring Boot 3.3+; on older 3.x use -Djarmode=layertools extract and JarLauncher. MaxRAMPercentage matters because the JVM reads the container memory limit and sizes the heap from it; leave headroom for metaspace, threads and direct buffers.

GitHub Actions

Run tests on every push, and publish the image only from main.

name: ci
on:
  push: { branches: [main] }
  pull_request:
jobs:
  build:
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 21, cache: maven }
      - run: ./mvnw -B verify
      - uses: docker/login-action@v3
        if: github.ref == 'refs/heads/main'
        with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
      - uses: docker/build-push-action@v6
        if: github.ref == 'refs/heads/main'
        with: { push: true, tags: "ghcr.io/${{ github.repository }}:${{ github.sha }}" }

Tag with the commit SHA, not only latest: an immutable tag makes a rollout reproducible and a rollback precise.

Actuator and Prometheus

Expose only what you need and move probes to their own groups so liveness never depends on downstream services.

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      probes:
        enabled: true
      show-details: never
  metrics:
    tags:
      application: ${spring.application.name}

Add spring-boot-starter-actuator and micrometer-registry-prometheus. You then get /actuator/health/liveness, /actuator/health/readiness and /actuator/prometheus. Liveness answers "restart me if false" so it must not include the database; readiness answers "send me traffic" and may include it. A liveness probe that checks the database turns a database outage into a restart storm.

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata: { name: orders }
spec:
  replicas: 2
  strategy:
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
  selector: { matchLabels: { app: orders } }
  template:
    metadata:
      labels: { app: orders }
      annotations: { prometheus.io/scrape: "true", prometheus.io/path: /actuator/prometheus, prometheus.io/port: "8080" }
    spec:
      containers:
        - name: orders
          image: ghcr.io/acme/orders:abc123
          ports: [{ containerPort: 8080 }]
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits: { memory: 512Mi }
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            initialDelaySeconds: 30
            periodSeconds: 10
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 2

Set server.shutdown=graceful so in-flight requests finish after SIGTERM, and keep terminationGracePeriodSeconds above spring.lifecycle.timeout-per-shutdown-phase. Memory request equals limit gives a Guaranteed-like memory profile; a CPU limit often causes throttling at startup, so many teams set only a request.

Verify it

docker build -t orders:local . && docker run --rm -p 8080:8080 orders:local &
curl -s localhost:8080/actuator/health/readiness
curl -s localhost:8080/actuator/prometheus | grep http_server_requests_seconds_count
kubectl rollout status deployment/orders
kubectl get pods -l app=orders -w

Interview angle

  • Liveness versus readiness versus startup? Liveness restarts a wedged process, readiness removes the pod from Service endpoints without restarting, and startup delays the other two for slow boots.
  • Why was my pod OOMKilled while heap looked fine? The limit covers heap plus metaspace, thread stacks, direct buffers and native memory. Cap heap with MaxRAMPercentage below 100 and watch jvm_memory_used_bytes per area.
  • How do you achieve zero-downtime deploys? maxUnavailable: 0, a readiness probe that reflects real ability to serve, graceful shutdown, and a preStop delay if the load balancer lags endpoint removal.
  • Why not latest? It is mutable, so you cannot tell what is running or roll back to a known build.

Milestones

0 of 7 milestones

Done when

Stretch goals