☸️ To Production and Beyond: Kubernetes Deployment with Helm

A Docker container without orchestration is a boat without navigation. This article covers the HELM framework — Kubernetes deployments, Helm chart templating, horizontal auto-scaling, network policies, pod security contexts, and the monitoring integration that ties the entire series together.

Kubernetes deployment with Helm charts — Spring Boot microservice to production
📚 Part of the series: Spring Boot Microservices

📚 Series Navigation:
Previous: Part 12 - Ship It
👉 You are here: Part 13 - To Production and Beyond (Final)


📋 Introduction

Congratulations. You've made it. Thirteen articles, thousands of lines of code, and one fully-architected Weather Microservice later, we're at the final chapter. And it's the one that matters most — because none of the code we've written means anything if it can't run reliably in production.

Docker gave us a portable container. But Docker alone is like having a shipping container with no port, no crane, and no ship. Sure, the container is perfect — but it's sitting in a parking lot. Kubernetes is the entire logistics system: the port, the ships, the routing, the scheduling, and the automatic "if that ship sinks, put the cargo on another one."

But Kubernetes YAML is... let's be honest... verbose. A simple deployment can easily produce 300 lines of YAML across 6 files. Change one value and you have to update it in three places. Deploy to a different environment and you need a completely new set of files. This is where Helm enters the picture — a package manager for Kubernetes that lets you template, version, and share your deployment configuration.

In this final article, we'll deploy the Weather Microservice to Kubernetes using a production-grade Helm chart. We'll configure horizontal autoscaling that grows and shrinks with traffic, network policies that lock down who can talk to whom, pod security contexts that prevent privilege escalation, and a CI pipeline that builds, tests, and packages everything on every commit.

This is the finish line. Let's cross it together. ☕


☸️ The HELM Framework

Our deployment strategy follows the HELM framework:

Letter Principle Description
H Horizontal Scaling Auto-scale pods based on CPU/memory with intelligent behavior policies
E Environment Configuration Helm values externalize all environment-specific settings
L Liveness/Readiness Probes Kubernetes knows when your app is alive, ready, and starting
M Monitoring Integration Prometheus annotations, ServiceMonitor, and Zipkin tracing built in

🔥 Critical Insight: A Helm chart isn't just a deployment tool — it's documentation. A well-structured values.yaml tells any engineer exactly what your service needs, how it scales, and what it connects to. If you can't understand a service from its Helm chart, the chart needs work.


🔬 The Helm Chart Structure

Starting with the chart definition:

# Chart.yaml
apiVersion: v2
name: weatherspring
description: Helm chart for WeatherSpring microservice - Spring Boot weather API with H2 database
type: application
version: 1.0.0
appVersion: "1.0.0"
keywords:
  - weather
  - spring-boot
  - microservice
  - rest-api
  - java
maintainers:
  - name: Robert Saveanu

apiVersion: v2 — Helm 3 chart format. Helm 2 is long EOL.

version vs appVersionversion is the chart version (the deployment template). appVersion is the application version (your Spring Boot JAR). They evolve independently — you might update the chart (to change resource limits) without changing the application.

The chart directory structure:

helm/weatherspring/
+-- Chart.yaml              # Chart metadata
+-- values.yaml             # Default configuration (465 lines)
+-- values-minikube-windows.yaml  # Minikube overrides
+-- templates/
|   +-- _helpers.tpl        # Template helper functions
|   +-- deployment.yaml     # Pod specification
|   +-- service.yaml        # Service (ClusterIP/NodePort)
|   +-- ingress.yaml        # Ingress rules
|   +-- hpa.yaml            # Horizontal Pod Autoscaler
|   +-- networkpolicy.yaml  # Network security rules
|   +-- configmap.yaml      # Application configuration
|   +-- secret.yaml         # Sensitive data
|   +-- serviceaccount.yaml # RBAC identity
|   +-- servicemonitor.yaml # Prometheus integration
|   +-- pvc.yaml            # Persistent storage
|   +-- poddisruptionbudget.yaml  # Availability guarantee
|   +-- zipkin-deployment.yaml    # Zipkin tracing backend
|   +-- zipkin-service.yaml       # Zipkin service
|   +-- tests/
|       +-- test-connection.yaml  # Helm test

