Skip to content
CalliCoder

Full-Stack Spring Boot on Kubernetes with Volumes

DevOps 14 min read

MySQL needs a PersistentVolumeClaim and a StatefulSet, a Secret is base64 rather than encrypted, and the React build has to be served by nginx that also proxies the API — with the routing decided at build time or not at all.

A three-tier stack on Kubernetes is where two things stop being optional: durable storage for the database, and a decision about how the browser reaches the API. Both have a default that works in a demo and fails in a cluster. An emptyDir that survives no restart, and a hard-coded API host that cannot be changed after the image is built.

Written against Kubernetes 1.29, Spring Boot 3.2 and MySQL 8.

Secrets are encoded, not encrypted

kubectl create secret generic mysql-credentials \
  --from-literal=root-password="$ROOT_PASSWORD" \
  --from-literal=username=app \
  --from-literal=password="$APP_PASSWORD"
apiVersion: v1
kind: Secret
metadata:
  name: mysql-credentials
type: Opaque
stringData:            # stringData takes plain text; data takes base64
  username: app
  password: change-me

stringData accepts plain values and Kubernetes encodes them; data requires base64 that you produce. Either way the stored value is base64, not encryption: kubectl get secret -o yaml plus base64 -d reveals it, and by default it sits unencrypted in etcd.

Three consequences worth acting on. Never commit a Secret manifest with real values: use stringData with a placeholder and create the real one out of band, or Sealed Secrets or an external secrets operator. Turn on encryption at rest in the API server if the cluster does not have it. And restrict access with RBAC, because any pod whose service account can read secrets in the namespace can read all of them.

MySQL needs a PersistentVolumeClaim

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-data
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: standard
  resources:
    requests:
      storage: 20Gi

A PVC is a request; the storageClassName names a provisioner that fulfils it. Omitting the field uses the cluster’s default class, which may not exist, the claim then stays Pending for ever, and the pod stays Pending with it, with no error explaining why.

kubectl get storageclass
kubectl describe pvc mysql-data

ReadWriteOnce means one node may mount it, not one pod. That is the constraint behind the rollout deadlock: a Deployment with the default RollingUpdate strategy starts the new pod before terminating the old one, and if it is scheduled to a different node the volume cannot attach. The new pod waits for the volume, the old pod waits to be replaced, and the rollout hangs.

For a database the answer is a StatefulSet, which replaces pods one at a time and gives each a stable identity and its own volume:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql
  replicas: 1
  selector:
    matchLabels: { app: mysql }
  template:
    metadata:
      labels: { app: mysql }
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          args: ["--default-authentication-plugin=mysql_native_password"]
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef: { name: mysql-credentials, key: root-password }
            - name: MYSQL_DATABASE
              value: appdb
            - name: MYSQL_USER
              valueFrom:
                secretKeyRef: { name: mysql-credentials, key: username }
            - name: MYSQL_PASSWORD
              valueFrom:
                secretKeyRef: { name: mysql-credentials, key: password }
          ports:
            - containerPort: 3306
          volumeMounts:
            - name: data
              mountPath: /var/lib/mysql
          readinessProbe:
            exec:
              command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
            initialDelaySeconds: 20
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        resources: { requests: { storage: 20Gi } }

volumeClaimTemplates creates one PVC per replica and, importantly, does not delete them when the StatefulSet is deleted. That is deliberate and occasionally surprising: removing the workload leaves the data, and recreating it reattaches.

The headless service that gives the pods stable DNS names:

apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  clusterIP: None
  selector: { app: mysql }
  ports: [{ port: 3306 }]

clusterIP: None is what makes mysql-0.mysql resolve to the pod rather than load-balancing across replicas, which for a database with one primary is the addressing you want.

Running MySQL in the cluster at all is a decision worth making explicitly. A managed database removes backups, failover and version upgrades from your responsibility, and those are the parts that matter at three in the morning.

