Config and Deployment

A Spring Boot service that only runs on your MacBook is a hobby project. A Spring Boot service that runs identically on your laptop, in CI, in staging, and in production — with different databases, secrets, and feature flags each place — is a product. The gap between those two is 90% config discipline and 10% container hygiene. This file covers both.

The modern deployment target is a container image running on Kubernetes (or a PaaS that shells out to K8s under the hood: Fly.io, Render, Cloud Run). Everything in this file assumes that reality.


1. Externalized Configuration — The Twelve-Factor Discipline

Rule zero: anything that changes between environments comes from the environment, not from a file in the repo.

The canonical order (highest wins) in Spring Boot:

  1. Command-line args

  2. SPRING_APPLICATION_JSON env var

  3. Individual env vars (SPRING_DATASOURCE_URL, APP_JWT_SECRET, etc.)

  4. application-{profile}.yml on classpath

  5. application.yml on classpath

Strategy per config type:

What

Where

Why

DB URL / hostnames

Env vars

Different per env

Feature flags

Env vars OR a config service (LaunchDarkly, Unleash)

Toggle without redeploy

API keys, DB passwords

Secret manager (Vault, K8s Secret, AWS Secrets Manager) mounted as env vars

Never in Git, never in logs

Timeouts, pool sizes, log levels

application.yml with env-var override

Sensible defaults, env can tune

Business rules

Code

If it changes without a code review, it’s not a business rule

Env-var mapping

Spring auto-maps env vars to properties: SPRING_DATASOURCE_URLspring.datasource.url. Uppercase, underscore-separated, dot-boundaries. Custom properties work too: APP_JWT_SECRETapp.jwt.secret.

app:
  jwt:
    secret: ${APP_JWT_SECRET:default-only-for-dev}
  cors:
    allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000}

The : provides a default. Use it for dev-only values. Never put a real secret as a default — make the app fail to start if the secret isn’t provided.


2. Secrets: Where They Actually Live

Approach

Reality

Env var directly

Works for local dev. In production, only if injected by K8s/PaaS from a secret store.

.env file loaded at boot

Fine for dev with direnv or dotenv-java. Never commit .env.

K8s Secret (base64 in etcd)

Baseline for K8s. Enable encryption at rest.

HashiCorp Vault

Enterprise standard. Spring Cloud Vault mounts secrets as PropertySources.

AWS Secrets Manager / GCP Secret Manager / Azure Key Vault

Cloud-native. Spring Cloud AWS / GCP / Azure connectors.

Sealed Secrets / SOPS

GitOps-friendly — encrypted in Git, decrypted in cluster. Nice with ArgoCD.

Reality check for a solo dev / small team: K8s Secrets + SOPS in Git is the sweet spot. Vault is overkill until you have >50 services.


3. Dockerfile: Multi-Stage, Slim, Correct

Don’t ship a openjdk:21 image with a fat jar copied in. Multi-stage builds cut image size by 60-80%:

# ---- Build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src ./src
RUN ./mvnw package -DskipTests -B
RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

# ---- Runtime stage ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
USER app
# Boot 2.3+ layered jars — cache-friendly ordering
COPY --from=build /app/target/extracted/dependencies/ ./
COPY --from=build /app/target/extracted/spring-boot-loader/ ./
COPY --from=build /app/target/extracted/snapshot-dependencies/ ./
COPY --from=build /app/target/extracted/application/ ./

EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -XshareAuto"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Why each piece matters:

  • dependency:go-offline before COPY src so the maven cache layer only busts when pom.xml changes.

  • Layered jars (spring-boot-maven-plugin supports this by default) split the image so dependency changes don’t invalidate the app-code layer.

  • Non-root user (app) — K8s security policies increasingly forbid root containers.

  • -XX:MaxRAMPercentage=75 — container-aware JVM. Since Java 10+ this is respected in containers, but the flag makes intent explicit.

  • -XX:+ExitOnOutOfMemoryError — fail fast, let K8s restart. Better than a zombie process serving 500s.

  • -XshareAuto — enables Class Data Sharing (CDS) if available. Java 21 CDS + Boot 3.3+ Application CDS cuts startup by 40-50%.

Class Data Sharing (CDS) — the boring startup win

Spring Boot 3.3+ supports Application CDS out of the box. It’s the pragmatic middle ground between vanilla JVM and GraalVM native-image:

RUN java -Dspring.context.exit=onRefresh -jar app.jar   # generate the AOT cache during image build

Results from field data: JVM startup drops from 4-5s to 2-3s with zero code changes and no GraalVM headaches. Do this before you consider native-image.

GraalVM native-image (Spring Boot Native)

./mvnw -Pnative native:compile

Produces a native binary. Startup: 40-120ms. Peak throughput: 10-25% lower than JIT-tiered JVM. Memory footprint: significantly lower.

When it’s worth it:

  • Serverless (Cloud Run, Lambda SnapStart) where cold-start dominates cost.

  • Kubernetes services with aggressive readiness SLAs (< 2s).

  • CLI tools written in Spring Boot.