That's a lot of templates. We'll focus on the ones that matter most for production readiness.


🏗️ The Deployment: Where Pods Come to Life

The deployment template is the heart of the Helm chart — it defines how your application runs in Kubernetes:

# deployment.yaml (simplified for clarity)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "weatherspring.fullname" . }}
  labels:
    {{- include "weatherspring.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  revisionHistoryLimit: {{ .Values.revisionHistoryLimit }}
  strategy:
    {{- toYaml .Values.strategy | nindent 4 }}

Conditional replicas — When autoscaling is enabled, the HPA controls replica count. Specifying replicas in the Deployment would conflict with the HPA. The if not conditional omits it when autoscaling is active.

revisionHistoryLimit: 3 — Kubernetes keeps old ReplicaSets for rollback. Default is 10, which is excessive for most services. Three versions of rollback history is plenty.

Rolling Update Strategy

# values.yaml
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0

maxSurge: 1 — During deployment, Kubernetes can create one extra pod beyond the desired count. New pod starts, health check passes, old pod terminates.

maxUnavailable: 0 — Zero pods can be unavailable during deployment. This means the new pod must be healthy before the old pod is killed. Zero downtime deployment, guaranteed.

The tradeoff: with maxUnavailable: 0, deployments take longer because Kubernetes waits for the new pod's readiness probe to pass before terminating the old one. For a Spring Boot app with a 30-second startup, this adds ~30 seconds to each rolling update step. Worth it for zero downtime.

Pod Security Context

# values.yaml
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 1000

This is defense in depth for containers:

Setting What It Does Why It Matters
runAsNonRoot: true Kubernetes rejects the pod if it tries to run as root Even if the Dockerfile's USER directive is removed, Kubernetes blocks it
runAsUser: 1000 Sets the UID to 1000 (our spring user) Consistent, non-root UID
fsGroup: 1000 Files on volumes are owned by group 1000 The application can write to mounted volumes
seccompProfile: RuntimeDefault Applies the default seccomp profile Restricts system calls to a safe subset
allowPrivilegeEscalation: false Prevents setuid/setgid binaries from gaining privileges Blocks privilege escalation attacks
capabilities.drop: ALL Drops all Linux capabilities No NET_RAW, no SYS_ADMIN, no CHOWN — nothing
readOnlyRootFilesystem: true Container filesystem is read-only Attackers can't write web shells or modify binaries

readOnlyRootFilesystem: true needs writable volumes for /tmp, /app/logs, and /data:

# deployment.yaml
volumeMounts:
- name: config
  mountPath: /app/config
  readOnly: true
- name: data
  mountPath: {{ .Values.persistence.mountPath }}
- name: tmp
  mountPath: /tmp
- name: logs
  mountPath: /app/logs

volumes:
- name: tmp
  emptyDir: {}
- name: logs
  emptyDir: {}

The tmp and logs volumes are emptyDir — ephemeral storage that gets wiped when the pod restarts. The data volume uses a PersistentVolumeClaim for durable H2 database storage.

Environment Variables and Secrets

# deployment.yaml
env:
- name: SPRING_PROFILES_ACTIVE
  value: {{ .Values.application.springProfile | quote }}
- name: WEATHER_API_KEY
  valueFrom:
    secretKeyRef:
      name: {{ include "weatherspring.fullname" . }}
      key: weather-api-key
- name: DATABASE_USERNAME
  valueFrom:
    secretKeyRef:
      name: {{ include "weatherspring.fullname" . }}
      key: db-username
- name: JAVA_OPTS
  value: "-Xms{{ .Values.javaOpts.xms }} {{ .Values.javaOpts.other }}"

Configuration goes in ConfigMaps. Secrets go in Secrets. Never the other way around.

The JAVA_OPTS are assembled from values, giving operators control over JVM tuning without modifying the chart:

# values.yaml
javaOpts:
  xms: "512m"
  other: "-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseStringDeduplication -Djava.security.egd=file:/dev/./urandom"

MaxRAMPercentage=75.0 — Instead of hardcoded -Xmx, this tells the JVM to use 75% of the container's memory limit as max heap. If you change the memory resource limit from 1Gi to 2Gi, the heap automatically adjusts. No chart change needed.

Probes: Liveness, Readiness, and Startup

# values.yaml
probes:
  liveness:
    httpGet:
      path: /actuator/health/liveness
      port: http
    initialDelaySeconds: 60
    periodSeconds: 10
    timeoutSeconds: 3
    failureThreshold: 3
  readiness:
    httpGet:
      path: /actuator/health/readiness
      port: http
    initialDelaySeconds: 30
    periodSeconds: 5
    timeoutSeconds: 3
    failureThreshold: 3
  startup:
    httpGet:
      path: /actuator/health
      port: http
    initialDelaySeconds: 10
    periodSeconds: 5
    timeoutSeconds: 3
    failureThreshold: 30

Three probes, three different questions:

Startup Probe — "Has the application finished starting?" Checked every 5 seconds, with up to 30 failures allowed (150 seconds max startup time). Until this passes, Kubernetes doesn't check liveness or readiness. This prevents slow-starting apps from being killed during initialization.

Readiness Probe — "Can this pod accept traffic?" Uses /actuator/health/readiness, which checks that database connections are available, caches are warm, etc. If this fails, the pod is removed from the Service endpoint — traffic stops flowing to it, but the pod isn't killed. Perfect for temporary issues like database maintenance.

Liveness Probe — "Is this pod fundamentally broken?" Uses /actuator/health/liveness, which checks that the application isn't deadlocked or in an unrecoverable state. If this fails 3 times, Kubernetes kills the pod and restarts it. This is the nuclear option — only use it for truly fatal conditions.

🤔 Design decision: Why separate liveness and readiness URLs? Because a pod can be alive but not ready (e.g., waiting for a database migration to complete). Killing it wouldn't help — it would just restart and wait again. By separating the probes, Kubernetes knows the difference between "temporarily busy" and "permanently broken."

Graceful Shutdown

# values.yaml
terminationGracePeriodSeconds: 60

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 10"]

When Kubernetes decides to terminate a pod (during scaling down, deployment, or node maintenance):

  1. Pod is removed from Service endpoints (no new traffic)
  2. preStop hook runs: sleep 10 (allows in-flight requests to drain from load balancers)
  3. SIGTERM is sent to the JVM
  4. Spring Boot's graceful shutdown completes in-flight requests
  5. If the pod hasn't stopped after 60 seconds, SIGKILL forces termination

The sleep 10 in the preStop hook is a known pattern. Even after Kubernetes removes a pod from the Service, some load balancers take a few seconds to update their routing tables. The sleep ensures no traffic arrives at a shutting-down pod.

Pod Anti-Affinity

# values.yaml
affinity:
  podAntiAffinity:
    enabled: true
    type: preferred
    weight: 100
    topologyKey: kubernetes.io/hostname

Anti-affinity says "don't schedule two pods of the same service on the same node." If a node crashes, you lose at most one pod instead of all of them.

type: preferred (soft) — Kubernetes prefers separate nodes but will schedule on the same node if no other option exists. This is better than required (hard) which would leave pods unscheduled if there aren't enough nodes.

Config Checksums for Automatic Rollouts

# deployment.yaml
metadata:
  annotations:
    checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}

This is a clever Helm pattern. Kubernetes doesn't restart pods when a ConfigMap or Secret changes — it only restarts when the Deployment spec changes. By including a SHA256 checksum of the ConfigMap and Secret as annotations, any configuration change produces a different checksum, which changes the Deployment spec, which triggers a rolling update.

Change a config value → checksum changes → Deployment updated → pods roll automatically. No manual restart needed.


📈 Horizontal Pod Autoscaler

The HPA automatically adjusts the number of pods based on resource utilization:

# hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "weatherspring.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "weatherspring.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
    {{- end }}
  {{- with .Values.autoscaling.behavior }}
  behavior:
    {{- toYaml . | nindent 4 }}
  {{- end }}
{{- end }}

The Scaling Configuration

# values.yaml
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 5
  targetCPUUtilizationPercentage: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
        - type: Pods
          value: 1
          periodSeconds: 60
      selectPolicy: Min
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
        - type: Pods
          value: 2
          periodSeconds: 30
      selectPolicy: Max

minReplicas: 2 — Always run at least 2 pods. If one dies, the other handles traffic while Kubernetes replaces it. This is your availability guarantee.

maxReplicas: 5 — Upper limit prevents runaway scaling (and runaway cloud bills). A weather service doesn't need 100 pods — if you're hitting that kind of load, you have bigger architectural questions.

targetCPUUtilizationPercentage: 70 — Scale up when average CPU across pods exceeds 70%. This leaves headroom for traffic spikes before scaling kicks in.

Asymmetric Scaling Behavior

The behavior section implements asymmetric scaling — fast scale-up, slow scale-down:

Scale Up: Aggressive

  • stabilizationWindowSeconds: 0 — Scale up immediately when needed
  • Two policies with selectPolicy: Max — Use the more aggressive policy
  • Can add up to 2 pods or 100% more pods every 30 seconds

Scale Down: Conservative

  • stabilizationWindowSeconds: 300 — Wait 5 minutes of stable low usage before scaling down
  • Two policies with selectPolicy: Min — Use the less aggressive policy
  • Can remove at most 1 pod or 50% of pods every 60 seconds

Why asymmetric? Because the cost of under-scaling (dropped requests, high latency) is much higher than over-scaling (a few extra pods for a few minutes). Scale up fast to handle spikes, scale down slowly to avoid flapping.

Pro tip: The 5-minute stabilization window prevents the "scale down, spike hits, scale up, spike ends, scale down, spike hits again" oscillation. Real traffic is bursty — give the cluster time to settle before removing capacity.


🔒 Network Policy: Zero Trust Networking

By default, Kubernetes pods can communicate with any other pod in the cluster. That's convenient for development but terrifying for production. Network policies implement a zero-trust model:

# networkpolicy.yaml
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ include "weatherspring.fullname" . }}
spec:
  podSelector:
    matchLabels:
      {{- include "weatherspring.selectorLabels" . | nindent 6 }}
  policyTypes:
    - Ingress
    - Egress

