Deployment & DevOps
Dockerizing Spring Boot Apps
# Multi-stage build — keeps the final image lean by discarding build tooling
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
RUN ./mvnw dependency:go-offline # cached layer — only re-runs if pom.xml changes
COPY src src
RUN ./mvnw package -DskipTests
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring # never run as root in production containers
COPY --from=build /app/target/*.jar app.jar
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Layer ordering matters for build cache efficiency: pom.xml/dependencies copied and resolved before application source — Docker caches each layer, so changing only Java source doesn’t force a full dependency re-download on every build, which matters meaningfully for CI build time at scale.
Spring Boot’s built-in layered jar support — a better alternative to the manual multi-stage pattern above for most teams:
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=runtime /app/dependencies/ ./
COPY --from=runtime /app/spring-boot-loader/ ./
COPY --from=runtime /app/snapshot-dependencies/ ./
COPY --from=runtime /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
This splits the fat jar into separate layers (dependencies, loader, snapshot-deps, application code) — dependencies rarely change between builds, so this layer gets reused from cache almost every build; only the thin application/ layer (your actual code) typically changes, meaning Docker pushes/pulls only that small delta layer to the registry instead of the entire fat jar on every deploy.
Buildpacks — the alternative many teams now prefer over hand-written Dockerfiles entirely:
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:1.0
Cloud Native Buildpacks (Paketo, used by Spring Boot’s Maven/Gradle plugin) automatically produce an OCI-compliant, layered, security-patched-base-image build with zero Dockerfile authored — a strong default for teams that don’t need bespoke image customization, trading some control for substantially less Dockerfile maintenance burden across many microservices.
Container resource limits, JVM-aware sizing — a real production gotcha:
resources:
limits:
memory: "512Mi"
cpu: "500m"
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
Modern JVMs (10+) are container-memory-aware by default (read cgroup limits rather than host memory), but explicitly setting -XX:MaxRAMPercentage rather than relying purely on defaults gives architects direct control over the JVM-heap-vs-container-limit ratio — a JVM that doesn’t leave headroom for non-heap memory (metaspace, thread stacks, direct buffers) within a tight container memory limit is a classic cause of OOMKilled pods that otherwise look like “the app just randomly dies,” with no application-level exception ever logged because the container runtime killed the process externally.
CI/CD Basics
Pipeline stages — the standard shape for a Spring Boot service, regardless of specific tool (Jenkins, GitHub Actions, GitLab CI, Azure DevOps):
# GitHub Actions example — illustrative of the universal stage shape
name: CI
on: [push, pull_request]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- run: ./mvnw verify # compile + unit tests + integration tests
- run: ./mvnw sonar:sonar # static analysis / code quality gate
- uses: docker/build-push-action@v5
with:
push: true
tags: registry.acme.com/order-service:${{ github.sha }}
- run: trivy image registry.acme.com/order-service:${{ github.sha }} # container vulnerability scan
CI vs CD — a distinction worth stating precisely rather than treating as one blob:
| Scope | |
|---|---|
| Continuous Integration | Every commit/PR is automatically built, tested, and quality-gated — catches integration problems early, before merge |
| Continuous Delivery | Every change that passes CI is automatically packaged into a deployable artifact (the container image, pushed to a registry) and is always in a deployable state — but a human still triggers the actual production deploy |
| Continuous Deployment | The fully automated extension of delivery — passing all gates triggers an automatic production deploy, no human approval step |
Most enterprise environments (and certainly anything regulated, or with the change-control discipline relevant to client-facing delivery engagements) land on Continuous Delivery, not full Continuous Deployment — automated up through “ready to deploy,” with an explicit approval gate before production. This is a deliberate risk/control trade-off, not a maturity gap — being able to articulate why full continuous deployment isn’t always the goal (audit/compliance requirements, change windows, the need for a human sign-off on customer-facing changes) is a stronger architect-level answer than assuming “more automation is always better.”
Quality gates worth wiring into the pipeline, beyond just “tests pass”:
- Code coverage threshold (with the caveat that coverage percentage alone is a weak proxy for test quality — a gate, not the goal)
- Static analysis (SonarQube/Checkstyle) for code smells, security hotspots
- Dependency vulnerability scanning (
mvn dependency-check, Snyk, or container-layer scanning via Trivy) — catching a vulnerable transitive dependency before it ships is materially cheaper than patching it in production later - Container image scanning post-build, as shown above
Cloud Deployment Patterns
Deployment strategies — the actual decision architects make per-service based on risk tolerance and rollback needs:
| Strategy | Mechanism | Rollback speed | Resource cost during deploy |
|---|---|---|---|
| Rolling update | Replace instances gradually, old version serves traffic until new ones are healthy | Moderate — must roll forward through the same gradual process in reverse | Low — no duplicate full fleet needed |
| Blue-Green | Two complete environments; traffic switches atomically from old (blue) to new (green) at the LB/DNS level | Instant — flip traffic back to blue | High — briefly running two full production-sized environments |
| Canary | Small % of traffic routed to new version first, gradually increased while monitoring error rates/latency | Fast — pull the canary, 100% of traffic was never at risk | Low — only a small canary fleet alongside the main one |
| Feature flags / dark launch | New code deployed but gated behind a runtime flag, decoupling deploy from release entirely | Instant — flip the flag, no redeploy needed | Minimal — same fleet, conditional logic |
# Kubernetes rolling update — the default Deployment strategy
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # at most 1 pod down during the rollout
maxSurge: 1 # at most 1 extra pod beyond desired count during rollout
Architect-level decision framing, since “which strategy” is a near-guaranteed system-design interview thread:
- Rolling update is the sensible default for most internal/low-risk services — K8s does it natively with zero extra infrastructure.
- Canary is the right call for high-traffic, customer-facing services where a bad deploy needs to be caught against a small blast radius before it reaches everyone — requires solid metrics/alerting (tying back to the Prometheus/Grafana stack) to actually detect a canary regression automatically rather than relying on someone watching a dashboard.
- Blue-Green suits services where instant, guaranteed rollback matters more than resource efficiency, and where database schema compatibility across both versions can be guaranteed (a blue-green deploy that also changes the DB schema incompatibly breaks the rollback guarantee entirely — a frequently overlooked caveat).
- Feature flags decouple “deployed” from “live to users” entirely — the strongest tool for de-risking large changes, but adds code complexity (flag-branching logic, eventual flag cleanup debt) that needs active management or it accumulates as permanent tech debt.
Health-check integration is what actually makes rolling/canary deploys safe — tying directly back to the Actuator readiness probe discussed earlier: a rolling update that doesn’t gate on readiness will route live traffic to a pod that’s started but not actually ready (DB pool not yet warmed, cache not populated), causing a wave of errors during every deploy. This is the concrete link between the Actuator/observability topics and deployment safety — they’re not separate concerns, readiness probes are the deployment safety mechanism.
12-factor app principles most relevant to Spring Boot specifically, since the earlier topics (config externalization, profiles, statelessness for horizontal scaling) all trace back to this:
- Config in environment, not code — directly ties to the property-source precedence and Config Server topics covered earlier; baking environment-specific config into the jar defeats the entire point of building one immutable artifact promoted across environments.
- Stateless processes — session state belongs in Redis/a shared store, not in-memory on the instance, precisely so any instance can be killed/replaced/scaled without losing user state (directly relevant to rolling updates and autoscaling both).
- Disposability — fast startup and graceful shutdown matter architecturally because they’re what makes rolling deploys and autoscaling actually safe and fast; a service that takes 90 seconds to become ready, or doesn’t drain in-flight requests on shutdown, undermines every deployment strategy discussed above regardless of how well the pipeline itself is configured.
// Graceful shutdown — let in-flight requests finish before the JVM exits
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Without graceful shutdown configured, a pod termination (during a rolling update, scale-down, or node eviction) can drop in-flight requests mid-response — a subtle source of intermittent client-visible errors during otherwise “successful” deploys, and worth calling out explicitly as a checklist item in any cloud deployment pattern discussion.