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:
Command-line args
SPRING_APPLICATION_JSONenv varIndividual env vars (
SPRING_DATASOURCE_URL,APP_JWT_SECRET, etc.)application-{profile}.ymlon classpathapplication.ymlon 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 |
|
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_URL → spring.datasource.url. Uppercase, underscore-separated, dot-boundaries. Custom properties work too: APP_JWT_SECRET → app.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. |
|
Fine for dev with |
K8s |
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-offlinebeforeCOPY srcso the maven cache layer only busts whenpom.xmlchanges.Layered jars (
spring-boot-maven-pluginsupports 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: gracefulinapplication.yml+spring.lifecycle.timeout-per-shutdown-phase: 30s+preStopsleep. 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 aServiceMonitor.
5. Graceful Shutdown — The Detail Everyone Misses¶
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
What happens on SIGTERM:
Spring stops accepting new HTTP connections.
In-flight requests get up to 30s to finish.
@PreDestroyruns (close Kafka producers, flush caches).HikariCP closes connections cleanly.
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 |
|---|---|---|
1-service demo |
|
|
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 |
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.ymlwith real values. Delete it. Use env vars.Skipping
readinessProbe— K8s sends traffic during startup, users see 502s.Xmxhardcoded 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
:latestin prod — rollback is impossible. Tag by git SHA or semver.spring.profiles.activeset in the image — that’s an env var, not a build-time constant.Secrets in
application.ymleven “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¶
Write a multi-stage Dockerfile for your Spring Boot service. Compare image size to a naive
FROM openjdk:21 + COPY jarversion.Enable Spring Boot CDS. Measure startup time with and without it.
Deploy the service to Fly.io. Screenshot the health endpoint at a public URL.
Write K8s manifests, deploy to
kindlocally, and verify rolling updates cause zero dropped requests (use k6 or hey to hammer during rollout).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