Ingress Rules: Who Can Talk To Us

  ingress:
    # Allow traffic from ingress controller
    - from:
      - namespaceSelector:
          matchLabels:
            kubernetes.io/metadata.name: ingress-nginx
      ports:
      - protocol: TCP
        port: {{ .Values.service.targetPort }}
    # Allow traffic from pods in the same namespace
    - from:
      - podSelector: {}
      ports:
      - protocol: TCP
        port: {{ .Values.service.targetPort }}

Two ingress rules:

  1. The NGINX ingress controller can reach our app (for external traffic)
  2. Pods in the same namespace can reach our app (for health checks, monitoring)

Everything else is blocked. A compromised pod in another namespace can't reach the weather service.

Egress Rules: Who Can We Talk To

  egress:
    # Allow DNS resolution
    - to:
      - namespaceSelector:
          matchLabels:
            kubernetes.io/metadata.name: kube-system
      ports:
      - protocol: UDP
        port: 53
      - protocol: TCP
        port: 53
    # Allow HTTPS to external services (WeatherAPI.com)
    - to:
      - ipBlock:
          cidr: 0.0.0.0/0
          except:
            - 10.0.0.0/8
            - 172.16.0.0/12
            - 192.168.0.0/16
            - 169.254.0.0/16
            - 127.0.0.0/8
      ports:
      - protocol: TCP
        port: 443
      - protocol: TCP
        port: 80
    # Allow traffic to Zipkin
    {{- if .Values.zipkin.enabled }}
    - to:
      - podSelector:
          matchLabels:
            app.kubernetes.io/component: tracing
      ports:
      - protocol: TCP
        port: 9411
    {{- end }}