When it’s NOT worth it:

  • Long-running services where startup is a rounding error and steady-state throughput matters.

  • Codebases with heavy reflection / dynamic proxies without proper GraalVM metadata.

  • Team without GraalVM experience — debugging native crashes is measurably harder.

Honest recommendation: default to JVM + CDS. Reach for native-image only when the SLA demands it or you’re on serverless.


4. Kubernetes Deployment Shape

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shortener
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels: { app: shortener }
  template:
    metadata:
      labels: { app: shortener }
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/actuator/prometheus"
    spec:
      containers:
        - name: shortener
          image: registry.example.com/shortener:1.2.3
          ports: [{ containerPort: 8080 }]
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: SPRING_DATASOURCE_URL
              valueFrom: { secretKeyRef: { name: shortener-db, key: url } }
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom: { secretKeyRef: { name: shortener-db, key: password } }
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits:   { cpu: 1000m, memory: 1Gi }
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          lifecycle:
            preStop:
              exec: { command: ["sh","-c","sleep 15"] }   # let load balancer notice we're going away

Key ideas:

  • Three probes: startupProbe (relaxed during boot), livenessProbe (restart if JVM hangs), readinessProbe (remove from load balancer during startup/shutdown).

  • Graceful shutdown chain: server.shutdown: graceful in application.yml + spring.lifecycle.timeout-per-shutdown-phase: 30s + preStop sleep. Together, in-flight requests finish; new ones don’t arrive.

  • Resource requests set = limits for memory. Prevents OOMKilled surprises. CPU limit is more nuanced (throttling), but memory hard-limits are essential.

  • Prometheus scrape via annotations if you use prometheus-operator’s Prometheus. Otherwise a ServiceMonitor.


5. Graceful Shutdown — The Detail Everyone Misses

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

What happens on SIGTERM:

  1. Spring stops accepting new HTTP connections.

  2. In-flight requests get up to 30s to finish.

  3. @PreDestroy runs (close Kafka producers, flush caches).

  4. HikariCP closes connections cleanly.

  5. JVM exits.

Without this, K8s SIGTERM interrupts in-flight requests — users see 500s. Ship this on day one, not “later.”


6. CI/CD — The Minimum Viable Pipeline

# .github/workflows/build.yml
name: build
on:
  push:
    branches: [main]
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      docker:
        image: docker:24-dind
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
      - run: ./mvnw -B verify
      - uses: docker/build-push-action@v5
        if: github.ref == 'refs/heads/main'
        with:
          push: true
          tags: registry.example.com/shortener:${{ github.sha }},registry.example.com/shortener:latest

Add on top: Snyk / Trivy for image scanning, SonarQube / SonarCloud for static analysis, and Testcontainers Cloud if CI Docker gets flaky.


7. Where to Actually Deploy (Cheapest Path to Public URL)

Platform

Fit

Notes

Fly.io

1-service demo

fly launch reads Dockerfile, deploys globally. Free tier. Best DX for a Java portfolio project.

Render

Similar to Fly

Auto-detects Docker, free web-service tier (spins down).

Google Cloud Run

Serverless containers

Great with native-image. Pay-per-request.

Railway

Full-stack side project

Includes managed Postgres/Redis. Simple.

AWS ECS/Fargate

Enterprise reality

More setup, but the standard MNC target. Do this once for the resume line.

DigitalOcean App Platform

Simple, moderate cost

Fine for a single microservice.

Kubernetes on any cloud

Team-scale

Learn the manifests locally with kind or minikube first.

For the M13 portfolio project: ship to Fly.io or Render first (fast, public URL, screenshot-able), then re-deploy to K8s on kind locally to prove you can do the harder path.


8. Config & Deployment Sins

  • Committing application-prod.yml with real values. Delete it. Use env vars.

  • Skipping readinessProbe — K8s sends traffic during startup, users see 502s.

  • Xmx hardcoded in Dockerfile in an era of container-aware JVM (Java 10+). Use -XX:MaxRAMPercentage.

  • Fat single-layer image — no docker layer caching, CI takes forever.

  • Same image tag :latest in prod — rollback is impossible. Tag by git SHA or semver.

  • spring.profiles.active set in the image — that’s an env var, not a build-time constant.

  • Secrets in application.yml even “just for staging.” That’s how prod-secrets end up in staging Git history.

  • No health-check timeout on downstream calls — one slow dependency and the pod is marked unhealthy.


Practice Exercises

  1. Write a multi-stage Dockerfile for your Spring Boot service. Compare image size to a naive FROM openjdk:21 + COPY jar version.

  2. Enable Spring Boot CDS. Measure startup time with and without it.

  3. Deploy the service to Fly.io. Screenshot the health endpoint at a public URL.

  4. Write K8s manifests, deploy to kind locally, and verify rolling updates cause zero dropped requests (use k6 or hey to hammer during rollout).

  5. Stretch: Build a GraalVM native-image variant. Compare startup, memory, and steady-state throughput vs the JVM build.


Return to README.md · Previous: 06_observability_metrics_logs_traces.md · Next: projects.md