The Spring Boot deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      containers:
        - name: api
          image: registry.example.com/api:1.4.0
          env:
            - name: SPRING_DATASOURCE_URL
              value: jdbc:mysql://mysql:3306/appdb
            - name: SPRING_DATASOURCE_USERNAME
              valueFrom:
                secretKeyRef: { name: mysql-credentials, key: username }
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom:
                secretKeyRef: { name: mysql-credentials, key: password }
          ports: [{ containerPort: 8080 }]
          resources:
            requests: { memory: 512Mi, cpu: 250m }
            limits:   { memory: 1Gi }
          startupProbe:
            httpGet: { path: /actuator/health, port: 8080 }
            failureThreshold: 30
            periodSeconds: 5
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }

The environment variable names are Spring’s relaxed binding, SPRING_DATASOURCE_URL maps to spring.datasource.url with no configuration file involved.

The three probes are three different questions. Startup gives a slow JVM time to boot without the liveness probe killing it; without it, a 40-second startup under a 30-second liveness threshold produces a pod that restarts for ever. Readiness removes a pod from the Service when it cannot serve, a lost database connection should fail readiness and not liveness, or every replica restarts together when the database blips. Liveness should only fail when a restart would genuinely help.

A memory limit without a request means the request equals the limit. More importantly, the JVM must be told about it, -XX:MaxRAMPercentage=75, because a container limit is not something the default heap sizing respects, and exceeding it is an OOMKill with exit code 137 rather than an OutOfMemoryError.

Database migrations are the other thing to decide here. Two replicas both running Flyway at startup race for the lock, and the loser can exceed its startup probe. An initContainer or a Job that migrates before the rollout is the cleaner shape.

The React front end

A React build is static files, and the API host has to be decided before the build:

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

That is the constraint people meet late: process.env.REACT_APP_API_URL is substituted at build time, so one image cannot be promoted from staging to production with a different API host.

The way out is not to have an API host at all. Serve the front end and proxy /api from the same nginx, and the browser only ever talks to one origin, which also removes CORS entirely:

server {
    listen 80;
    root /usr/share/nginx/html;

    location /api/ {
        proxy_pass http://api:8080/api/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html;    # client-side routing
    }
}

try_files ... /index.html is what makes a deep link work. Without it, refreshing on /articles/5 returns 404 because no such file exists, the route is a client-side concept.

The trailing slash on both sides of proxy_pass matters: proxy_pass http://api:8080/api/ with the location /api/ passes the path through unchanged, while omitting either produces a doubled or a stripped prefix.

Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: nginx
  tls:
    - hosts: [app.example.com]
      secretName: web-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: { name: web, port: { number: 80 } }

One host, one backend, and the API reached through the front end’s own proxy. Routing /api at the Ingress instead is also valid and puts the routing decision in the cluster rather than in the image — either is defensible, and doing both is how a request ends up proxied twice.

Related: Kubernetes for a single service, a stateful dependency and the same stack in Compose.

Frequently asked questions

Are Kubernetes Secrets encrypted?

No. They are base64-encoded and, by default, stored unencrypted in etcd. Enable encryption at rest, restrict access with RBAC, and keep real values out of version control.

What is the difference between data and stringData in a Secret?

data takes base64 you produce; stringData takes plain text and Kubernetes encodes it. Neither is more secure.

Why is my PersistentVolumeClaim stuck in Pending?

No storage class matched. Either the named class does not exist or the cluster has no default. kubectl describe pvc names the reason.

Why does my database rollout hang?

A Deployment with a ReadWriteOnce volume starts the new pod before removing the old one, and the volume cannot attach to a second node. Use a StatefulSet, or the Recreate strategy.

Does deleting a StatefulSet delete its volumes?

No. PVCs created from volumeClaimTemplates are kept deliberately, so recreating the StatefulSet reattaches the data.

Why does my pod restart during startup?

The liveness probe fired before the JVM finished booting. Add a startupProbe with a generous failureThreshold; liveness does not run until it passes.

Should a lost database connection fail liveness or readiness?

Readiness. Failing liveness restarts every replica at once for a problem a restart cannot fix.

Why is my Java container OOMKilled?

The JVM sized its heap for the node rather than the container. Set -XX:MaxRAMPercentage. Exit code 137 is the kernel killing it, not an OutOfMemoryError.

Why can’t I change the API URL in my React image?

It was substituted at build time. Proxy /api from the nginx serving the front end so there is no API host to configure.

Why does refreshing a React route return 404?

nginx looked for a file that does not exist. Add try_files $uri $uri/ /index.html so the client-side router receives the request.