Three egress rules:

  1. DNS — The app needs to resolve hostnames. DNS runs in kube-system on port 53.

  2. External HTTPS — The app calls api.weatherapi.com over HTTPS. We allow port 443/80 to public IPs but exclude private IP ranges.
    This means the app can reach the internet but can't reach internal cluster services by IP — it has to go through Kubernetes service discovery.

  3. Zipkin — If tracing is enabled, allow connections to the Zipkin pods on port 9411.

Any other egress (SMTP to send emails? SSH to another server? Connecting to an unknown database?) is blocked. If the application is compromised, the attacker can't exfiltrate data to arbitrary destinations.


📊 Monitoring Integration

Prometheus Annotations

# values.yaml
podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/path: "/actuator/prometheus"

These annotations tell Prometheus to scrape metrics from the pod. Combined with the Prometheus endpoint we configured in Part 10, this provides automatic metric collection without any Prometheus configuration changes.

ServiceMonitor (Prometheus Operator)

# values.yaml
metrics:
  serviceMonitor:
    enabled: false  # Set to true if using Prometheus Operator
    interval: 30s
    scrapeTimeout: 10s

For clusters using the Prometheus Operator, the ServiceMonitor resource provides a more robust way to configure metric scraping. It's disabled by default because not every cluster has the Prometheus Operator installed.

