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.
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: 2Set 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 -wInterview 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
MaxRAMPercentagebelow 100 and watchjvm_memory_used_bytesper 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
Done when
- The image runs as a non-root user and starts with a memory limit applied.
- Every push runs tests, and only
mainpublishes an image tagged with its commit SHA. - Liveness does not depend on external services, and readiness gates traffic.
- A rolling update completes with no failed requests in the test loop.
- Killing a pod with
kubectl delete podis replaced and serves again without manual action. - Prometheus shows JVM memory and HTTP request metrics labelled with the application name.
Stretch goals
- Add a Horizontal Pod Autoscaler on CPU and observe it scale under load.
- Build with Buildpacks (
spring-boot:build-image) and compare with your Dockerfile. - Add a Grafana dashboard and an alert on p95 latency or error rate.
- Add a CD job that applies the manifests with the new SHA tag and rolls back on a failed rollout.