Deployment & DevOps

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.