Resource Limits

# values.yaml
resources:
  limits:
    cpu: 1000m
    memory: 1Gi
  requests:
    cpu: 500m
    memory: 512Mi

Requests — The guaranteed minimum resources. Kubernetes uses these for scheduling decisions. "This pod needs at least 500m CPU and 512Mi memory."

Limits — The maximum resources. If the pod tries to use more, CPU is throttled and memory triggers an OOM kill.

The 2:1 ratio (limits:requests) is a good default. It allows bursting during spikes while preventing resource hogging.


🚀 The CI Pipeline

The CI pipeline ties everything together — from code push to deployable artifact:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Set up JDK 25
      uses: actions/setup-java@v4
      with:
        java-version: '25'
        distribution: 'oracle'
        cache: maven

    - name: Build and Test with Coverage
      run: mvn clean verify

    - name: Upload coverage report
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: coverage-report
        path: target/site/jacoco/

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v4
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: ./target/site/jacoco/jacoco.xml

    - name: Package application
      run: mvn package -DskipTests

    - name: Upload JAR artifact
      uses: actions/upload-artifact@v4
      with:
        name: weather-service-jar
        path: target/weather-service-*.jar

Pipeline Stages

Checkout + Setup — Gets the code and configures Java 25 with Maven caching. The cache: maven directive caches ~/.m2/repository between runs, slashing dependency download time.

Build and Testmvn clean verify runs everything:

  1. Compile the code
  2. Run unit tests (Mockito)
  3. Run integration tests (MockMvc)
  4. Run architecture tests (ArchUnit)
  5. Generate JaCoCo coverage report
  6. Check JaCoCo 80% gate (fails the build if below)
  7. Run Checkstyle and Spotless formatting checks

If any step fails, the pipeline stops. No half-tested code makes it through.

Coverage Upload — Reports go to Codecov for tracking trends over time. The if: always() ensures reports are uploaded even if earlier steps fail — you want to see coverage even on a failing build.

Package + Artifact — The final JAR is packaged and uploaded as a build artifact. Downstream jobs (like Docker image building) can download it.

What the Pipeline Guarantees

When the CI pipeline passes, you know:

  • All tests pass (unit, integration, architecture)
  • Code coverage is above 80% on business logic
  • Code style follows the project's Checkstyle rules
  • Code is formatted consistently (Spotless)
  • The application compiles on Java 25
  • A deployable JAR is available as an artifact

🎯 Deploying the Chart

Installing to Minikube (Development)

# Start Minikube
minikube start

# Build the Docker image
eval $(minikube docker-env)
docker build -t weatherspring/weather-service:latest .

# Install the Helm chart
helm install weather ./helm/weatherspring \
  --set secrets.weatherApiKey=YOUR_API_KEY \
  --set image.pullPolicy=Never

# Check the deployment
kubectl get pods
kubectl get svc

Installing to Production

# Install with production values
helm install weather ./helm/weatherspring \
  --namespace production \
  --create-namespace \
  --set secrets.weatherApiKey=$WEATHER_API_KEY \
  --set application.springProfile=prod \
  --set autoscaling.minReplicas=3 \
  --set autoscaling.maxReplicas=10 \
  --set resources.requests.memory=1Gi \
  --set resources.limits.memory=2Gi

Upgrading

# Upgrade with new image
helm upgrade weather ./helm/weatherspring \
  --set image.tag=1.1.0

# Rollback if something goes wrong
helm rollback weather 1

Helm tracks every release as a revision. helm rollback weather 1 reverts to the first revision — the previous deployment spec, the previous ConfigMap, the previous everything. This is your safety net.


📝 The Production Deployment Checklist

Kubernetes Security

  • [ ] Pod runs as non-root user (runAsNonRoot: true)
  • [ ] All Linux capabilities dropped
  • [ ] No privilege escalation allowed
  • [ ] Read-only root filesystem with writable tmpfs/volumes
  • [ ] Seccomp profile applied
  • [ ] ServiceAccount token automounting disabled
  • [ ] Network policies restrict ingress and egress

Availability

  • [ ] Minimum 2 replicas (minReplicas: 2)
  • [ ] Pod anti-affinity spreads across nodes
  • [ ] PodDisruptionBudget guarantees minimum availability
  • [ ] Rolling update with maxUnavailable: 0
  • [ ] Graceful shutdown with preStop hook

Probes

  • [ ] Startup probe (allows slow startup)
  • [ ] Readiness probe (removes unhealthy pods from traffic)
  • [ ] Liveness probe (restarts truly broken pods)
  • [ ] Appropriate timeouts and thresholds for each

Autoscaling

  • [ ] HPA enabled with CPU metric
  • [ ] Asymmetric scaling (fast up, slow down)
  • [ ] Stabilization windows prevent flapping
  • [ ] Resource requests and limits defined

Observability

  • [ ] Prometheus annotations on pods
  • [ ] Zipkin tracing deployed and connected
  • [ ] Log volumes mounted for JSON logging
  • [ ] ServiceMonitor for Prometheus Operator clusters

CI/CD

  • [ ] Pipeline runs on push and PR
  • [ ] Tests + coverage gate + style checks
  • [ ] Artifacts uploaded (JAR, coverage report)
  • [ ] Codecov integration for coverage tracking

🎓 Conclusion: The Complete Picture

Over thirteen articles, we've built a complete, production-ready microservice from the ground up. One final look at the full picture:

  1. Architecture (Part 1) — SLICED framework: Layered architecture enforced by 13 ArchUnit rules.
    Controllers, services, repositories, mappers — each in its place, boundaries tested on every build.
  2. Configuration (Part 2) — PROPS framework: Profile-driven configuration with Spring Boot auto-config. Virtual threads enabled with one YAML line.
  3. API Design (Part 3) — CLEAR framework: REST APIs with multi-layer validation, composed constraint annotations, and OpenAPI documentation.
  4. Data Layer (Part 4) — FORGE framework: JPA entities with Flyway migrations, audit timestamps, and proper transaction propagation.
  5. Resilience (Part 5) — SHIELD framework: RestClient with circuit breaker, retry with exponential backoff, and rate limiting — all from Resilience4j.
  6. Caching (Part 6) — TEMPO framework: Three Caffeine cache regions with different TTLs and a custom @CacheEvictingOperation meta-annotation.
  7. Security (Part 7) — GUARD framework: Spring Security with RBAC by HTTP method, BCrypt password hashing, and CORS configuration.
  8. Error Handling (Part 8) — CRAFT framework: Sealed exception hierarchy, RFC 7807 ProblemDetail responses, and 14 exception handlers.
  9. Concurrency (Part 9) — ASYNC framework: Virtual threads, CompletableFuture composition, and async bulk processing with back-pressure.
  10. Observability (Part 10) — TRACE framework: Correlation IDs in every log line, Micrometer custom business metrics, and Zipkin distributed tracing.
  11. Testing (Part 11) — PYRAMID framework: Mockito unit tests, MockMvc integration tests, ArchUnit architecture tests, and JaCoCo 80% coverage gate.
  12. Containerization (Part 12) — DOCK framework: Multi-stage Docker builds, non-root users, Alpine JRE, and Docker Compose orchestration.
  13. Deployment (Part 13) — HELM framework: Horizontal autoscaling, network policies, pod security contexts, and CI pipeline.

The Frameworks

# Framework Focus
1 SLICED Architecture structure
2 PROPS Configuration management
3 CLEAR API design
4 FORGE Data persistence
5 SHIELD Resilience patterns
6 TEMPO Caching strategy
7 GUARD Security implementation
8 CRAFT Error handling
9 ASYNC Concurrency patterns
10 TRACE Observability
11 PYRAMID Testing strategy
12 DOCK Containerization
13 HELM Kubernetes deployment

Each framework is a checklist, a mental model, and a conversation starter. When someone asks "how should we handle caching?", you don't start from scratch — you start with TEMPO. When someone asks "how do we test this?", you start with PYRAMID.

What We've Really Built

Beyond the code and configuration, we've built something more important: a way of thinking about microservices. Every decision in the Weather Microservice was deliberate:

  • Security by default, not bolted on later
  • Observability from day one, not added during the first outage
  • Tests as documentation, not as busywork
  • Infrastructure as code, not as tribal knowledge

This isn't just a weather service. It's a template for every microservice you'll build next.

Your Turn

The Weather Microservice is open source on GitHub: Saveanu-Robert/Weather-Microservice. Fork it. Modify it. Break it and fix it. Replace H2 with PostgreSQL. Add WebSocket support. Deploy it to AWS EKS or Google GKE. The patterns you've learned in this series will guide you — not as rigid rules, but as proven starting points.

Thank you for reading all thirteen parts. Building production-ready software is hard. But you've just walked through every layer of it, from the first architecture decision to the final Kubernetes deployment. You're ready.

Now go build something amazing. ☕


📚 Series Progress

✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured
✅ Part 4: The Data Foundation
✅ Part 5: When the World Breaks
✅ Part 6: Cache Me If You Can
✅ Part 7: Guarding the Gates
✅ Part 8: Fail Gracefully
✅ Part 9: 10,000 Threads and a Dream
✅ Part 10: Can You See Me Now?
✅ Part 11: Trust, But Verify
✅ Part 12: Ship It
✅ Part 13: To Production and Beyond ← You just finished this!


Happy deploying, and may your pods always be ready and your rollbacks never needed.


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.